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
This commit is contained in:
parent
f86ef9e433
commit
e561507898
7 changed files with 441 additions and 2 deletions
|
|
@ -119,7 +119,37 @@ The lever + spec stay as the regression gate for the fix regardless: they
|
|||
deterministically create and verify the concurrent-park window that all three
|
||||
shipped fixes were blind to.
|
||||
|
||||
## Candidate real fix (only after a red)
|
||||
## Round 3 — v0.1.20 PROD crash decoded (2026-07-31 late, log console-export-2026-7-31_22-33-27.log)
|
||||
|
||||
The crash reproduced in prod with the diagnostics live, and they discriminate
|
||||
cleanly: ZERO `concurrent-park`, ZERO `aliased-wake-live` (this spec's
|
||||
collision class is NOT the prod mechanism — the two green rounds were a true
|
||||
negative), exactly one benign `overlapped-wake … over null` 2 ms after
|
||||
`open:settled`, traps 218 ms later. Decoding the trap stack against the
|
||||
shipped runtime source:
|
||||
|
||||
**Root cause: a KiCad coroutine is Resume()d while its body is
|
||||
asyncify-parked inside handleSleep.** libcontext-on-emscripten-fibers and
|
||||
handleSleep are two independent suspension protocols sharing one context: a
|
||||
fiber suspended by `fiber_swap` has valid rewind data at `fiber+20`; a fiber
|
||||
whose body parked via handleSleep does NOT (its live state is in the sleep's
|
||||
allocateData buffer, invisible to the fiber machinery — and to TOOL_MANAGER).
|
||||
Resume → `fiber_swap` into it → `finishContextSwitch` rewinds the STALE
|
||||
`fiber+20` → rewind-path mismatch → "unreachable executed"; every subsequent
|
||||
entry (the GAL timer's async_call cascade among them) reads poisoned asyncify
|
||||
state → "index out of bounds". Impossible on native (no parking without
|
||||
yielding), unreachable in this spec (warm fibers never park internally), and
|
||||
untouched by all three shipped fixes.
|
||||
|
||||
Next: a `kicadTestSetFiberPark(ms)`-style lever (coroutine body that
|
||||
`emscripten_sleep()`s + a Resume poked mid-park) as spec cycle 5 — expected
|
||||
deterministically RED with exactly the prod signature — then the fix: track
|
||||
"asyncify park in flight" per fiber and defer Resume/swap-into until the
|
||||
park completes (wasm layer; `kicadCollabFiberBusy` from drift-trio #10b is
|
||||
the precedent).
|
||||
|
||||
## Candidate real fix for the timer half (superseded by round 3 for the prod
|
||||
crash, still sound hygiene)
|
||||
|
||||
Deliver timer notifies from the main-loop chain: the JS timer callback only
|
||||
marks the timer due and wakes the yield; the loop dispatches due timers after
|
||||
|
|
|
|||
85
docs/features/async/16-fiber-resume-guard.md
Normal file
85
docs/features/async/16-fiber-resume-guard.md
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# 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.
|
||||
|
||||
## 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.
|
||||
Loading…
Reference in a new issue