fix(async): fiber resume guard — the prod board-load trap, red/green
Companion to kicad f0ce20ef64 (libcontext swap_suspended guard), which this
pins. The v0.1.20 diagnostics decoded the crash that survived v0.1.13–19:
TOOL_MANAGER Resume()s a coroutine whose body is asyncify-parked inside
handleSleep, the swap rewinds the stale fiber suspension, and the runtime is
poisoned. Full chain of evidence in docs/features/async/16-fiber-resume-guard.md
(+ round-3 addendum in 15-timer-park-repro.md).
- wasm/bindings/fiber_park.h + kicadTestFiberPark{Start,Prime,Poke,State}
exports (pcbnew + merged kicad_editor): stages Call→yield→legitimate
resume→sleep park→mid-park Resume, the exact prod state machine. The
first yield matters: it primes a real (then stale) suspension, matching
long-lived tool loops rather than a first-slice park.
- tests/kicad/fiber-resume-park.spec.ts: asserts the healthy contract on
polled state only (embind returns across fiber swaps are unwind
placeholders). RED on the unguarded build — fiber/sleep buffer
cross-restores, a jump-ghost beacon, the parked body zombified. GREEN with
the guard: mid-park poke refused ([collab-fcontext] jump-refused beacon),
park completes, post-yield resume works, no trap signatures.
- Regression sweep green: timer-park-repro, collab-load-fuzz, load-pcb,
pcbnew-collab, collab-undo, eeschema-collab (19 passed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
2026-07-31 23:37:05 +02:00
|
|
|
# 16 — Fiber resume guard (the production board-load trap, fixed)
|
|
|
|
|
|
|
|
|
|
Status: 2026-07-31 · red/green e2e `tests/kicad/fiber-resume-park.spec.ts` ·
|
|
|
|
|
lineage: 14 (open-settle gate), 15 (timer-park lever + the v0.1.20 decode),
|
|
|
|
|
drift-trio #10b (fiber buffers, ghost beacons).
|
|
|
|
|
|
|
|
|
|
## The bug
|
|
|
|
|
|
|
|
|
|
`RuntimeError: index out of bounds` / `unreachable executed` on prod board
|
|
|
|
|
loads (editor.pcbjam.com, v0.1.12 → v0.1.20), runtime permanently poisoned
|
|
|
|
|
afterwards. Survived three shipped fixes aimed at other reentrancy holes
|
|
|
|
|
(open-settle gate, dispatch interlock, busy-guard-holds-interlock).
|
|
|
|
|
|
|
|
|
|
**Root cause — two suspension protocols share one context.** KiCad coroutines
|
|
|
|
|
(TOOL_MANAGER tool loops, collab fiber bodies) run on emscripten fibers via
|
|
|
|
|
the libcontext wasm port. A coroutine suspended by a real yield
|
|
|
|
|
(`jump_fcontext` → `emscripten_fiber_swap`) has valid rewind state in its
|
|
|
|
|
fiber struct. A coroutine whose body is asyncify-parked inside `handleSleep`
|
|
|
|
|
(lib-bridge wait, `emscripten_sleep`, any EM_ASYNC_JS below a tool handler)
|
|
|
|
|
is suspended in a way the fiber machinery — and TOOL_MANAGER — cannot see:
|
|
|
|
|
its live state sits in the sleep's buffer, and the fiber struct still holds
|
|
|
|
|
the stale data of its LAST real yield. Natively this state cannot exist (a
|
|
|
|
|
coroutine cannot be suspended without yielding), so no upstream code guards
|
|
|
|
|
it.
|
|
|
|
|
|
|
|
|
|
When the next event arrives during such a park, TOOL_MANAGER `Resume()`s the
|
|
|
|
|
"waiting" tool → `finishContextSwitch` rewinds the STALE fiber suspension →
|
|
|
|
|
binaryen's rewind-path mismatch `unreachable`, or replayed frames clobber the
|
|
|
|
|
parked body's stack; the sleep's wake then rewinds into wreckage. Every later
|
|
|
|
|
Asyncify entry (the GAL refresh timer's `emscripten_async_call` cascade was
|
|
|
|
|
the usual first victim, which is why the symbolization kept pointing at
|
|
|
|
|
timers) reads poisoned state → `index out of bounds`.
|
|
|
|
|
|
|
|
|
|
Trigger window in prod: right after `open:settled`, presync/realtime lib
|
|
|
|
|
churn + first-paint activity dispatch events while tool bodies still park in
|
|
|
|
|
lib waits. Fast loads crash too — the window needs only one event during one
|
|
|
|
|
park.
|
|
|
|
|
|
|
|
|
|
## The evidence chain
|
|
|
|
|
|
|
|
|
|
1. v0.1.20 shipped rate-limited `[wx-asyncify]` reporting in the handlesleep
|
|
|
|
|
shim + the `[wx-timer]`/`[wx-dispatch]` channels. The crashing prod log
|
|
|
|
|
(console-export-2026-7-31_22-33-27.log) showed NO concurrent-park, NO
|
|
|
|
|
retry storms, NO depth erasure — every competing hypothesis ruled out on a
|
|
|
|
|
live crash — and one `overlapped-wake … over null` 2 ms after settle: a
|
|
|
|
|
fiber fingerprint (`finishContextSwitch`/`fiber_swap` are the only
|
|
|
|
|
mid-flight sources of a null `currData`).
|
|
|
|
|
2. The trap-2 stack decoded mechanically against the shipped runtime:
|
|
|
|
|
`maybeStopUnwind → Fibers.trampoline → finishContextSwitch → doRewind →
|
|
|
|
|
unreachable` — a fiber swap-in rewinding stale data at the tail of an
|
|
|
|
|
unwind.
|
|
|
|
|
3. The lever (`wasm/bindings/fiber_park.h`, `kicadTestFiberPark*`) stages the
|
|
|
|
|
state machine deterministically: Call + yield (valid suspension) →
|
|
|
|
|
legitimate resume → body parks in `emscripten_sleep` → **Resume during the
|
|
|
|
|
park**. RED on the unguarded runtime: fiber-buffer/sleep-buffer
|
|
|
|
|
cross-restores (`aliased-wake-live` 103710720 ⇄ 406683648), a
|
|
|
|
|
`jump-ghost` beacon, and the parked body zombified (its wake lost — in
|
|
|
|
|
prod, the next timer/tool entry turns this into the loud trap).
|
|
|
|
|
|
|
|
|
|
## The fix
|
|
|
|
|
|
|
|
|
|
`kicad/thirdparty/libcontext/libcontext.cpp` (wasm port only):
|
|
|
|
|
`wasm_fcontext::swap_suspended` — true only between a real `fiber_swap`
|
|
|
|
|
suspension and the next swap-in (set at both swap sites; fresh contexts start
|
|
|
|
|
true, the entry-point path needs no rewind). `jump_fcontext` REFUSES a target
|
|
|
|
|
whose flag is false — returns 0, the null-`INVOCATION_ARGS` contract the
|
|
|
|
|
jump-ghost path already established, with a rate-limited
|
|
|
|
|
`[collab-fcontext] jump-refused` beacon. The refused dispatch is dropped; the
|
|
|
|
|
parked body completes via its own wake, suspends properly, and the next
|
|
|
|
|
dispatch lands normally.
|
|
|
|
|
|
|
|
|
|
Why refuse rather than wait: a waiting guard (park the resumer until the
|
|
|
|
|
target suspends) deadlocks against the yield-back path — the target's yield
|
|
|
|
|
needs to swap into the very context that is busy waiting.
|
|
|
|
|
|
2026-08-01 10:05:42 +02:00
|
|
|
## Round 2 (2026-08-01) — v0.1.21 still trapped: the guard was laundered
|
|
|
|
|
|
|
|
|
|
The prod crash reproduced on v0.1.21 with ZERO `jump-refused` beacons: the
|
|
|
|
|
fatal swap passed the `swap_suspended` check. Mechanism: when a fresh JS
|
|
|
|
|
entry executes while `g_current_context` still points at a parked fiber (the
|
|
|
|
|
port has no way to know a new JS turn began), `jump_fcontext` attributes the
|
|
|
|
|
jump's old side to that parked fiber — `fiber_swap` then writes a fresh
|
|
|
|
|
(foreign but valid-looking) suspension INTO the parked fiber's struct and
|
|
|
|
|
re-marks it `swap_suspended`. The flag lies; the later Resume passes the C++
|
|
|
|
|
guard and rewinds garbage.
|
|
|
|
|
|
|
|
|
|
**Layer 2 (attribution-proof, JS runtime):** `handlesleep.js` wraps
|
|
|
|
|
`Fibers.finishContextSwitch` and tracks truth at the emscripten-fiber layer:
|
|
|
|
|
|
|
|
|
|
- a fiber is *validly suspended* only when a real swap-out wrote its
|
|
|
|
|
suspension (observable: `fiber_swap` leaves `currData = oldFiber+20` when
|
|
|
|
|
the trampoline runs);
|
|
|
|
|
- a fiber whose entered slice ends in a `handleSleep` park (currData holds a
|
|
|
|
|
sleep buffer, no `nextFiber`) is *internally parked* — quarantined until a
|
|
|
|
|
GENUINE swap-out, where genuine means its pending sleep has resolved
|
|
|
|
|
(checked against the shim's `__pendingSleepContexts`); a laundering write
|
|
|
|
|
while the sleep is still pending does not lift the quarantine;
|
|
|
|
|
- entering a quarantined or suspension-less fiber is REFUSED
|
|
|
|
|
(`[wx-asyncify] fiber-resume-refused` beacon, currData cleared, ghost
|
|
|
|
|
contract as usual).
|
|
|
|
|
|
|
|
|
|
Lever for the laundered scenario: `kicadTestFiberParkStartSecond/PokeSecond`
|
|
|
|
|
(a second coroutine started while the first is parked = the misattributed
|
|
|
|
|
jump), spec scenario 2 asserts the refusal beacon fires AND everything
|
|
|
|
|
completes cleanly.
|
|
|
|
|
|
|
|
|
|
## The white screen itself (fixed this round)
|
|
|
|
|
|
|
|
|
|
The prod logs also revealed why every fatal-overlay attempt failed: the last
|
|
|
|
|
trap of each cascade lands inside a React EFFECT (an embind call via a
|
|
|
|
|
react-query subscription), React unmounts the entire root, and the overlay +
|
|
|
|
|
console die with the tree. Fix: `WasmErrorBoundary` inside `WasmTool` — all
|
|
|
|
|
crash-capable children live inside it; the fatal screen (now an actual blue
|
|
|
|
|
screen) and the console panel live OUTSIDE it and survive. All fatal
|
|
|
|
|
promotions auto-open the console. Pinned by `tests/web/fatal-overlay.spec.ts`.
|
|
|
|
|
|
fix(async): fiber resume guard — the prod board-load trap, red/green
Companion to kicad f0ce20ef64 (libcontext swap_suspended guard), which this
pins. The v0.1.20 diagnostics decoded the crash that survived v0.1.13–19:
TOOL_MANAGER Resume()s a coroutine whose body is asyncify-parked inside
handleSleep, the swap rewinds the stale fiber suspension, and the runtime is
poisoned. Full chain of evidence in docs/features/async/16-fiber-resume-guard.md
(+ round-3 addendum in 15-timer-park-repro.md).
- wasm/bindings/fiber_park.h + kicadTestFiberPark{Start,Prime,Poke,State}
exports (pcbnew + merged kicad_editor): stages Call→yield→legitimate
resume→sleep park→mid-park Resume, the exact prod state machine. The
first yield matters: it primes a real (then stale) suspension, matching
long-lived tool loops rather than a first-slice park.
- tests/kicad/fiber-resume-park.spec.ts: asserts the healthy contract on
polled state only (embind returns across fiber swaps are unwind
placeholders). RED on the unguarded build — fiber/sleep buffer
cross-restores, a jump-ghost beacon, the parked body zombified. GREEN with
the guard: mid-park poke refused ([collab-fcontext] jump-refused beacon),
park completes, post-yield resume works, no trap signatures.
- Regression sweep green: timer-park-repro, collab-load-fuzz, load-pcb,
pcbnew-collab, collab-undo, eeschema-collab (19 passed).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
2026-07-31 23:37:05 +02:00
|
|
|
## Verification
|
|
|
|
|
|
|
|
|
|
- `fiber-resume-park.spec.ts` red on unguarded build (phase-3 poll dies),
|
|
|
|
|
green with the guard: mid-park poke refused + logged, body completes its
|
|
|
|
|
park, second yield reached, post-yield resume works, no trap signatures,
|
|
|
|
|
model walk functional afterwards.
|
|
|
|
|
- `timer-park-repro.spec.ts`, `collab-load-fuzz.spec.ts`, `load-pcb.spec.ts`
|
|
|
|
|
stay green (guard must not refuse valid suspensions).
|
|
|
|
|
- Prod validation: watch for `jump-refused` beacons in the next crash-free
|
|
|
|
|
Leonardo load — each one is a would-have-been crash.
|