diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.ts b/src/serviceFeatures/syncGuard/SyncGuardState.ts index 41f9bce0..d3484064 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardState.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardState.ts @@ -33,6 +33,10 @@ export class SyncGuardState { return this.bypassed; } + get isCatchUpActive(): boolean { + return this.catchUpActive; + } + /** Begins a fresh, initially non-blocking observation window on app resume. */ resume(enabled: boolean, minimumFiles: number): void { this.minimumFiles = Number.isFinite(minimumFiles) ? Math.max(1, Math.floor(minimumFiles)) : 20; diff --git a/src/serviceFeatures/syncGuard/index.ts b/src/serviceFeatures/syncGuard/index.ts index 468bbd2c..fc7b83e0 100644 --- a/src/serviceFeatures/syncGuard/index.ts +++ b/src/serviceFeatures/syncGuard/index.ts @@ -72,9 +72,12 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard guard.disable(); return; } - // A duplicate resume in the same active catch-up must honour a user's - // explicit decision to continue editing rather than silently re-lock. - if (guard.wasBypassed && isCouchDbCatchUp()) { + // PouchDB briefly reports 'idle' when disposing its progress observer, while + // the enclosing CouchDB catch-up still owns the previous guard generation. + // Do not reset that generation on duplicate resume, even before the threshold. + const sameCatchUp = guard.isCatchUpActive || isCouchDbCatchUp(); + // An explicit bypass must survive this same transient 'idle'. + if (guard.wasBypassed && sameCatchUp) { return; } // Duplicate resume notifications or a visibility transition must not discard an @@ -83,7 +86,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard guard.presentation.phase === "interrupted") { return; } - if (guard.presentation.phase === "observing" && isCouchDbCatchUp()) { + if (guard.presentation.phase === "observing" && sameCatchUp) { return; } guard.resume(Boolean(isEnabled()), settings.syncGuardMinimumFiles); diff --git a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts index 26495757..108d7644 100644 --- a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts @@ -176,6 +176,41 @@ describe("useSyncGuardFeature", () => { await h.onUnload[0](); }); + it("keeps received-note counting across duplicate resume during transient idle with finite pull active", async () => { + vi.useFakeTimers(); + const h = setup({ minimumFiles: 3 }); + await h.onResumed[0](); + h.setCouchDbPhase("catch-up"); + h.finiteReplicationActivityCount.value = 1; + await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]); + h.setCouchDbPhase("idle"); + await h.onResumed[0](); // Must not reset the already observed two notes. + await h.parseHandlers[0]([receivedNote("third")]); + expect(h.updates[h.updates.length - 1]).toMatchObject({ + phase: "transferring", + receivedFiles: 3, + }); + h.finiteReplicationActivityCount.value = 0; + h.setCouchDbPhase("continuous"); + expect(h.updates[h.updates.length - 1]?.phase).toBe("applying"); + await h.onUnload[0](); + }); + + it("honours manual bypass across duplicate resume while catch-up reports transient 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.bypass(); + h.setCouchDbPhase("idle"); + await h.onResumed[0](); + await h.parseHandlers[0]([receivedNote("second")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); + 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]();