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:
parent
141f7ba5bd
commit
a4fab53590
4 changed files with 100 additions and 0 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue