pcbjam/tests/kicad/fiber-resume-park.spec.ts
Gergő Törcsvári e561507898
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
2026-07-31 23:37:05 +02:00

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([]);
});
});