diff --git a/src/serviceFeatures/syncGuard/index.ts b/src/serviceFeatures/syncGuard/index.ts index 93b1f919..468bbd2c 100644 --- a/src/serviceFeatures/syncGuard/index.ts +++ b/src/serviceFeatures/syncGuard/index.ts @@ -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(); diff --git a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts index bf8d5b4b..26495757 100644 --- a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts @@ -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]();