perf(libs): realtime only for libs the open document references

Under realtime "shared-only" a board/schematic session holds no socket per
org lib — which silently broke the lib-update toast (a peer editing a
PLACED symbol never reached the open session live). Complete the design
with the deferred-realtime upgrade: after open, scan the staged target
document for lib-table nicknames (lib_id / footprint / lib_symbols tokens)
and promote exactly those libs' stacks to realtime via the new
LibsSource.enableRealtime. One socket per lib the document actually uses
(typically 0-5) instead of one per lib in scope (60+); every other lib
still catches up on the next load via the descriptor digests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4EzyhjhDzLdNZsFAYjz7X
This commit is contained in:
Gergő Törcsvári 2026-07-31 15:43:11 +02:00
commit 9ea8b263cc
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 95 additions and 2 deletions

@ -1 +1 @@
Subproject commit 29f6898c2ffdc0f6ab94bf7dbab790265c0b4a6c
Subproject commit 46f9a8e58d22c22fd9aa1f58a471db7cbe035b6e

View file

@ -60,7 +60,12 @@ import {
type ModelsLoadingDetail,
} from "@/wasm/libs/models-bridge";
import { memfsFilePath, memfsProjectDir, TOOL_BUNDLE, TOOL_FRAME } from "@/wasm/constants";
import { driveProjectIntoTool, type ToolFile } from "@/wasm/kicad-runner";
import {
driveProjectIntoTool,
readStagedFile,
usedLibNicknames,
type ToolFile,
} from "@/wasm/kicad-runner";
import { dump as dumpTrace, mark } from "@/wasm/load-trace";
import { registerSaveHook, type SaveBytes } from "@/wasm/save-flow";
import type {
@ -1797,6 +1802,26 @@ export function WasmTool({
append(`[collab] attach failed — continuing without collab: ${String(err)}`);
}
}
// Deferred-realtime upgrade: the scope libs source opens its stacks
// channel-less (no socket per org lib), so promote the libs the OPEN
// DOCUMENT references — a peer editing a PLACED symbol must still
// reach this session live (lib-update toast); everything else syncs
// on the next load. Fire-and-forget: boot never waits on sockets.
if (targetPath && source?.enableRealtime) {
const staged = readStagedFile(win, slug, targetPath);
const nicks = staged
? usedLibNicknames(new TextDecoder().decode(staged))
: [];
append(
`[libs] doc references ${nicks.length} lib nickname(s)` +
(staged ? "" : " (target not staged?)"),
);
if (nicks.length) {
void source
.enableRealtime(nicks)
.catch((e) => append(`[libs] realtime upgrade: ${String(e)}`));
}
}
// Lib editors: the enumerate gate holds their whole-set hydrate until
// the presync settles — wait for it here too, so the boot overlay (with
// its ticking lib line) stays up instead of revealing an empty tree.

View file

@ -71,6 +71,38 @@ export function restageFile(
log(`[memfs] wrote ${dest} (${bytes.length} bytes)`);
}
/** Read one staged project file back from the tool's MEMFS (null if absent).
* Counterpart of {@link restageFile}; used post-open to inspect the target
* document (e.g. which lib nicknames it references). */
export function readStagedFile(
win: ToolWindow,
slug: string,
relPath: string,
): Uint8Array | null {
try {
const fs = getFS(win) as unknown as {
readFile(path: string): Uint8Array;
};
return fs.readFile(memfsFilePath(slug, relPath));
} catch {
return null;
}
}
/**
* Lib-table nicknames the document references: placed symbols (`lib_id`),
* board footprints (`footprint`), and the embedded `lib_symbols` cache
* (`symbol "NICK:NAME"`). Text scan, not a parse nicknames land in quoted
* `NICK:NAME` tokens in all three shapes, and a stray match only costs a
* no-op realtime upgrade for a name that resolves to nothing.
*/
export function usedLibNicknames(text: string): string[] {
const out = new Set<string>();
const re = /\((?:lib_id|footprint|symbol)\s+"([^":]+):[^"]*"/g;
for (let m = re.exec(text); m; m = re.exec(text)) out.add(m[1]!);
return [...out];
}
/** How many project files are fetched at once by the MEMFS staging below.
* Matches the lib presync's default: enough to hide per-request latency on a
* many-file project, low enough not to starve the parallel wasm download. */

View file

@ -144,6 +144,16 @@ export interface LibsSource {
* Optional: stateless sources omit it.
*/
dispose?(): void;
/**
* Promote the named libs (by display name / lib-table nickname) to realtime
* sync the deferred-realtime upgrade. Bulk sources open their stacks
* channel-less (a board session must not hold one socket per org lib); the
* editor calls this with the libs the OPEN DOCUMENT references, so a peer's
* edit to a placed symbol still reaches the session live (lib-update toast)
* while the ~150 unreferenced libs stay socket-free. Optional: sources that
* are always-realtime (or never) omit it.
*/
enableRealtime?(libNames: string[]): Promise<void>;
}
/**

View file

@ -220,6 +220,12 @@ export function syncedLibsSource(
opened?.then((r) => r.stack.close()).catch(() => {});
opened = null;
},
async enableRealtime(): Promise<void> {
// Names are the SCOPE source's concern (it fans out per lib); a one-lib
// source just promotes its own stack.
const { stack } = await ensure();
stack.connectRealtime();
},
};
}
@ -459,5 +465,25 @@ export function syncedScopeLibsSource(
for (const src of perLib.values()) src.dispose?.();
perLib.clear();
},
async enableRealtime(libNames): Promise<void> {
if (libNames.length === 0) return;
// Nickname → lib id via the backend listing (one request; the wasm boot
// has usually made the same call already). Names that don't resolve —
// project-local table rows, stale nicknames — are simply not ours.
const wanted = new Set(libNames);
const libs = (await remote.listLibs()).filter((l) => wanted.has(l.name));
opts.log?.(
`[synced] realtime upgrade for ${libs.length}/${libNames.length} referenced lib(s)`,
);
await Promise.all(
libs.map((l) =>
forLib(l.id)
.enableRealtime?.([])
?.catch((e) =>
opts.log?.(`[synced] realtime upgrade failed for ${l.name}: ${String(e)}`),
),
),
);
},
};
}