fix(syncguard): preserve catch-up state across transient idle resume

This commit is contained in:
Artem Ovchinnikov committed 2026-10-27 15:51:05 +03:00
1 parent b552c2a455
commit 2d3a5e5ff8
3 files changed
+46 -4

No files matched your search

@@ -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;
+7 -4
View File
@@ -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);
@@ -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]();