pcbjam/docs/features/async/14-open-settle-gate.md

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:

  1. The "file loaded" signal was a no-op. openFileInTool fires Module.kicadOpenFile(path) — which runs OpenProjectFiles under Asyncify, i.e. the embind call unwinds back to JS long before the load finishes — and then polls schematicLoaded(): "title is non-empty and does not contain 'untitled'". But pcbnew's pre-open title is "PCB Editor" (a fresh frame; PCB_EDIT_FRAME ctor 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 @local new-file flow, whose files are literally named untitled.*.)

  2. driveProjectIntoTool ignored the result — even an honest "failed" (60 s timeout) let the boot IIFE continue.

  3. Everything after it drives bare embind entries that walk the model. maybeStartCollabattachKicadCollabseed()kicadCollabSnapshotItems() (inline board walk + per-item Format()), then bindKicadPresence (GAL VIEW_OVERLAY bind). These run while the kicadOpenFile chain 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_indirect hits a wrong-typed table entry → Firefox reports indirect 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:

  1. Truthful completion signalwasm/bindings/open_gate.h: pcbjam_open::BusyGuard, an RAII counter on kicadOpenFile'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 when OpenProjectFiles truly returns (the same primitive as wxWasmDispatchGuard). Exported as Module.kicadOpenFileBusy() from all four kicadOpenFile definitions (pcbnew / eeschema standalone, merged kicad_editor, pl_editor), mirroring the kicadCollabFiberBusy probe.

  2. JS waits for itopen-flow.ts waitForOpenSettled(): after invoking the open, poll kicadOpenFileBusy() 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.

  3. Degrade, don't diedriveProjectIntoTool returns 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 collab instead 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.
  • kicadSetReadOnly polling during the load is unaffected (leaf flag flip, no model walk) — it has always run during parks, like kicadCollabFiberBusy.
  • 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).