Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
116 lines
6.2 KiB
Markdown
116 lines
6.2 KiB
Markdown
# 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.**
|
|
`maybeStartCollab` → `attachKicadCollab` → `seed()` →
|
|
`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 signal** — `wasm/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 it** — `open-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 die** — `driveProjectIntoTool` 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).
|