diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.ts b/src/serviceFeatures/syncGuard/SyncGuardState.ts index d3484064..2e93c6d8 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardState.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardState.ts @@ -59,7 +59,16 @@ export class SyncGuardState { /** The CouchDB catch-up phase ends before continuous replication and Vault reflection finish. */ catchUpActivityChanged(active: boolean, pendingCount: number, now: number): void { - if (this.phase === "idle" || this.bypassed || this.catchUpActive === active) return; + if (this.catchUpActive === active) return; + // A manual bypass lasts only for this catch-up generation. Even while the + // overlay is closed, observe its terminal transition so that a later + // reconnect can protect the workspace again. + if (this.bypassed) { + this.catchUpActive = active; + if (!active) this.bypassed = false; + return; + } + if (this.phase === "idle") return; if (active) { // A retry or a second pull can begin while the Vault is still applying an earlier batch. // Do not let the previous successful completion unlock an active newer transfer. diff --git a/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts b/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts index 14578577..7cf58ec4 100644 --- a/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/SyncGuardState.unit.spec.ts @@ -54,6 +54,28 @@ describe("SyncGuardState", () => { expect(guard.presentation.phase).toBe("idle"); }); + it("expires the manual bypass when its catch-up completes and protects the next catch-up", () => { + const { guard } = setup(); + guard.resume(true, 2); + guard.catchUpActivityChanged(true, 0, 0); + guard.receivedDocuments(["first", "second"]); + expect(guard.presentation.phase).toBe("transferring"); + guard.bypass(); + expect(guard.wasBypassed).toBe(true); + guard.catchUpActivityChanged(true, 0, 5); + expect(guard.isCatchUpActive).toBe(true); + guard.catchUpSucceeded(); + guard.catchUpActivityChanged(false, 0, 10); + expect(guard.isCatchUpActive).toBe(false); + expect(guard.wasBypassed).toBe(false); + expect(guard.presentation.phase).toBe("idle"); + + guard.resume(true, 2); + guard.catchUpActivityChanged(true, 0, 20); + guard.receivedDocuments(["another-first", "another-second"]); + expect(guard.presentation).toEqual({ phase: "transferring", receivedFiles: 2 }); + }); + it("does not unlock when a new pull starts while applying an earlier completed transfer", () => { const { guard } = setup(); guard.resume(true, 1); diff --git a/src/serviceFeatures/syncGuard/index.ts b/src/serviceFeatures/syncGuard/index.ts index fc7b83e0..3b0ff5e4 100644 --- a/src/serviceFeatures/syncGuard/index.ts +++ b/src/serviceFeatures/syncGuard/index.ts @@ -104,7 +104,7 @@ export function useSyncGuardFeature(host: LiveSyncCore, createOverlay: SyncGuard // Commonlib enters continuous only after a successful pull-only catch-up. guard.catchUpSucceeded(); guard.catchUpActivityChanged(false, readPendingCount(), Date.now()); - } else if (guard.presentation.phase !== "idle" && terminalPhaseTimer === undefined) { + } else if ((guard.presentation.phase !== "idle" || guard.isCatchUpActive) && 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. diff --git a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts index 108d7644..1d63c58e 100644 --- a/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts +++ b/src/serviceFeatures/syncGuard/syncGuardFeature.unit.spec.ts @@ -211,6 +211,51 @@ describe("useSyncGuardFeature", () => { await h.onUnload[0](); }); + it("re-arms protection for a new substantial catch-up after bypass, completion, suspension and resume", async () => { + const h = setup({ minimumFiles: 2 }); + await h.onResumed[0](); + h.setCouchDbPhase("catch-up"); + await h.parseHandlers[0]([receivedNote("first"), receivedNote("second")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); + h.bypass(); + + // A duplicate resume before the first transfer finishes must honour the bypass. + await h.onResumed[0](); + await h.parseHandlers[0]([receivedNote("third"), receivedNote("fourth")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); + + // Successful hand-off to Continuous retires the bypassed generation. + h.setCouchDbPhase("continuous"); + await h.onSuspending[0](); + await h.onResumed[0](); + h.setCouchDbPhase("catch-up"); + await h.parseHandlers[0]([receivedNote("new-first"), receivedNote("new-second")]); + expect(h.updates[h.updates.length - 1]).toMatchObject({ + phase: "transferring", + receivedFiles: 2, + }); + await h.onUnload[0](); + }); + + it("re-arms protection after bypassed catch-up fails and later restarts", 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("failed")]); + h.bypass(); + h.setCouchDbPhase("idle"); + h.finiteReplicationActivityCount.value = 0; + await vi.advanceTimersByTimeAsync(1); + await h.onSuspending[0](); + await h.onResumed[0](); + h.setCouchDbPhase("catch-up"); + await h.parseHandlers[0]([receivedNote("after-restart")]); + expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); + 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](); @@ -335,7 +380,7 @@ describe("useSyncGuardFeature", () => { await h.onUnload[0](); }); - it("does not re-lock after manual bypass when an internal reconnect starts", async () => { + it("re-locks on a new internal catch-up after a bypassed transfer has completed", async () => { const h = setup({ minimumFiles: 1 }); await h.onResumed[0](); h.setCouchDbPhase("catch-up"); @@ -345,7 +390,7 @@ describe("useSyncGuardFeature", () => { h.setCouchDbPhase("continuous"); h.setCouchDbPhase("catch-up"); await h.parseHandlers[0]([receivedNote("second")]); - expect(h.updates[h.updates.length - 1]?.phase).toBe("idle"); + expect(h.updates[h.updates.length - 1]?.phase).toBe("transferring"); await h.onUnload[0](); }); diff --git a/styles.css b/styles.css index 5bdce64b..5068f752 100644 --- a/styles.css +++ b/styles.css @@ -873,6 +873,8 @@ body.is-mobile .livesync-compatibility-review-notice { .sls-sync-guard-bypass { min-height: 44px; min-width: 44px; +} + .livesync-stat-item { font-variant-numeric: tabular-nums; } diff --git a/test/syncGuardCss.unit.spec.ts b/test/syncGuardCss.unit.spec.ts new file mode 100644 index 00000000..3e6b85dc --- /dev/null +++ b/test/syncGuardCss.unit.spec.ts @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const stylesheet = readFileSync(fileURLToPath(new URL("../styles.css", import.meta.url)), "utf8"); + +describe("SyncGuard CSS", () => { + it("closes its bypass rule before the existing status display rules", () => { + const bypassStart = stylesheet.indexOf(".sls-sync-guard-bypass {"); + const statusStart = stylesheet.indexOf(".livesync-stat-item {", bypassStart); + expect(bypassStart).toBeGreaterThanOrEqual(0); + expect(statusStart).toBeGreaterThan(bypassStart); + const bypassRule = stylesheet.slice(bypassStart, statusStart); + expect(bypassRule).toMatch(/min-height:\s*44px;/); + expect(bypassRule).toMatch(/min-width:\s*44px;/); + expect(bypassRule).toMatch(/}\s*$/); + }); +});