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),

View file

@ -33,6 +33,8 @@ type Mod = {
kicadTestFiberParkPrime(): boolean;
kicadTestFiberParkPoke(): boolean;
kicadTestFiberParkState(): string;
kicadTestFiberParkStartSecond(): boolean;
kicadTestFiberParkPokeSecond(): boolean;
kicadCollabSnapshotItems(): string;
};
@ -41,6 +43,7 @@ interface ParkState {
pokes: number;
parkMs: number;
running: boolean;
phase2: number;
}
async function bootHarness(page: Page): Promise<void> {
@ -140,4 +143,84 @@ test.describe("Resume() into an asyncify-parked coroutine (libcontext guard)", (
);
expect(trapLines, "no wasm trap signature anywhere in the run").toEqual([]);
});
test("poisoned attribution: a second coroutine launders the parked fiber; the JS guard still quarantines it", async ({
page,
testLogger,
}) => {
test.setTimeout(240000);
await bootHarness(page);
// Prime + park the first coroutine (same staging as scenario 1).
await page.evaluate(() => {
(window.Module as unknown as Mod).kicadTestFiberParkStart(2500);
});
await expect
.poll(async () => (await parkState(page)).phase, { timeout: 10000, intervals: [50] })
.toBe(1);
await page.evaluate(() => {
(window.Module as unknown as Mod).kicadTestFiberParkPrime();
});
await expect
.poll(async () => (await parkState(page)).phase, { timeout: 10000, intervals: [50] })
.toBe(2);
// THE LAUNDERING: start a second coroutine while the first is parked.
// libcontext attributes this jump's old side to the PARKED fiber
// (g_current_context is stale) — writing a fresh suspension into its
// struct and re-marking it swap_suspended, exactly how the prod resume
// bypassed the C++ guard on v0.1.21.
await page.evaluate(() => {
(window.Module as unknown as Mod).kicadTestFiberParkStartSecond();
});
await expect
.poll(async () => (await parkState(page)).phase2, { timeout: 10000, intervals: [50] })
.toBe(1);
// The fatal prod operation, now with the C++ guard blinded. The JS
// stale-rewind guard must refuse it (quarantine beacon) instead of
// rewinding foreign/stale data.
await page.evaluate(() => {
(window.Module as unknown as Mod).kicadTestFiberParkPoke();
});
const afterPoke = await parkState(page);
console.log(`[TEST] laundered mid-park poke: ${JSON.stringify(afterPoke)}`);
expect(afterPoke.phase, "quarantined poke left the parked body undisturbed").toBe(2);
// The park must still complete on its own wake and yield again.
await expect
.poll(async () => (await parkState(page)).phase, { timeout: 15000, intervals: [100] })
.toBe(3);
// Post-yield resume is legitimate again and completes the first body.
await page.evaluate(() => {
(window.Module as unknown as Mod).kicadTestFiberParkPoke();
});
await expect
.poll(async () => (await parkState(page)).phase, { timeout: 10000, intervals: [100] })
.toBe(4);
// The second coroutine also completes cleanly.
await page.evaluate(() => {
(window.Module as unknown as Mod).kicadTestFiberParkPokeSecond();
});
await expect
.poll(async () => (await parkState(page)).phase2, { timeout: 10000, intervals: [100] })
.toBe(2);
// Window-engagement proof: the JS guard must have actually refused the
// laundered resume — silence means the scenario never bypassed the C++
// guard and the test is vacuous.
const refusals = testLogger.consoleLogs.filter((l) =>
l.includes("fiber-resume-refused"),
);
console.log(`[TEST] refusal beacons: ${refusals.length}`);
for (const l of refusals.slice(0, 4)) console.log(`[TEST] ${l}`);
expect(refusals.length, "the stale-rewind guard intercepted the laundered resume").toBeGreaterThan(0);
const trapLines = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) =>
TRAP_SIGNATURE.test(l),
);
expect(trapLines, "no wasm trap signature anywhere in the run").toEqual([]);
});
});

View file

@ -47,6 +47,13 @@ struct State
int phase = 0;
int parkMs = 0;
int pokes = 0;
// Second coroutine (poisoned-attribution scenario): 0 idle · 1 yielded ·
// 2 completed. Starting it while the FIRST body is asyncify-parked makes
// libcontext attribute the jump's old side to that parked fiber
// (g_current_context is stale), writing a fresh suspension into its
// struct — the exact laundering that let the prod resume bypass the
// swap_suspended guard.
int phase2 = 0;
};
inline State& state()
@ -114,13 +121,59 @@ inline bool poke()
return co()->Resume();
}
inline COROUTINE<int, int>*& co2()
{
static COROUTINE<int, int>* s_co2 = nullptr;
return s_co2;
}
inline int fiberBody2( int )
{
state().phase2 = 1;
co2()->KiYield();
state().phase2 = 2;
return 0;
}
/**
* Start a SECOND coroutine while the first body is asyncify-parked. Because
* g_current_context still points at the parked fiber, libcontext attributes
* this jump's old side to it: the swap writes a fresh (foreign) suspension
* into the PARKED fiber's struct and re-marks it swap_suspended the
* laundering that lets a later Resume bypass the C++ guard. The JS
* stale-rewind guard (handlesleep.js) must still quarantine it.
*/
inline bool startSecond()
{
if( !co() )
return false; // scenario needs the first coroutine in flight
if( co2() && co2()->Running() )
return false;
delete co2();
state().phase2 = 0;
co2() = new COROUTINE<int, int>( fiberBody2 );
co2()->Call( 0 );
return state().phase2 == 1;
}
/** Resume the second coroutine past its yield (cleanup / completion). */
inline bool pokeSecond()
{
if( !co2() )
return false;
return co2()->Resume();
}
inline std::string stateJson()
{
char buf[112];
char buf[144];
snprintf( buf, sizeof( buf ),
"{\"phase\":%d,\"pokes\":%d,\"parkMs\":%d,\"running\":%s}",
"{\"phase\":%d,\"pokes\":%d,\"parkMs\":%d,\"running\":%s,\"phase2\":%d}",
state().phase, state().pokes, state().parkMs,
( co() && co()->Running() ) ? "true" : "false" );
( co() && co()->Running() ) ? "true" : "false", state().phase2 );
return buf;
}

View file

@ -211,6 +211,16 @@ static std::string kicadTestFiberParkState()
return pcbjam_fiber_park::stateJson();
}
static bool kicadTestFiberParkStartSecond()
{
return pcbjam_fiber_park::startSecond();
}
static bool kicadTestFiberParkPokeSecond()
{
return pcbjam_fiber_park::pokeSecond();
}
// Canvas-only chrome toggle (features/mobile): hide/show every AUI pane
// except the central draw canvas, plus the menubar and status bar, so the GAL
@ -571,6 +581,8 @@ EMSCRIPTEN_BINDINGS(kicad_editor) {
function("kicadTestFiberParkPrime", &kicadTestFiberParkPrime);
function("kicadTestFiberParkPoke", &kicadTestFiberParkPoke);
function("kicadTestFiberParkState", &kicadTestFiberParkState);
function("kicadTestFiberParkStartSecond", &kicadTestFiberParkStartSecond);
function("kicadTestFiberParkPokeSecond", &kicadTestFiberParkPokeSecond);
// Canvas-only mobile mode (features/mobile).
function("kicadSetChrome", &kicadSetChrome);

View file

@ -160,6 +160,16 @@ std::string kicadTestFiberParkState()
return pcbjam_fiber_park::stateJson();
}
bool kicadTestFiberParkStartSecond()
{
return pcbjam_fiber_park::startSecond();
}
bool kicadTestFiberParkPokeSecond()
{
return pcbjam_fiber_park::pokeSecond();
}
// Read-only viewer lock (read-only-viewer): flips the process-global
// PCBJAM_READ_ONLY flag consumed by TOOL_MANAGER (view-only action allowlist)
// and the selection tools (nothing selectable), and mirrors it onto the
@ -2428,6 +2438,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
function("kicadTestFiberParkPrime", &kicadTestFiberParkPrime);
function("kicadTestFiberParkPoke", &kicadTestFiberParkPoke);
function("kicadTestFiberParkState", &kicadTestFiberParkState);
function("kicadTestFiberParkStartSecond", &kicadTestFiberParkStartSecond);
function("kicadTestFiberParkPokeSecond", &kicadTestFiberParkPokeSecond);
function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe);
// Read-only viewer lock (read-only-viewer).
function("kicadSetReadOnly", &kicadSetReadOnly);