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
|
target suspends) deadlocks against the yield-back path — the target's yield
|
||||||
needs to swap into the very context that is busy waiting.
|
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
|
## Verification
|
||||||
|
|
||||||
- `fiber-resume-park.spec.ts` red on unguarded build (phase-3 poll dies),
|
- `fiber-resume-park.spec.ts` red on unguarded build (phase-3 poll dies),
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ type Mod = {
|
||||||
kicadTestFiberParkPrime(): boolean;
|
kicadTestFiberParkPrime(): boolean;
|
||||||
kicadTestFiberParkPoke(): boolean;
|
kicadTestFiberParkPoke(): boolean;
|
||||||
kicadTestFiberParkState(): string;
|
kicadTestFiberParkState(): string;
|
||||||
|
kicadTestFiberParkStartSecond(): boolean;
|
||||||
|
kicadTestFiberParkPokeSecond(): boolean;
|
||||||
kicadCollabSnapshotItems(): string;
|
kicadCollabSnapshotItems(): string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -41,6 +43,7 @@ interface ParkState {
|
||||||
pokes: number;
|
pokes: number;
|
||||||
parkMs: number;
|
parkMs: number;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
|
phase2: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bootHarness(page: Page): Promise<void> {
|
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([]);
|
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 phase = 0;
|
||||||
int parkMs = 0;
|
int parkMs = 0;
|
||||||
int pokes = 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()
|
inline State& state()
|
||||||
|
|
@ -114,13 +121,59 @@ inline bool poke()
|
||||||
return co()->Resume();
|
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()
|
inline std::string stateJson()
|
||||||
{
|
{
|
||||||
char buf[112];
|
char buf[144];
|
||||||
snprintf( buf, sizeof( buf ),
|
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,
|
state().phase, state().pokes, state().parkMs,
|
||||||
( co() && co()->Running() ) ? "true" : "false" );
|
( co() && co()->Running() ) ? "true" : "false", state().phase2 );
|
||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,16 @@ static std::string kicadTestFiberParkState()
|
||||||
return pcbjam_fiber_park::stateJson();
|
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
|
// 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
|
// 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("kicadTestFiberParkPrime", &kicadTestFiberParkPrime);
|
||||||
function("kicadTestFiberParkPoke", &kicadTestFiberParkPoke);
|
function("kicadTestFiberParkPoke", &kicadTestFiberParkPoke);
|
||||||
function("kicadTestFiberParkState", &kicadTestFiberParkState);
|
function("kicadTestFiberParkState", &kicadTestFiberParkState);
|
||||||
|
function("kicadTestFiberParkStartSecond", &kicadTestFiberParkStartSecond);
|
||||||
|
function("kicadTestFiberParkPokeSecond", &kicadTestFiberParkPokeSecond);
|
||||||
|
|
||||||
// Canvas-only mobile mode (features/mobile).
|
// Canvas-only mobile mode (features/mobile).
|
||||||
function("kicadSetChrome", &kicadSetChrome);
|
function("kicadSetChrome", &kicadSetChrome);
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,16 @@ std::string kicadTestFiberParkState()
|
||||||
return pcbjam_fiber_park::stateJson();
|
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
|
// Read-only viewer lock (read-only-viewer): flips the process-global
|
||||||
// PCBJAM_READ_ONLY flag consumed by TOOL_MANAGER (view-only action allowlist)
|
// PCBJAM_READ_ONLY flag consumed by TOOL_MANAGER (view-only action allowlist)
|
||||||
// and the selection tools (nothing selectable), and mirrors it onto the
|
// and the selection tools (nothing selectable), and mirrors it onto the
|
||||||
|
|
@ -2428,6 +2438,8 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
|
||||||
function("kicadTestFiberParkPrime", &kicadTestFiberParkPrime);
|
function("kicadTestFiberParkPrime", &kicadTestFiberParkPrime);
|
||||||
function("kicadTestFiberParkPoke", &kicadTestFiberParkPoke);
|
function("kicadTestFiberParkPoke", &kicadTestFiberParkPoke);
|
||||||
function("kicadTestFiberParkState", &kicadTestFiberParkState);
|
function("kicadTestFiberParkState", &kicadTestFiberParkState);
|
||||||
|
function("kicadTestFiberParkStartSecond", &kicadTestFiberParkStartSecond);
|
||||||
|
function("kicadTestFiberParkPokeSecond", &kicadTestFiberParkPokeSecond);
|
||||||
function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe);
|
function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe);
|
||||||
// Read-only viewer lock (read-only-viewer).
|
// Read-only viewer lock (read-only-viewer).
|
||||||
function("kicadSetReadOnly", &kicadSetReadOnly);
|
function("kicadSetReadOnly", &kicadSetReadOnly);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue