Compare commits

...
2 changed files with 162 additions and 1 deletions

No files matched your search

+82
View File
@@ -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')");
+80 -1
View File
@@ -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,