fix(syncguard): retire bypass on catch-up completion and close CSS rule

This commit is contained in:
Artem Ovchinnikov committed 2026-10-29 12:56:01 +03:00
1 parent 2d3a5e5ff8
commit 47828e934b
6 files changed
+100 -4

No files matched your search

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