Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
6.2 KiB
14 — The open-settle gate: prod "indirect call signature mismatch" at board load
Symptom
Intermittent, prod, mostly Firefox: loading an editor URL (observed on
…/Arduino Mega 2560.kicad_pcb) sometimes fails the boot with
Error: RuntimeError: indirect call signature mismatch
right around the [collab] maybeStartCollab gate: console line. Previously
suspected (wrongly) to be related to spaces in filenames.
Root cause
Three stacked defects:
-
The "file loaded" signal was a no-op.
openFileInToolfiresModule.kicadOpenFile(path)— which runsOpenProjectFilesunder Asyncify, i.e. the embind call unwinds back to JS long before the load finishes — and then pollsschematicLoaded(): "title is non-empty and does not contain 'untitled'". But pcbnew's pre-open title is"PCB Editor"(a fresh frame;PCB_EDIT_FRAMEctor title), and a real project's title never contains "untitled" at any point. The poll therefore passed on the first check, while the load had barely started. (The heuristic only ever waited for the@localnew-file flow, whose files are literally nameduntitled.*.) -
driveProjectIntoToolignored the result — even an honest"failed"(60 s timeout) let the boot IIFE continue. -
Everything after it drives bare embind entries that walk the model.
maybeStartCollab→attachKicadCollab→seed()→kicadCollabSnapshotItems()(inline board walk + per-itemFormat()), thenbindKicadPresence(GALVIEW_OVERLAYbind). These run while thekicadOpenFilechain is still parked mid-mutation of the same BOARD/SCH_SCREEN (progress-reporter yields, futex yields, lib bridges). A virtual call that lands on a half-built item reads a garbage vtable slot →call_indirecthits a wrong-typed table entry → Firefox reportsindirect call signature mismatch. The boot IIFE catches it →Error: RuntimeError: …in the status overlay.
This is the same reentrancy class as two already-fixed bugs — the wxWidgets
dispatch interlock (wxwidgets/docs/wasm/dispatch-interlock.md: no event
dispatch while another chain is parked) and drift-trio finding #10b
(kicadCollabFiberBusy: no bare-embind scratch save while a collab fiber is in
flight) — but through the one entry family neither guard covers: web-shell
JS → embind calls during the open park. Timing-dependent, hence "sometimes";
slow Firefox loads widen the window enormously; filename spaces were never
involved.
Fix
Three layers, mirroring the shape of the earlier interlocks:
-
Truthful completion signal —
wasm/bindings/open_gate.h:pcbjam_open::BusyGuard, an RAII counter onkicadOpenFile's C++ stack frame. Under Asyncify an unwind does not run destructors and a rewind resumes past the constructor, so the count is held across every park and drops exactly whenOpenProjectFilestruly returns (the same primitive aswxWasmDispatchGuard). Exported asModule.kicadOpenFileBusy()from all fourkicadOpenFiledefinitions (pcbnew / eeschema standalone, merged kicad_editor, pl_editor), mirroring thekicadCollabFiberBusyprobe. -
JS waits for it —
open-flow.tswaitForOpenSettled(): after invoking the open, pollkicadOpenFileBusy()until clear (5 min budget — slow loads are real; the poll is free). Escape hatch: a visible non-progress dialog means the load is parked awaiting user input (file-version confirm, remap…) — proceed rather than leave the dialog unanswerable under the boot overlay. Feature-detected: wasm builds without the probe fall back to the legacy title poll unchanged. -
Degrade, don't die —
driveProjectIntoToolreturns the open outcome; on"failed"the shell skips the whole collab/presence/drift attach (board stays viewable, saves still route). The attach block is additionally wrapped so a residual trap logs[collab] attach failed — continuing without collabinstead of failing the boot;SexprVersionError("update required") still rethrows.
Layer 4 — entry guards + the regression spec
The shell gate alone leaves the raw embind entries trappable if anything else
calls them mid-load, and is untestable end-to-end (see below). So the collab
snapshot/apply entries themselves early-return while pcbjam_open::busy():
snapshots return the empty delta, applies drop. kicadTestSetOpenPark(ms)
(test-only, default off) makes kicadOpenFile Asyncify-park for a fixed time
on entry and again after OpenProjectFiles returns — model fully loaded, gate
still closed.
tests/kicad/collab-load-fuzz.spec.ts uses that window deterministically: it
opens a ~13k-item generated board and hammers all four entries the whole time
kicadOpenFileBusy() is true, asserting the gate engages, mid-load snapshots
are EMPTY (an unguarded build returns the full board → deterministic red),
mid-load applies are dropped (probe segment must not move), nothing traps, and
everything works after settle.
Why the window must be synthetic: the wasm port's wxYield/progress pump
never parks — the only natural in-load parks are thread-pool waits
(futex_yield/nanosleep_yield), which on a fast idle machine never happen
(the whole open runs synchronously and JS cannot interleave at all). That is
also why the prod trap correlates with slow machines/Firefox. A second,
PCBJAM_FUZZ_STRESS=1-gated test in the same spec hunts those natural parks
under spinning-worker CPU starvation; it cannot gate CI (window engagement is
scheduler-dependent) but is the honest reproducer to loop on a loaded box.
Residuals / notes
- A trap escaping the open leaves the busy count stuck → the JS poll times out (5 min) and boots without collab; same end state as before, minus the trap.
kicadSetReadOnlypolling during the load is unaffected (leaf flag flip, no model walk) — it has always run during parks, likekicadCollabFiberBusy.- The mid-load modal escape accepts the status-quo risk for that rare case: parked-at-a-dialog is a stable park point, not a mid-container-append one.
- Unit coverage:
web/standalone/src/wasm/open-flow.test.ts(settle wait, stuck-busy failure, dialog escape incl. progress-dialog exclusion, legacy fallback).