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

116 lines
6.2 KiB
Markdown
Raw Normal View History

# 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).