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:
parent
a8adfa9843
commit
5333099810
5 changed files with 204 additions and 3 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue