test(kicad): poisoned-attribution lever — the laundering scenario, red/green

kicadTestFiberParkStartSecond/PokeSecond: a second coroutine started while
the first body is asyncify-parked reproduces the misattributed jump that
launders the parked fiber past the C++ guard (the v0.1.21 prod bypass).
Spec scenario 2 stages it and asserts the JS stale-rewind guard quarantines
the laundered resume (exactly one fiber-resume-refused beacon), the parked
body completes undisturbed, and both coroutines finish cleanly.

Doc: async/16 rounds 2 + WSOD section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SE4o46Lnq3hF574FFq8x4
This commit is contained in:
Gergő Törcsvári 2026-08-01 10:05:42 +02:00
commit 5333099810
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 204 additions and 3 deletions

View file

@ -73,6 +73,47 @@ 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.
## 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`.
## Verification
- `fiber-resume-park.spec.ts` red on unguarded build (phase-3 poll dies),