From b5ed68ef874fdf74e1c602ccd7990d21b877b545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Fri, 31 Jul 2026 15:00:51 +0200 Subject: [PATCH] perf(collab): scope board-load connections to what's actually open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presence-scoped sibling restage: every tab announces the document it is actively editing in the project presence room (PresenceState.sheetPath, re-published on eeschema sheet navigation), and a pcbnew session connects a sibling schematic's board-room only while a peer announces it open — instead of eagerly holding one socket per sheet for the whole session. Works because /files/ materializes from the ydoc, so the boot MEMFS snapshot is room-fresh; a sheet can only drift while someone is editing it, and that someone is in the roster. On leave the watch lingers 30s, flushes the pending restage, and closes; without a presence room the eager mode remains as fallback. A solo board session now holds 3 sockets (doc, presence, lib-mirror mux) — down from 92 on an 8-project repo before this series. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01W4EzyhjhDzLdNZsFAYjz7X --- web/pcbjam-shared | 2 +- web/standalone/src/components/WasmTool.tsx | 12 ++ web/standalone/src/wasm/collab/cross-app.ts | 18 ++ .../src/wasm/collab/presence-kicad.test.ts | 1 + .../src/wasm/collab/sibling-restage.test.ts | 71 ++++++- .../src/wasm/collab/sibling-restage.ts | 198 ++++++++++++++---- 6 files changed, 257 insertions(+), 45 deletions(-) diff --git a/web/pcbjam-shared b/web/pcbjam-shared index 0f839cd..9e26971 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit 0f839cda767b428fa5677c62591ad289c7260e19 +Subproject commit 9e269711deebbb7ed5fbb3679082c2b90224a188 diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 38eb959..867c9b4 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -1539,6 +1539,10 @@ export function WasmTool({ provider: yjsProviderConfig(), user: presenceUser(), tool, + // Announce the open document (the active sheet for + // eeschema, re-published on navigation below) — peers' + // sibling-restage scopes its sockets to announced files. + docPath: targetPath, }); } catch (err) { append(`[collab] cross-app presence connect failed: ${String(err)}`); @@ -1706,6 +1710,10 @@ export function WasmTool({ onActiveChange: (activeRoom) => { driftRef.current?.stop(); driftRef.current = null; + // Re-announce the actively-edited sheet in the project room — + // peers' sibling-restage tracks it (a pcbnew tab only mirrors + // sheets someone actually has open). + crossAppRef.current?.setDocPath(activeRoom?.sheetPath); startPresence(activeRoom?.provider, activeRoom?.sheetPath, activeRoom?.doc); startComments(activeRoom?.doc); if (activeRoom && !readOnly) { @@ -1752,6 +1760,10 @@ export function WasmTool({ projectId, files, targetPath, + // Presence-scoped: connect a sheet's room only while a peer + // announces it open (zero sibling sockets when alone). Absent + // (provider "none" / connect failed) ⇒ eager fallback. + presence: crossAppRef.current ?? undefined, provider: yjsProviderConfig(), log: append, }); diff --git a/web/standalone/src/wasm/collab/cross-app.ts b/web/standalone/src/wasm/collab/cross-app.ts index a40b4fd..7c16a5a 100644 --- a/web/standalone/src/wasm/collab/cross-app.ts +++ b/web/standalone/src/wasm/collab/cross-app.ts @@ -32,6 +32,13 @@ export interface CrossAppPeer { export interface CrossAppHandle { /** Publish this tab's selection (uuids + pcbnew footprint paths). */ setSelection(uuids: string[], paths?: string[]): void; + /** + * Publish which document this tab is actively editing (project-relative + * path; the active sheet for eeschema, re-announced on sheet navigation). + * Sibling-restage peers use it to connect a sheet's room only while someone + * actually has it open. + */ + setDocPath(path: string | undefined): void; /** * Peers in a DIFFERENT tool, one entry per awareness client. Unlike the * room roster this INCLUDES the own user's other tabs — one person with @@ -49,6 +56,8 @@ export async function startCrossAppPresence(opts: { provider: ProviderConfig; user: PresenceUser; tool: string; + /** Initial doc this tab edits (see CrossAppHandle.setDocPath). */ + docPath?: string; }): Promise { if (opts.provider.kind === "none") return undefined; @@ -72,6 +81,7 @@ export async function startCrossAppPresence(opts: { let selection: string[] = []; let selectionPaths: string[] | undefined; + let docPath = opts.docPath; const publish = () => { const state: PresenceState = { @@ -82,6 +92,9 @@ export async function startCrossAppPresence(opts: { cursor: null, selection, ...(selectionPaths?.length ? { selectionPaths } : {}), + // The actively-edited document, so peers can scope work (e.g. sibling + // restage sockets) to sheets that are ACTUALLY open somewhere. + ...(docPath ? { sheetPath: docPath } : {}), updatedAt: Date.now(), }; awareness.setLocalState(state); @@ -109,6 +122,11 @@ export async function startCrossAppPresence(opts: { selectionPaths = paths; publish(); }, + setDocPath(path) { + if (path === docPath) return; + docPath = path; + publish(); + }, peers() { const out: CrossAppPeer[] = []; for (const [clientId, raw] of awareness.getStates()) { diff --git a/web/standalone/src/wasm/collab/presence-kicad.test.ts b/web/standalone/src/wasm/collab/presence-kicad.test.ts index 4ff2f1d..cbbe52f 100644 --- a/web/standalone/src/wasm/collab/presence-kicad.test.ts +++ b/web/standalone/src/wasm/collab/presence-kicad.test.ts @@ -161,6 +161,7 @@ function stubCrossApp(initial: CrossAppPeer[] = []) { const subscribers = new Set<() => void>(); const handle: CrossAppHandle & { firePeers(p: CrossAppPeer[]): void } = { setSelection: vi.fn(), + setDocPath: vi.fn(), peers: () => peers, subscribe(cb) { subscribers.add(cb); diff --git a/web/standalone/src/wasm/collab/sibling-restage.test.ts b/web/standalone/src/wasm/collab/sibling-restage.test.ts index b821e08..0dd937e 100644 --- a/web/standalone/src/wasm/collab/sibling-restage.test.ts +++ b/web/standalone/src/wasm/collab/sibling-restage.test.ts @@ -17,7 +17,7 @@ vi.mock("@pcbjam/shared", () => ({ docToFile: () => "(kicad_sch materialized)", })); -import { startSiblingRestage } from "./sibling-restage"; +import { startSiblingRestage, type SiblingPresence } from "./sibling-restage"; interface FakeSession { room: string; @@ -52,18 +52,36 @@ function makeSession(room: string): FakeSession { }; } -function start(files: string[]) { +function start(files: string[], presence?: SiblingPresence) { return startSiblingRestage({ win: {} as never, slug: "proj", scopeId: "S", projectId: "P", files: files.map((path) => ({ path })), + presence, provider: { kind: "none" } as never, log: () => {}, }); } +/** Roster fake: `announce` swaps the open-path set and fires subscribers. */ +function fakePresence(initial: string[] = []) { + let paths = initial; + const subs = new Set<() => void>(); + return { + peers: () => paths.map((sheetPath) => ({ state: { sheetPath } })), + subscribe(cb: () => void) { + subs.add(cb); + return () => subs.delete(cb); + }, + announce(next: string[]) { + paths = next; + subs.forEach((cb) => cb()); + }, + }; +} + beforeEach(() => { vi.useFakeTimers(); sessions = []; @@ -127,4 +145,53 @@ describe("startSiblingRestage", () => { expect(s.doc.destroy).toHaveBeenCalled(); } }); + + describe("presence-scoped mode", () => { + it("holds zero sockets while no peer announces a sheet", async () => { + await start(["main.kicad_sch", "sub.kicad_sch"], fakePresence()); + expect(connectKicadDoc).not.toHaveBeenCalled(); + }); + + it("connects only announced in-scope sheets, once", async () => { + const presence = fakePresence(); + await start(["main.kicad_sch", "sub.kicad_sch"], presence); + presence.announce(["main.kicad_sch", "other-dir.kicad_pcb"]); + await vi.runAllTimersAsync(); + expect(sessions.map((s) => s.room)).toEqual(["S:P:main.kicad_sch"]); + // A second announce of the same sheet (another peer joining) is a no-op. + presence.announce(["main.kicad_sch"]); + await vi.runAllTimersAsync(); + expect(connectKicadDoc).toHaveBeenCalledTimes(1); + }); + + it("lingers after the peer leaves, flushes the pending restage, closes", async () => { + const presence = fakePresence(["main.kicad_sch"]); + await start(["main.kicad_sch"], presence); + await vi.runAllTimersAsync(); + expect(sessions).toHaveLength(1); + restageFile.mockClear(); + sessions[0]!.doc.emitRemote(); // debounced restage now pending + presence.announce([]); // peer closes their tab + await vi.advanceTimersByTimeAsync(60_000); // past linger + debounce + expect(sessions[0]!.provider.destroy).toHaveBeenCalled(); + expect(sessions[0]!.doc.destroy).toHaveBeenCalled(); + expect(restageFile).toHaveBeenCalled(); // last edits reached MEMFS + // Peer comes back: a fresh session dials again. + presence.announce(["main.kicad_sch"]); + await vi.runAllTimersAsync(); + expect(connectKicadDoc).toHaveBeenCalledTimes(2); + }); + + it("a rejoin during the linger keeps the existing session", async () => { + const presence = fakePresence(["main.kicad_sch"]); + await start(["main.kicad_sch"], presence); + await vi.runAllTimersAsync(); + presence.announce([]); // leave… + await vi.advanceTimersByTimeAsync(5_000); // …but rejoin within linger + presence.announce(["main.kicad_sch"]); + await vi.advanceTimersByTimeAsync(120_000); + expect(connectKicadDoc).toHaveBeenCalledTimes(1); + expect(sessions[0]!.provider.destroy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/web/standalone/src/wasm/collab/sibling-restage.ts b/web/standalone/src/wasm/collab/sibling-restage.ts index f14f3e6..c05832e 100644 --- a/web/standalone/src/wasm/collab/sibling-restage.ts +++ b/web/standalone/src/wasm/collab/sibling-restage.ts @@ -8,27 +8,54 @@ import type { ProviderConfig } from "./provider"; * Live sibling-document mirror for pcbnew sessions (project-sync 0001 bug 3). * * Boot stages every project file into MEMFS exactly once (kicad-runner - * syncProjectToMemfs), so the `.kicad_sch` a PCB session would sync from is a - * page-load snapshot while collaborators keep editing it in its own room. This - * subscribes — as an invisible, data-only observer — to every sibling sheet's - * collab room and re-materializes the file into MEMFS on updates, debounced - * like the lib refresh (synced-source scheduleEditorReload). It also restages - * once right after connect: the room is the source of truth since ysync, so it - * can already be ahead of the API snapshot boot fetched (unsaved collab edits - * live only in the room). + * syncProjectToMemfs) — and since the files route materializes from the ydoc, + * that snapshot is room-fresh at fetch time. A sheet can therefore only drift + * from its MEMFS copy while SOMEONE IS EDITING IT — and whoever does sits in + * the project presence room announcing which document their tab has open + * (PresenceState.sheetPath). So instead of eagerly holding one board-room + * WebSocket per sibling schematic for the whole session, this watches the + * presence roster and connects a sheet's room only while a peer (including the + * same user's other tab) actually has it open — a solo session holds ZERO + * sibling sockets. On connect it restages once (the peer may have edited + * between our boot fetch and now), then re-materializes into MEMFS on updates, + * debounced like the lib refresh. When the peer leaves, the socket lingers + * briefly (reload flaps), flushes a final restage, and closes. + * + * Without a presence room (provider "none", connect failure) it falls back to + * the eager mode: every in-scope sheet is watched for the whole session. * * MEMFS-only: nothing is uploaded and no editor poke is needed — pcbnew reads - * the schematic from MEMFS when the sync runs. A sheet created by a peer - * mid-session is not picked up (its path isn't in the boot file list); v1 - * accepts that gap. + * the schematic from MEMFS when the sync runs. Accepted v1 gaps: a sheet + * created by a peer mid-session (path not in the boot file list), and a peer + * whose edit lands between our boot fetch and their presence entry reaching us + * (a seconds-wide window during boot; the next load heals it). */ const RESTAGE_DEBOUNCE_MS = 400; +/** How long a watch outlives its last announcing peer (reload/nav flaps). */ +const CLOSE_LINGER_MS = 30_000; export interface SiblingRestageHandle { destroy(): void; } +/** + * The slice of `CrossAppHandle` this consumes (structural, so tests fake it): + * the project-presence roster + its change feed. + */ +export interface SiblingPresence { + peers(): Array<{ state: { sheetPath?: string } }>; + subscribe(cb: () => void): () => void; +} + +interface Watch { + session?: KicadDocSession; + connecting?: Promise; + linger?: ReturnType; + /** Connect failed — don't re-dial on every roster change. */ + failed?: boolean; +} + export async function startSiblingRestage(opts: { win: ToolWindow; slug: string; @@ -37,6 +64,8 @@ export async function startSiblingRestage(opts: { files: { path: string }[]; /** The opened `.kicad_pcb` — scopes the watch to ITS KiCad project. */ targetPath?: string; + /** Project presence roster; omitted ⇒ eager mode (watch every sheet). */ + presence?: SiblingPresence; provider: ProviderConfig; log: (m: string) => void; }): Promise { @@ -44,18 +73,18 @@ export async function startSiblingRestage(opts: { // Only the opened board's own KiCad project can be synced from: pcbnew's // "update from schematic" reads the sheets next to the .kicad_pcb (same // directory tree). A backend project holding SEVERAL KiCad projects (a - // repo of boards) must not fan out one room per schematic repo-wide — - // that held ~27 idle board-room sockets for an 8-board repo. Sheets a - // project references OUTSIDE its directory (rare ../ sheet paths) fall - // back to the boot snapshot — same gap v1 already accepts for new sheets. + // repo of boards) must not fan out repo-wide — that held ~27 idle + // board-room sockets for an 8-board repo. Sheets a project references + // OUTSIDE its directory (rare ../ sheet paths) fall back to the boot + // snapshot — same gap v1 already accepts for new sheets. const dir = opts.targetPath ? opts.targetPath.slice(0, opts.targetPath.lastIndexOf("/") + 1) : ""; const sheetPaths = opts.files .map((f) => f.path) .filter((p) => p.endsWith(".kicad_sch") && p.startsWith(dir)); + const sheetSet = new Set(sheetPaths); - const sessions: KicadDocSession[] = []; const timers = new Map>(); let destroyed = false; @@ -83,41 +112,126 @@ export async function startSiblingRestage(opts: { ); }; - await Promise.all( - sheetPaths.map(async (sheetPath) => { - try { - const session = await connectKicadDoc({ - provider: opts.provider, - room: collabRoomId(opts.scopeId, opts.projectId, sheetPath), - }); - if (destroyed) { - session.provider.destroy(); - session.doc.destroy(); - return; + const openSession = async (sheetPath: string): Promise => { + const session = await connectKicadDoc({ + provider: opts.provider, + room: collabRoomId(opts.scopeId, opts.projectId, sheetPath), + }); + if (destroyed) { + session.provider.destroy(); + session.doc.destroy(); + return null; + } + // Data-only observer: never appear in the sheet's presence roster + // (mirrors the sheet-manager's read-only invisible-observer handling). + session.provider.awareness?.setLocalState(null); + session.doc.on("update", () => schedule(sheetPath, session.doc)); + log(`[sibling] watching ${sheetPath}`); + restageFromDoc(sheetPath, session.doc); + return session; + }; + + /* ------------------------- eager fallback (no presence room) ------------ */ + + if (!opts.presence) { + const sessions: KicadDocSession[] = []; + await Promise.all( + sheetPaths.map(async (sheetPath) => { + try { + const session = await openSession(sheetPath); + if (session) sessions.push(session); + } catch (err) { + log(`[sibling] room connect failed for ${sheetPath}: ${String(err)}`); } - // Data-only observer: never appear in the sheet's presence roster - // (mirrors the sheet-manager's read-only invisible-observer handling). - session.provider.awareness?.setLocalState(null); - sessions.push(session); - session.doc.on("update", () => schedule(sheetPath, session.doc)); - log(`[sibling] watching ${sheetPath}`); - restageFromDoc(sheetPath, session.doc); - } catch (err) { - log(`[sibling] room connect failed for ${sheetPath}: ${String(err)}`); + }), + ); + return { + destroy() { + destroyed = true; + for (const t of timers.values()) clearTimeout(t); + timers.clear(); + for (const s of sessions) { + s.provider.destroy(); + s.doc.destroy(); + } + sessions.length = 0; + }, + }; + } + + /* ------------------------- presence-driven mode ------------------------- */ + + const presence = opts.presence; + const watches = new Map(); + + const closeNow = (sheetPath: string, w: Watch, flushPending: boolean) => { + const t = timers.get(sheetPath); + if (t) { + clearTimeout(t); + timers.delete(sheetPath); + // A debounced restage was pending — run it before dropping the doc, or + // the peer's last burst of edits never reaches MEMFS. + if (flushPending && w.session) restageFromDoc(sheetPath, w.session.doc); + } + if (w.linger) clearTimeout(w.linger); + w.session?.provider.destroy(); + w.session?.doc.destroy(); + watches.delete(sheetPath); + }; + + const reconcile = () => { + if (destroyed) return; + const open = new Set(); + for (const p of presence.peers()) { + const sp = p.state.sheetPath; + if (sp && sheetSet.has(sp)) open.add(sp); + } + for (const sheetPath of open) { + let w = watches.get(sheetPath); + if (w?.linger) { + clearTimeout(w.linger); + w.linger = undefined; } - }), + if (!w) { + const watch: Watch = {}; + watches.set(sheetPath, watch); + watch.connecting = (async () => { + try { + const session = await openSession(sheetPath); + if (session) watch.session = session; + } catch (err) { + watch.failed = true; + log(`[sibling] room connect failed for ${sheetPath}: ${String(err)}`); + } finally { + watch.connecting = undefined; + } + })(); + } + } + for (const [sheetPath, w] of watches) { + if (open.has(sheetPath) || w.linger) continue; + w.linger = setTimeout(() => { + if (destroyed) return; + log(`[sibling] releasing ${sheetPath} (no peer has it open)`); + closeNow(sheetPath, w, true); + }, CLOSE_LINGER_MS); + } + }; + + const unsubscribe = presence.subscribe(reconcile); + reconcile(); + log( + `[sibling] presence-scoped watch over ${sheetPaths.length} sheet(s) — ` + + `connecting only while a peer has one open`, ); return { destroy() { destroyed = true; + unsubscribe(); for (const t of timers.values()) clearTimeout(t); timers.clear(); - for (const s of sessions) { - s.provider.destroy(); - s.doc.destroy(); - } - sessions.length = 0; + for (const [sheetPath, w] of [...watches]) closeNow(sheetPath, w, false); }, }; }