mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-07 08:31:00 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e38a725a8 | ||
|
|
dc35f73728 | ||
|
|
c471d7e250 | ||
|
|
18dd204014 |
No files matched your search
@@ -250,6 +250,88 @@ test("auto syncNow and debounce re-check manager autoSyncEnabled before pushing"
|
||||
);
|
||||
});
|
||||
|
||||
test("periodic convergent runtime checks skip the join when neither side changed", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const checkIndex = source.indexOf("const checkRemoteVersion = useCallback");
|
||||
const convergentGuardIndex = source.indexOf(
|
||||
"if (currentConvergentConfig.initialized && currentConvergentConfig.enabled)",
|
||||
checkIndex,
|
||||
);
|
||||
const gateIndex = source.indexOf("if (options?.periodic === true)", convergentGuardIndex);
|
||||
const allProvidersIndex = source.indexOf(
|
||||
"const convergentProviders = (Object.keys(state.providers) as CloudProvider[])",
|
||||
gateIndex,
|
||||
);
|
||||
const loopIndex = source.indexOf(
|
||||
"for (const provider of convergentProviders)",
|
||||
gateIndex,
|
||||
);
|
||||
const baselineIndex = source.indexOf(
|
||||
"manager.loadConvergentProviderBaseline(provider)",
|
||||
gateIndex,
|
||||
);
|
||||
const remoteMetaIndex = source.indexOf("baseline.remoteUpdatedAt", gateIndex);
|
||||
const hashDecisionIndex = source.indexOf("resolveAutoSyncHashDecision({", gateIndex);
|
||||
const skipReturnIndex = source.indexOf("if (hashDecision !== 'sync')", gateIndex);
|
||||
const buildPayloadIndex = source.indexOf(
|
||||
"const localPayload = await buildPayloadRef.current()",
|
||||
gateIndex,
|
||||
);
|
||||
const syncNowIndex = source.indexOf(
|
||||
"syncNowRef.current({ notifyOnFailure, allowEmptyConvergentSync })",
|
||||
gateIndex,
|
||||
);
|
||||
|
||||
assert.notEqual(checkIndex, -1);
|
||||
assert.notEqual(convergentGuardIndex, -1);
|
||||
assert.notEqual(gateIndex, -1);
|
||||
assert.notEqual(allProvidersIndex, -1);
|
||||
assert.notEqual(loopIndex, -1);
|
||||
assert.notEqual(baselineIndex, -1);
|
||||
assert.notEqual(remoteMetaIndex, -1);
|
||||
assert.notEqual(hashDecisionIndex, -1);
|
||||
assert.notEqual(skipReturnIndex, -1);
|
||||
assert.notEqual(buildPayloadIndex, -1);
|
||||
assert.notEqual(syncNowIndex, -1);
|
||||
assert.ok(
|
||||
allProvidersIndex < loopIndex && loopIndex < baselineIndex,
|
||||
"the no-op gate must inspect every connected provider, not only the preferred one",
|
||||
);
|
||||
assert.ok(
|
||||
gateIndex < baselineIndex && baselineIndex < remoteMetaIndex,
|
||||
"the no-op gate must compare the fresh remote meta against the verified provider baseline",
|
||||
);
|
||||
assert.ok(
|
||||
remoteMetaIndex < hashDecisionIndex && hashDecisionIndex < skipReturnIndex,
|
||||
"remote-unchanged must be paired with the local hash decision before skipping",
|
||||
);
|
||||
assert.ok(
|
||||
gateIndex < buildPayloadIndex && buildPayloadIndex < syncNowIndex,
|
||||
"an unchanged periodic check must skip the expensive payload build and CRDT join",
|
||||
);
|
||||
});
|
||||
|
||||
test("periodic runtime remote checks pass the periodic flag to checkRemoteVersion", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const runRuntimeIndex = source.indexOf("const runRuntimeRemoteCheck = useCallback");
|
||||
const callIndex = source.indexOf(
|
||||
"checkRemoteVersion({ force: true, notifyOnFailure: false, periodic: true })",
|
||||
runRuntimeIndex,
|
||||
);
|
||||
const retryEffectIndex = source.indexOf(
|
||||
"checkRemoteVersionRef.current(\n notifyOnFailure ? undefined : { notifyOnFailure: false },\n );",
|
||||
);
|
||||
|
||||
assert.notEqual(runRuntimeIndex, -1);
|
||||
assert.notEqual(callIndex, -1);
|
||||
assert.notEqual(retryEffectIndex, -1);
|
||||
assert.equal(
|
||||
source.slice(retryEffectIndex, retryEffectIndex + 200).includes("periodic"),
|
||||
false,
|
||||
"startup retries must stay unmarked so the no-op gate cannot suppress reconciliation",
|
||||
);
|
||||
});
|
||||
|
||||
test("startup local-wins and merge round-trips refuse device-bound credential placeholders", () => {
|
||||
const source = readFileSync(new URL("./useAutoSync.ts", import.meta.url), "utf8");
|
||||
const uploadLocalIndex = source.indexOf("if (conflictAction === 'upload-local')");
|
||||
|
||||
@@ -174,6 +174,8 @@ interface SyncNowOptions {
|
||||
interface RemoteVersionCheckOptions {
|
||||
force?: boolean;
|
||||
notifyOnFailure?: boolean;
|
||||
/** True when the invocation is a periodic runtime check, not startup reconciliation. */
|
||||
periodic?: boolean;
|
||||
}
|
||||
|
||||
export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
@@ -681,6 +683,83 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
};
|
||||
try {
|
||||
if (currentConvergentConfig.initialized && currentConvergentConfig.enabled) {
|
||||
// Periodic runtime checks (#3527): a converged provider must not run a
|
||||
// full CRDT join cycle every timer tick when nothing changed on either
|
||||
// side. Every join flips the UI into SYNCING, records a merge history
|
||||
// entry, persists the replica, and can publish a fresh cloud revision
|
||||
// whenever its verification pass misses — so an unchanged vault plus an
|
||||
// unchanged remote must short-circuit, the same way the legacy branch
|
||||
// short-circuits on `remoteChanged` below. Pulls still happen: if the
|
||||
// remote moved since the last verified baseline, the gate falls
|
||||
// through to the full join.
|
||||
if (options?.periodic === true) {
|
||||
// The convergent runtime joins every connected provider
|
||||
// (`connectedProviders` in convergentSyncRuntimeMethods), so the
|
||||
// short-circuit must establish that ALL connected providers are
|
||||
// unchanged — not just the preferred one picked above. Checking a
|
||||
// single provider would suppress the join while another provider's
|
||||
// remote moved, never pulling those changes on periodic ticks.
|
||||
const convergentProviders = (Object.keys(state.providers) as CloudProvider[])
|
||||
.filter((id) => isProviderReadyForSync(state.providers[id]));
|
||||
let convergentRemoteUnchanged = convergentProviders.length > 0;
|
||||
try {
|
||||
for (const provider of convergentProviders) {
|
||||
const baseline = await manager.loadConvergentProviderBaseline(provider);
|
||||
if (!baseline) {
|
||||
convergentRemoteUnchanged = false;
|
||||
break;
|
||||
}
|
||||
const inspection = await manager.inspectProviderRemote(provider);
|
||||
const meta = inspection.remoteFile?.meta;
|
||||
const providerUnchanged = Boolean(
|
||||
meta
|
||||
&& meta.version === baseline.remoteVersion
|
||||
&& meta.updatedAt === baseline.remoteUpdatedAt
|
||||
&& (meta.deviceId ?? null) === (baseline.remoteDeviceId ?? null),
|
||||
);
|
||||
if (!providerUnchanged) {
|
||||
convergentRemoteUnchanged = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Inspection of an earlier provider may throw while later
|
||||
// providers are still reachable; clearing the flag here matches
|
||||
// the warning below and lets the full join cycle run so those
|
||||
// providers are not silently skipped on this tick.
|
||||
convergentRemoteUnchanged = false;
|
||||
console.warn(
|
||||
'[AutoSync] Convergent remote-unchanged pre-check failed; falling back to a full sync cycle:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
if (convergentRemoteUnchanged) {
|
||||
// A prior convergent cycle can leave one provider stale
|
||||
// (`pendingLocalSync` stays set after a transient upload or
|
||||
// verification failure) while every remote still matches its
|
||||
// per-provider baseline. Skipping the join on that condition would
|
||||
// never retry the stale provider, so only short-circuit when the
|
||||
// runtime reports no pending convergence.
|
||||
const pendingConvergence = manager.getState().pendingLocalSync === true;
|
||||
if (!pendingConvergence) {
|
||||
const currentHash = await getDataHashRef.current();
|
||||
const hashDecision = resolveAutoSyncHashDecision({
|
||||
currentHash,
|
||||
lastSyncedHash: lastSyncedDataRef.current,
|
||||
appliedSkipHash: skipNextSyncHashRef.current,
|
||||
});
|
||||
if (hashDecision !== 'sync') {
|
||||
if (hashDecision === 'skip-applied' && skipNextSyncHashRef.current !== null) {
|
||||
// The applied-remote data is fully synced; absorb it into the
|
||||
// baseline so the next tick does not re-evaluate the same hash.
|
||||
skipNextSyncHashRef.current = null;
|
||||
lastSyncedDataRef.current = currentHash;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A v2 remote check must join the provider replicas through the CRDT
|
||||
// runtime. Inspecting the materialized v1 snapshot here would discard
|
||||
// retained candidates and could turn the deterministic winner into a
|
||||
@@ -1221,7 +1300,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
lastRuntimeRemoteCheckAtRef.current = now;
|
||||
await checkRemoteVersion({ force: true, notifyOnFailure: false });
|
||||
await checkRemoteVersion({ force: true, notifyOnFailure: false, periodic: true });
|
||||
}, [
|
||||
checkRemoteVersion,
|
||||
enabled,
|
||||
|
||||
Reference in new issue
Block a user