sibling restage: focus-triggered repull — a dropped touched no longer strands the mirror until reload

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MLPKYptXFrxHj5Gu7rhToz
This commit is contained in:
Gergő Törcsvári 2026-08-31 15:13:43 +02:00
commit a4fab53590
4 changed files with 100 additions and 0 deletions

View file

@ -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);

View file

@ -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 =

View file

@ -35,6 +35,7 @@ interface FakeSession {
destroy: ReturnType<typeof vi.fn>;
awareness: { setLocalState: ReturnType<typeof vi.fn> };
onReset: ReturnType<typeof vi.fn>;
repull: ReturnType<typeof vi.fn>;
/** 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<string, () => 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();

View file

@ -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<KicadDocSession | undefined>,
): (() => 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();