test(syncguard): protect catch-up handoff while finite pull settles

This commit is contained in:
Artem Ovchinnikov committed 2026-10-27 15:37:01 +03:00
1 parent 101fe958f5
commit b552c2a455
2 files changed
+30 -2

No files matched your search

+13 -2
View File
@@ -102,9 +102,14 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard
guard.catchUpSucceeded();
guard.catchUpActivityChanged(false, readPendingCount(), Date.now());
} else if (guard.presentation.phase !== "idle" && terminalPhaseTimer === undefined) {
// The one-shot PouchDB progress observer can report 'idle' before the
// enclosing finite CouchDB pull has finished releasing its resources.
// This counter is not used to identify the provider; CouchDB phase owns that.
if (phase === "idle" && services.replicator.finiteReplicationActivityCount.value > 0) return;
// PouchDB disposes its per-attempt progress observer with phase 'idle' just
// before Commonlib publishes 'continuous'. Defer the terminal observation by
// one event-loop turn so that this internal hand-off never looks like a failure.
// before Commonlib publishes 'continuous'. When the enclosing finite
// transfer settles, defer the terminal observation by one event-loop
// turn so that this internal hand-off never looks like a failure.
terminalPhaseTimer = compatGlobal.setTimeout(() => {
terminalPhaseTimer = undefined;
const current = services.replicator.replicationStatics.value.couchDB?.phase;
@@ -119,6 +124,9 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard
else guard.updateThreshold(services.setting.currentSettings().syncGuardMinimumFiles);
};
const onPendingChanged = () => checkForUnlock();
const onFiniteChanged = () => {
if (services.replicator.finiteReplicationActivityCount.value === 0) onReplicationChanged();
};
const removeReflectionFailureHandler = services.context.events.onEvent(EVENT_VAULT_REFLECTION_FAILED, () => {
guard.reflectionFailed();
});
@@ -127,6 +135,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard
});
const replication = services.replicator.replicationStatics;
const finite = services.replicator.finiteReplicationActivityCount;
const pendingSources = [
services.replication.replicationResultCount,
services.replication.databaseQueueCount,
@@ -139,6 +148,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard
chunkFetchCounts,
];
replication.onChanged(onReplicationChanged);
finite.onChanged(onFiniteChanged);
for (const source of pendingSources) source.onChanged(onPendingChanged);
// Register before the ordinary replication scheduling handler so the first finite pull
@@ -164,6 +174,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard
stopTimer();
stopTerminalPhaseTimer();
replication.offChanged(onReplicationChanged);
finite.offChanged(onFiniteChanged);
for (const source of pendingSources) source.offChanged(onPendingChanged);
overlay.dispose();
removeSettingSavedHandler();
@@ -159,6 +159,23 @@ describe("useSyncGuardFeature", () => {
await h.onUnload[0]();
});
it("waits for the finite pull to settle when progress temporarily reports idle", async () => {
vi.useFakeTimers();
const h = setup({ minimumFiles: 1 });
await h.onResumed[0]();
h.setCouchDbPhase("catch-up");
h.finiteReplicationActivityCount.value = 1;
await h.parseHandlers[0]([receivedNote("first")]);
h.setCouchDbPhase("idle");
await vi.advanceTimersByTimeAsync(5_000); // Resource disposal may be asynchronous.
expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring");
h.finiteReplicationActivityCount.value = 0;
h.setCouchDbPhase("continuous");
await vi.advanceTimersByTimeAsync(1);
expect(h.updates[h.updates.length - 1]?.phase).toBe("applying");
await h.onUnload[0]();
});
it("ignores large P2P finite transfers when CouchDB catch-up is not running", async () => {
const h = setup({ minimumFiles: 2 });
await h.onResumed[0]();