From a4fab535906e99c6002480101f930d5566974ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Mon, 31 Aug 2026 15:13:43 +0200 Subject: [PATCH] =?UTF-8?q?sibling=20restage:=20focus-triggered=20repull?= =?UTF-8?q?=20=E2=80=94=20a=20dropped=20`touched`=20no=20longer=20strands?= =?UTF-8?q?=20the=20mirror=20until=20reload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the sync-delete fix: with the doc corruption gone, the remaining in-session staleness class is the passive watch itself. It re-pulls only on `touched` control frames, and the gateway debounces those 2s leading-edge with NO trailing emit — a dropped frame (real across gateway/DO hops, nearly impossible on localhost) leaves the MEMFS mirror pre-delete until an unrelated later edit. Field symptom: "delete + sync doesn't delete, but reloading the pcbnew tab and syncing again does". The sync gesture always brings the pcbnew tab to the front first, so on window focus / visibilitychange every live sibling watch now sends a manual SyncStep1 (new gateway-only `YjsProvider.repull`), rate-limited to one per 2s; any news restages through the normal debounce. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MLPKYptXFrxHj5Gu7rhToz --- web/standalone/src/wasm/collab/gateway.ts | 11 +++++ web/standalone/src/wasm/collab/provider.ts | 10 +++++ .../src/wasm/collab/sibling-restage.test.ts | 38 +++++++++++++++++ .../src/wasm/collab/sibling-restage.ts | 41 +++++++++++++++++++ 4 files changed, 100 insertions(+) diff --git a/web/standalone/src/wasm/collab/gateway.ts b/web/standalone/src/wasm/collab/gateway.ts index 6f7f9db..7ded2d3 100644 --- a/web/standalone/src/wasm/collab/gateway.ts +++ b/web/standalone/src/wasm/collab/gateway.ts @@ -398,6 +398,17 @@ export class GatewayDocFacade implements YjsProvider { this.resetCbs.push(cb); } + /** + * See {@link YjsProvider.repull} — a manual SyncStep1 so a passive replica + * can refresh without waiting for a `touched` frame (which the gateway + * debounces leading-edge and may drop entirely). + */ + repull(): void { + if (this.destroyed || this.dead || this.isPresence || this.isHintOnly) return; + if (!this.conn.isOpen()) return; + this.sendSyncStep1(); + } + /** `files` hints — project rows changed on the files route (0002 §1). */ onFiles(cb: (seq: number, changes: GatewayFileChange[]) => void): void { this.filesCbs.push(cb); diff --git a/web/standalone/src/wasm/collab/provider.ts b/web/standalone/src/wasm/collab/provider.ts index 2efdb28..f77d3a5 100644 --- a/web/standalone/src/wasm/collab/provider.ts +++ b/web/standalone/src/wasm/collab/provider.ts @@ -46,6 +46,16 @@ export interface YjsProvider { * must destroy doc + provider and connect afresh. */ onReset?(cb: () => void): void; + /** + * Gateway transport only: ask the server for a fresh diff NOW (SyncStep1). + * A passive subscription normally re-pulls on `touched` control frames, but + * the gateway debounces those 2s leading-edge with no trailing emit — a + * dropped frame leaves the replica stale until an unrelated later edit. + * Callers invoke this at moments freshness matters (the pcbnew tab + * regaining focus before an update-from-schematic). No-op when absent or + * the socket is down. + */ + repull?(): void; } export type ProviderKind = diff --git a/web/standalone/src/wasm/collab/sibling-restage.test.ts b/web/standalone/src/wasm/collab/sibling-restage.test.ts index 4e1f6d5..84ef341 100644 --- a/web/standalone/src/wasm/collab/sibling-restage.test.ts +++ b/web/standalone/src/wasm/collab/sibling-restage.test.ts @@ -35,6 +35,7 @@ interface FakeSession { destroy: ReturnType; awareness: { setLocalState: ReturnType }; onReset: ReturnType; + repull: ReturnType; /** Fire the gateway's `reset` (0004 §2.3). */ emitReset: () => void; }; @@ -58,6 +59,7 @@ function makeSession(room: string): FakeSession { destroy: vi.fn(), awareness: { setLocalState: vi.fn() }, onReset: vi.fn((cb: () => void) => resets.add(cb)), + repull: vi.fn(), emitReset: () => resets.forEach((h) => h()), }, }; @@ -169,6 +171,42 @@ describe("startSiblingRestage", () => { expect(restageFile).not.toHaveBeenCalled(); }); + it("window focus re-pulls every live watch, rate-limited (touched-drop recovery)", async () => { + const listeners = new Map void>(); + const winFake = { + addEventListener: (ev: string, cb: () => void) => listeners.set(ev, cb), + removeEventListener: vi.fn(), + document: { + visibilityState: "visible", + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }, + }; + const handle = await startSiblingRestage({ + win: winFake as never, + slug: "proj", + scopeId: "S", + projectId: "P", + files: [{ path: "main.kicad_sch" }, { path: "sub.kicad_sch" }], + provider: { kind: "none" } as never, + log: () => {}, + }); + const focus = listeners.get("focus")!; + expect(focus).toBeDefined(); + focus(); + for (const s of sessions) expect(s.provider.repull).toHaveBeenCalledTimes(1); + // immediate re-focus inside the rate window is a no-op + focus(); + for (const s of sessions) expect(s.provider.repull).toHaveBeenCalledTimes(1); + // past the window it pulls again + vi.advanceTimersByTime(3_000); + focus(); + for (const s of sessions) expect(s.provider.repull).toHaveBeenCalledTimes(2); + // destroy detaches the listeners + handle.destroy(); + expect(winFake.removeEventListener).toHaveBeenCalled(); + }); + it("debounces remote updates into one restage", async () => { await start(["main.kicad_sch"]); restageFile.mockClear(); diff --git a/web/standalone/src/wasm/collab/sibling-restage.ts b/web/standalone/src/wasm/collab/sibling-restage.ts index 907353b..1b95f84 100644 --- a/web/standalone/src/wasm/collab/sibling-restage.ts +++ b/web/standalone/src/wasm/collab/sibling-restage.ts @@ -170,6 +170,41 @@ export async function startSiblingRestage(opts: { return session; }; + /* ------------------- focus-triggered freshness (repull) ----------------- */ + + // A passive watch re-pulls only on `touched` control frames, and the + // gateway debounces those 2s leading-edge with NO trailing emit — a dropped + // frame leaves this mirror stale until an unrelated later edit, which reads + // as "delete + sync doesn't delete until I reload the tab". The sync + // gesture always brings the pcbnew tab to the front first, so ask every + // live watch for a fresh diff whenever this window regains focus; any news + // arrives as doc updates and restages through the normal debounce. + const REPULL_MIN_MS = 2_000; + let lastRepullAt = Number.NEGATIVE_INFINITY; + const attachFocusRepull = ( + live: () => Iterable, + ): (() => void) => { + const target = win as unknown as { + document?: { visibilityState?: string; addEventListener?: Window["addEventListener"]; removeEventListener?: Window["removeEventListener"] }; + addEventListener?: Window["addEventListener"]; + removeEventListener?: Window["removeEventListener"]; + }; + const repullAll = () => { + if (destroyed) return; + if (target.document?.visibilityState === "hidden") return; + const now = Date.now(); + if (now - lastRepullAt < REPULL_MIN_MS) return; + lastRepullAt = now; + for (const s of live()) s?.provider.repull?.(); + }; + target.addEventListener?.("focus", repullAll); + target.document?.addEventListener?.("visibilitychange", repullAll); + return () => { + target.removeEventListener?.("focus", repullAll); + target.document?.removeEventListener?.("visibilitychange", repullAll); + }; + }; + /* ------------------------- eager fallback (no presence room) ------------ */ if (!opts.presence) { @@ -184,9 +219,11 @@ export async function startSiblingRestage(opts: { } }), ); + const detachFocus = attachFocusRepull(() => sessions); return { destroy() { destroyed = true; + detachFocus(); for (const t of timers.values()) clearTimeout(t); timers.clear(); for (const s of sessions) { @@ -295,6 +332,9 @@ export async function startSiblingRestage(opts: { const unsubscribe = presence.subscribe(reconcile); reconcile(); + const detachFocus = attachFocusRepull( + () => [...watches.values()].map((w) => w.session), + ); log( `[sibling] presence-scoped watch over ${sheetPaths.length} sheet(s) — ` + `connecting only while a peer has one open`, @@ -303,6 +343,7 @@ export async function startSiblingRestage(opts: { return { destroy() { destroyed = true; + detachFocus(); unsubscribe(); for (const t of timers.values()) clearTimeout(t); timers.clear();