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
143 lines
5.7 KiB
TypeScript
143 lines
5.7 KiB
TypeScript
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([]);
|
|
});
|
|
});
|