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);
|
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). */
|
/** `files` hints — project rows changed on the files route (0002 §1). */
|
||||||
onFiles(cb: (seq: number, changes: GatewayFileChange[]) => void): void {
|
onFiles(cb: (seq: number, changes: GatewayFileChange[]) => void): void {
|
||||||
this.filesCbs.push(cb);
|
this.filesCbs.push(cb);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,16 @@ export interface YjsProvider {
|
||||||
* must destroy doc + provider and connect afresh.
|
* must destroy doc + provider and connect afresh.
|
||||||
*/
|
*/
|
||||||
onReset?(cb: () => void): void;
|
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 =
|
export type ProviderKind =
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ interface FakeSession {
|
||||||
destroy: ReturnType<typeof vi.fn>;
|
destroy: ReturnType<typeof vi.fn>;
|
||||||
awareness: { setLocalState: ReturnType<typeof vi.fn> };
|
awareness: { setLocalState: ReturnType<typeof vi.fn> };
|
||||||
onReset: ReturnType<typeof vi.fn>;
|
onReset: ReturnType<typeof vi.fn>;
|
||||||
|
repull: ReturnType<typeof vi.fn>;
|
||||||
/** Fire the gateway's `reset` (0004 §2.3). */
|
/** Fire the gateway's `reset` (0004 §2.3). */
|
||||||
emitReset: () => void;
|
emitReset: () => void;
|
||||||
};
|
};
|
||||||
|
|
@ -58,6 +59,7 @@ function makeSession(room: string): FakeSession {
|
||||||
destroy: vi.fn(),
|
destroy: vi.fn(),
|
||||||
awareness: { setLocalState: vi.fn() },
|
awareness: { setLocalState: vi.fn() },
|
||||||
onReset: vi.fn((cb: () => void) => resets.add(cb)),
|
onReset: vi.fn((cb: () => void) => resets.add(cb)),
|
||||||
|
repull: vi.fn(),
|
||||||
emitReset: () => resets.forEach((h) => h()),
|
emitReset: () => resets.forEach((h) => h()),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -169,6 +171,42 @@ describe("startSiblingRestage", () => {
|
||||||
expect(restageFile).not.toHaveBeenCalled();
|
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 () => {
|
it("debounces remote updates into one restage", async () => {
|
||||||
await start(["main.kicad_sch"]);
|
await start(["main.kicad_sch"]);
|
||||||
restageFile.mockClear();
|
restageFile.mockClear();
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,41 @@ export async function startSiblingRestage(opts: {
|
||||||
return session;
|
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) ------------ */
|
/* ------------------------- eager fallback (no presence room) ------------ */
|
||||||
|
|
||||||
if (!opts.presence) {
|
if (!opts.presence) {
|
||||||
|
|
@ -184,9 +219,11 @@ export async function startSiblingRestage(opts: {
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const detachFocus = attachFocusRepull(() => sessions);
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
|
detachFocus();
|
||||||
for (const t of timers.values()) clearTimeout(t);
|
for (const t of timers.values()) clearTimeout(t);
|
||||||
timers.clear();
|
timers.clear();
|
||||||
for (const s of sessions) {
|
for (const s of sessions) {
|
||||||
|
|
@ -295,6 +332,9 @@ export async function startSiblingRestage(opts: {
|
||||||
|
|
||||||
const unsubscribe = presence.subscribe(reconcile);
|
const unsubscribe = presence.subscribe(reconcile);
|
||||||
reconcile();
|
reconcile();
|
||||||
|
const detachFocus = attachFocusRepull(
|
||||||
|
() => [...watches.values()].map((w) => w.session),
|
||||||
|
);
|
||||||
log(
|
log(
|
||||||
`[sibling] presence-scoped watch over ${sheetPaths.length} sheet(s) — ` +
|
`[sibling] presence-scoped watch over ${sheetPaths.length} sheet(s) — ` +
|
||||||
`connecting only while a peer has one open`,
|
`connecting only while a peer has one open`,
|
||||||
|
|
@ -303,6 +343,7 @@ export async function startSiblingRestage(opts: {
|
||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
destroyed = true;
|
destroyed = true;
|
||||||
|
detachFocus();
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
for (const t of timers.values()) clearTimeout(t);
|
for (const t of timers.values()) clearTimeout(t);
|
||||||
timers.clear();
|
timers.clear();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue