fix(load): close the dispatch-interlock hole at open + open gerbers from a project route

- kicadOpenFile now holds wxWasmDispatchGuard (open_gate.h). It enters through
  embind, so the interlock read "nothing parked" for the whole load and wx timers
  dispatched into the half-built board — the residual prod "index out of bounds"
  that survived the settle gate.
- new wasm/bindings/gerbview_embind.cpp (the bundle had no embind surface at all):
  kicadOpenFile / kicadOpenFiles / kicadOpenFileBusy. Clicking one gerber opens the
  whole fabrication set in its folder, since a lone layer is not a useful view.
- cross-app presence rejoins in the boot fan-out (network-only; the wasm-bound half
  still waits for the open to settle) — it had been pushed behind the board load.
- tests: gerber-set selection units + a gerbview multi-file open e2e.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
This commit is contained in:
Gergő Törcsvári 2026-07-30 19:08:57 +02:00
commit d35cf4f4eb
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
7 changed files with 372 additions and 23 deletions

View file

@ -1445,6 +1445,44 @@ export function WasmTool({
return { error };
}
})();
// Project presence room, same fan-out slot as the doc room: it needs
// identity and a socket, NOT the wasm, so the websocket handshake
// happens while the wasm still downloads instead of queueing behind a
// multi-second board load (which also starves the handshake — Firefox
// drops it as "interrupted while the page was loading"). Only the
// wasm-bound half (bindKicadPresence) waits for the open to settle.
// Never rejects: presence is best-effort, exactly as before.
// Read-only viewers skip the room entirely — the server rejects their
// connection anyway (presence requires write).
const collabOptOut =
new URLSearchParams(win.location.search).get("collab") === "0" ||
new URLSearchParams(win.location.search).get("collab") === "false";
const crossAppReady: Promise<CrossAppHandle | undefined> =
(tool === "pcbnew" || tool === "eeschema") && !collabOptOut && !readOnly
? (async () => {
try {
await identityReady;
return await startCrossAppPresence({
scopeId,
projectId,
provider: yjsProviderConfig(),
user: presenceUser(),
tool,
});
} catch (err) {
append(`[collab] cross-app presence connect failed: ${String(err)}`);
return undefined;
}
})()
: Promise.resolve(undefined);
// Take ownership the moment it lands — a boot that dies before the
// handoff below would otherwise leave this socket open, since unmount
// only tears down what reached crossAppRef.
void crossAppReady.then((h) => {
if (!h) return;
if (presyncAbort.signal.aborted) h.destroy(); // unmounted mid-connect
else crossAppRef.current = h;
});
await bootKicadTool({
tool,
base,
@ -1570,25 +1608,14 @@ export function WasmTool({
// Cross-app selection (0006): join the project-wide presence room BEFORE
// the per-file collab starts, so the first startPresence bind already
// routes xsel. Honors the same ?collab=0 opt-out as the room collab.
const collabOptOut =
new URLSearchParams(win.location.search).get("collab") === "0" ||
new URLSearchParams(win.location.search).get("collab") === "false";
// Read-only viewers skip the project presence room entirely — the
// server rejects their connection anyway (presence requires write).
if ((tool === "pcbnew" || tool === "eeschema") && !collabOptOut && !readOnly) {
crossAppRef.current =
(await startCrossAppPresence({
scopeId,
projectId,
provider: yjsProviderConfig(),
user: presenceUser(),
tool,
})) ?? null;
// Test/debug handle (mirrors __pcbjamComments): lets the e2e assert
// the project-room peer view without driving pixels.
(win as { __pcbjamCrossApp?: CrossAppHandle | null }).__pcbjamCrossApp =
crossAppRef.current;
}
// Cross-app presence: the ROOM was joined back in the boot fan-out
// (pure network + Y.Doc, no wasm) — only the handoff to the wasm-bound
// presence below has to wait for the open. Settle it here.
crossAppRef.current = (await crossAppReady) ?? null;
// Test/debug handle (mirrors __pcbjamComments): lets the e2e assert
// the project-room peer view without driving pixels.
(win as { __pcbjamCrossApp?: CrossAppHandle | null }).__pcbjamCrossApp =
crossAppRef.current;
if (tool === "eeschema") {
// Multi-room (subschema) collab: every .kicad_sch is its own warm room; the

View file

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { gerberSiblings } from "./kicad-runner";
/**
* Clicking one gerber on the project page opens the whole fabrication SET in
* its folder (a single copper layer alone is a near-empty canvas). Drill files
* come along — GerbView routes them to the Excellon loader itself.
*/
const files = (...paths: string[]) => paths.map((path) => ({ path }));
describe("gerberSiblings", () => {
const SET = [
"Production/gerbers/board-B_Cu.gbr",
"Production/gerbers/board-Edge_Cuts.gbr",
"Production/gerbers/board-F_Cu.gbr",
"Production/gerbers/board-PTH.drl",
];
it("collects every gerber and drill file in the clicked folder, sorted", () => {
expect(
gerberSiblings(files(...SET), "Production/gerbers/board-F_Cu.gbr"),
).toEqual(SET);
});
it("ignores other folders, nested subfolders, and non-gerber files", () => {
const all = files(
...SET,
"Production/gerbers/readme.md",
"Production/gerbers/old/board-F_Cu.gbr",
"Production/other/board-F_Cu.gbr",
"KiCad Projects/board.kicad_pcb",
);
expect(gerberSiblings(all, "Production/gerbers/board-F_Cu.gbr")).toEqual(SET);
});
it("handles a gerber at the project root", () => {
const all = files("a-F_Cu.gbr", "a-B_Cu.gbr", "sub/deep.gbr");
expect(gerberSiblings(all, "a-F_Cu.gbr")).toEqual(["a-B_Cu.gbr", "a-F_Cu.gbr"]);
});
it("always includes the clicked file, even with an unrecognised extension", () => {
const all = files("g/board-F_Cu.gbr", "g/weird.xyz");
expect(gerberSiblings(all, "g/weird.xyz")).toEqual([
"g/weird.xyz",
"g/board-F_Cu.gbr",
]);
});
});

View file

@ -1,5 +1,5 @@
import type { Tool } from "@pcbjam/shared";
import { FILELESS_TOOLS } from "@pcbjam/shared";
import { FILELESS_TOOLS, toolForFile } from "@pcbjam/shared";
import { defaultKicadPro } from "../lib/new-file";
import { memfsFilePath, memfsProjectDir } from "./constants";
import { prescanBoardModels } from "./libs/models-bridge";
@ -137,6 +137,70 @@ function synthesizeProjectFile(win: ToolWindow, opts: DriveOptions): void {
opts.log(`[memfs] synthesized ${proPath} (project has no project file)`);
}
/**
* Every gerber/drill sibling of `targetPath`, in stable filename order.
*
* A fabrication set is a stack — copper layers, mask, silk, edge cuts, plus the
* Excellon drill files — and they are conventionally emitted into one folder.
* Opening only the clicked layer shows a near-empty canvas, so the clicked
* FOLDER is the real unit. `toolForFile` owns the extension list (it is what
* decided this route is gerbview in the first place); drill files come along
* because GerbView routes them to the Excellon loader itself.
*/
export function gerberSiblings(files: ToolFile[], targetPath: string): string[] {
const slash = targetPath.lastIndexOf("/");
const dir = slash < 0 ? "" : targetPath.slice(0, slash + 1);
const inDir = files
.map((f) => f.path)
.filter((path) => {
if (!path.startsWith(dir)) return false;
if (path.slice(dir.length).includes("/")) return false; // nested deeper
return toolForFile(path) === "gerbview";
})
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
// The clicked file always opens, even if its extension is unusual enough that
// toolForFile missed it (the route named it, so the user meant it).
return inDir.includes(targetPath) ? inDir : [targetPath, ...inDir];
}
/**
* Open a whole gerber set in GerbView. Prefers the multi-file embind entry
* (`kicadOpenFiles`); a bundle predating it falls back to the single-file open
* of just the clicked layer, which is the old behavior and still renders.
*/
async function openGerberSet(
win: ToolWindow,
opts: DriveOptions,
): Promise<"programmatic" | "ui" | "failed"> {
const paths = gerberSiblings(opts.files, opts.targetPath!);
const abs = paths.map((path) => memfsFilePath(opts.slug, path));
return openFileInTool(win, abs[0]!, {
log: opts.log,
open: () => {
// Feature-detect HERE, not before the call: embind registers the Module
// functions during runtime init, which lands AFTER the Emscripten FS is
// ready — i.e. after we get here from driveProjectIntoTool. Probing any
// earlier always misses and silently degrades to the single-file open
// (openFileInTool's frame wait is what guarantees the exports exist).
const mod = win.Module as
| {
kicadOpenFiles?: (json: string) => boolean;
kicadOpenFile?: (path: string) => unknown;
}
| undefined;
if (typeof mod?.kicadOpenFiles === "function") {
opts.log(`[open] gerbview: opening ${abs.length} file(s) from ${opts.targetPath}`);
mod.kicadOpenFiles(JSON.stringify(abs));
return;
}
opts.log(
"[open] gerbview bundle has no kicadOpenFiles — opening the clicked layer only",
);
mod?.kicadOpenFile?.(abs[0]!);
},
});
}
/**
* Drive a project into an already-booting tool runtime (booted into `win` by
* bootKicadTool — the top-level window). Waits for the Emscripten FS, syncs the
@ -166,7 +230,14 @@ export async function driveProjectIntoTool(
synthesizeProjectFile(win, opts);
let result: "programmatic" | "ui" | "failed" | "none" = "none";
if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) {
if (opts.targetPath && opts.tool === "gerbview") {
// GerbView boots fileless, but a project route can still name a gerber —
// and a single layer on its own is not a useful view, so open the whole
// fabrication set that lives beside it (see openGerberSet).
onStatus("Opening gerbers…");
result = await openGerberSet(win, opts);
log(`[open] result: ${result}`);
} else if (opts.targetPath && !FILELESS_TOOLS.has(opts.tool)) {
onStatus("Opening file…");
const abs = memfsFilePath(opts.slug, opts.targetPath);
result = await openFileInTool(win, abs, { log });

View file

@ -17,6 +17,13 @@ export interface OpenFlowOptions {
timeoutMs?: number;
/** Override the load-settle budget (kicadOpenFileBusy poll) — tests only. */
settleTimeoutMs?: number;
/**
* Replace the programmatic invocation (default: `Module.kicadOpenFile(path)`)
* while keeping the readiness handling around it — the frame wait, the
* settle gate, the no-UI-automation-while-parked rule. GerbView uses this to
* open a whole fabrication set through `kicadOpenFiles`.
*/
open?: () => void;
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
@ -220,8 +227,9 @@ export async function openFileInTool(
// waitForOpenSettled). We must NOT fall back to UI automation while the hook
// is in flight — synthesizing input would re-enter the suspended Asyncify
// call and corrupt it.
if (hasProgrammaticHook(win)) {
invokeProgrammaticOpen(win, absPath, log);
if (opts.open || hasProgrammaticHook(win)) {
if (opts.open) opts.open();
else invokeProgrammaticOpen(win, absPath, log);
const settled = await waitForOpenSettled(win, log, timeoutMs, opts.settleTimeoutMs);
return settled ? "programmatic" : "failed";
}