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
143
tests/kicad/fiber-resume-park.spec.ts
Normal file
143
tests/kicad/fiber-resume-park.spec.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
/**
|
||||
* Resume-into-asyncify-parked-coroutine repro — the DECODED production
|
||||
* board-load trap (docs/features/async/15-timer-park-repro.md round 3).
|
||||
*
|
||||
* A coroutine suspended by a real yield has valid rewind data in its fiber
|
||||
* struct; one whose body is asyncify-parked inside handleSleep does NOT.
|
||||
* TOOL_MANAGER cannot tell the difference, so an event arriving during the
|
||||
* park Resume()s it → the swap rewinds the STALE suspension →
|
||||
* finishContextSwitch → doRewind → "unreachable executed", and the runtime is
|
||||
* poisoned ("index out of bounds" from every later entry). The
|
||||
* kicadTestFiberPark* levers (wasm/bindings/fiber_park.h) stage exactly that
|
||||
* state machine:
|
||||
*
|
||||
* start(parkMs) Call + first KiYield — valid suspension primed (phase 1)
|
||||
* prime() legitimate Resume; body parks in emscripten_sleep (phase 2)
|
||||
* poke() Resume DURING the park — the fatal prod operation
|
||||
*
|
||||
* This spec asserts the HEALTHY contract: the mid-park poke must be refused
|
||||
* (null-INVOCATION_ARGS ghost contract), the body must complete its park and
|
||||
* yield again undisturbed, a post-yield poke must resume it for real, and no
|
||||
* trap signature may appear anywhere. On a runtime without the libcontext
|
||||
* guard this is deterministically RED with the prod signature.
|
||||
*/
|
||||
|
||||
const TRAP_SIGNATURE =
|
||||
/Aborted\(|index out of bounds|unreachable executed|indirect call signature|null function or function signature|memory access out of bounds/;
|
||||
|
||||
type Mod = {
|
||||
kicadTestFiberParkStart(parkMs: number): boolean;
|
||||
kicadTestFiberParkPrime(): boolean;
|
||||
kicadTestFiberParkPoke(): boolean;
|
||||
kicadTestFiberParkState(): string;
|
||||
kicadCollabSnapshotItems(): string;
|
||||
};
|
||||
|
||||
interface ParkState {
|
||||
phase: number;
|
||||
pokes: number;
|
||||
parkMs: number;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
async function bootHarness(page: Page): Promise<void> {
|
||||
await page.goto("/kicad/pcbnew-collab.html");
|
||||
await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 });
|
||||
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const m = (window as unknown as { Module?: Partial<Mod> }).Module;
|
||||
return (
|
||||
typeof m?.kicadTestFiberParkStart === "function" &&
|
||||
typeof m?.kicadCollabSnapshotItems === "function"
|
||||
);
|
||||
},
|
||||
null,
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
!!window.wxElementRegistry &&
|
||||
window.wxElementRegistry
|
||||
.findAll({ visible: true })
|
||||
.some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")),
|
||||
null,
|
||||
{ timeout: 90000 },
|
||||
);
|
||||
}
|
||||
|
||||
function parkState(page: Page): Promise<ParkState> {
|
||||
return page.evaluate(() =>
|
||||
JSON.parse((window.Module as unknown as Mod).kicadTestFiberParkState()),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("Resume() into an asyncify-parked coroutine (libcontext guard)", () => {
|
||||
test("mid-park resume is refused; the parked body completes undisturbed", async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
test.setTimeout(240000);
|
||||
await bootHarness(page);
|
||||
|
||||
// Phase 1: prime a real suspension (Call + first KiYield). NOTE: embind
|
||||
// return values are asyncify unwind PLACEHOLDERS for anything that
|
||||
// crosses a fiber swap (the real return lands in the discarded ghost
|
||||
// rewind) — every assertion here is on polled state, never on returns.
|
||||
await page.evaluate(() => {
|
||||
(window.Module as unknown as Mod).kicadTestFiberParkStart(2000);
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await parkState(page)).phase, { timeout: 10000, intervals: [50] })
|
||||
.toBe(1);
|
||||
|
||||
// Phase 2: legitimate resume; the body enters its 2s asyncify park.
|
||||
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 PROD OPERATION: resume while the body is parked. On an unguarded
|
||||
// runtime this rewinds the stale fiber suspension and traps right here
|
||||
// (the page's uncaught "unreachable executed"); with the guard it is a
|
||||
// clean no-op refusal.
|
||||
await page.evaluate(() => {
|
||||
(window.Module as unknown as Mod).kicadTestFiberParkPoke();
|
||||
});
|
||||
const afterPoke = await parkState(page);
|
||||
console.log(`[TEST] mid-park poke: ${JSON.stringify(afterPoke)}`);
|
||||
expect(afterPoke.pokes, "poke reached the coroutine layer").toBe(1);
|
||||
expect(afterPoke.phase, "refused poke left the parked body undisturbed").toBe(2);
|
||||
|
||||
// The park must complete on its own wake and yield again (phase 3) —
|
||||
// if the poke corrupted the fiber, the wake rewind dies instead.
|
||||
await expect
|
||||
.poll(async () => (await parkState(page)).phase, { timeout: 15000, intervals: [100] })
|
||||
.toBe(3);
|
||||
|
||||
// A post-yield poke is a LEGITIMATE resume and must work (the guard must
|
||||
// not refuse valid suspensions): body runs to completion.
|
||||
await page.evaluate(() => {
|
||||
(window.Module as unknown as Mod).kicadTestFiberParkPoke();
|
||||
});
|
||||
await expect
|
||||
.poll(async () => (await parkState(page)).phase, { timeout: 10000, intervals: [100] })
|
||||
.toBe(4);
|
||||
|
||||
// Runtime integrity: a model walk still works and no trap signature
|
||||
// appeared anywhere in the console.
|
||||
const snapshot = await page.evaluate(() =>
|
||||
(window.Module as unknown as Mod).kicadCollabSnapshotItems(),
|
||||
);
|
||||
expect(typeof snapshot, "snapshot entry still functional").toBe("string");
|
||||
const trapLines = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) =>
|
||||
TRAP_SIGNATURE.test(l),
|
||||
);
|
||||
expect(trapLines, "no wasm trap signature anywhere in the run").toEqual([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue