fix(load): open-settle gate — kicadOpenFileBusy probe + collab entry guards for the parked-open embind trap (indirect call signature mismatch) + deterministic collab-load-fuzz e2e
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
This commit is contained in:
parent
bd1fbdce85
commit
a26ef4ebeb
11 changed files with 964 additions and 17 deletions
116
docs/features/async/14-open-settle-gate.md
Normal file
116
docs/features/async/14-open-settle-gate.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# 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).
|
||||
Loading…
Reference in a new issue