import type { Page } from "@playwright/test"; import { test, expect } from "./fixtures"; import { expectGuardsSilent } from "./utils/wait-beacons"; /** * Timer-park concurrency repro (gal-refresh-timer investigation). * * The prod trap this spec was built to chase ("index out of bounds" + * "unreachable executed" in doRewind, v0.1.17–19, never reproduced naturally) * was an asyncify-era disease: a wx timer callback is a FRESH JS→wasm entry * (emscripten_async_call → TimerCallbackFunc::Run → Notify()), and a timer * handler that itself parked could overlap the main loop's in-place yield * park — two live suspension contexts over asyncify's single-slot state (the * emscripten #9153 family). The collab entries added the third ingredient: * they ran on TOOL_MANAGER fibers, which bypassed the old shim's accounting * entirely. Under JSPI every suspending entry owns its own suspender, so the * collision class is structurally gone; this spec pins that it STAYS gone. * * The natural trigger needs a timer handler that parks mid-paint * (scheduler-dependent; never hit locally). `kicadTestArmTimerPark` makes the * window deterministic: a one-shot wx timer whose Notify() emscripten_sleep()s * for a fixed time. Three escalating cycles: * * 1. timer park alone (timer chain suspends across frame yields) * 2. + collab entry hammering (adds coroutine switches through the window) * 3. same again (interleaving lottery, second draw) * * The spec asserts the runtime SURVIVES every cycle and that no scheduler or * libcontext anomaly beacon fires anywhere in the run. * * RE-PINNED AT THE FLIP (docs/features/async/22 §10, 2026-08-08): the staged * overlap needed the main loop's per-frame in-place park, which D5 removed. * The final assert now pins ZERO observable anomaly beacons — the * post-migration invariant — instead of demanding the overlap engage. */ const SEG_TARGET = "fa220000-0000-0000-0000-00000000cafe"; const PROBE_HOME = "10000000,10000000"; // on-disk position (IU) /** Compact deterministic board — enough items for a real snapshot walk. */ function board(): string { const lines: string[] = []; lines.push("(kicad_pcb"); lines.push("\t(version 20241229)"); lines.push('\t(generator "pcbnew")'); lines.push('\t(generator_version "9.0")'); lines.push("\t(general (thickness 1.6))"); lines.push('\t(paper "A4")'); lines.push("\t(layers"); lines.push('\t\t(0 "F.Cu" signal)'); lines.push('\t\t(2 "B.Cu" signal)'); lines.push('\t\t(25 "Edge.Cuts" user)'); lines.push("\t)"); lines.push("\t(setup)"); lines.push('\t(net 0 "")'); const uuid = (n: number) => `fa2${(n + 1).toString(16).padStart(5, "0")}-0000-0000-0000-000000000000`; let n = 0; for (let i = 0; i < 2000; i++) { const x = 20 + (i % 100) * 1.5; const y = 20 + Math.floor(i / 100) * 1; lines.push( `\t(segment (start ${x} ${y}) (end ${x + 1.2} ${y}) (width 0.2) (layer "F.Cu") (net 0) (uuid "${uuid(n++)}"))`, ); } lines.push( `\t(segment (start 10 10) (end 15 10) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG_TARGET}"))`, ); lines.push(")"); return lines.join("\n"); } type Mod = { kicadOpenFile(p: string): unknown; kicadOpenFileBusy(): boolean; kicadTestArmTimerPark(delayMs: number, parkMs: number): boolean; kicadTestTimerParkState(): string; kicadCollabSnapshotItems(): string; kicadCollabApply(j: string): unknown; kicadCollabGetPos(id: string): string; }; interface CycleStats { armed: boolean; fired: boolean; done: boolean; sawParked: boolean; hammerIters: number; /** Bytes the wasm heap grew mid-park (growHeap cycles; 0 = no growth). */ grewBytes: number; errors: string[]; elapsedMs: number; } const TRAP_SIGNATURE = /Aborted\(|index out of bounds|unreachable executed|indirect call signature|null function or function signature|memory access out of bounds/; async function bootHarness(page: Page): Promise { 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 }).Module; return ( typeof m?.kicadOpenFile === "function" && typeof m?.kicadCollabSnapshotItems === "function" && typeof m?.kicadTestArmTimerPark === "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 }, ); } /** Open the board and poll until the open chain truly settles. */ async function openAndSettle(page: Page, content: string): Promise { await page.evaluate((c) => { const w = window as unknown as { FS: { mkdirTree(p: string): void; writeFile(p: string, d: string): void }; Module: Mod; }; const dir = "/home/kicad/documents"; try { w.FS.mkdirTree(dir); } catch { /* exists */ } w.FS.writeFile(`${dir}/timerpark.kicad_pcb`, c); w.Module.kicadOpenFile(`${dir}/timerpark.kicad_pcb`); }, content); await expect .poll( () => page.evaluate(() => (window.Module as unknown as Mod).kicadOpenFileBusy()), { timeout: 120000, intervals: [250] }, ) .toBe(false); } /** * One repro cycle in-page: arm the parking timer, then poll its state until * the park completes — optionally hammering the coroutine-based collab entries * through the window (the prod settle fan-out shape). Every embind entry here * runs while the timer chain is suspended mid-Notify(): the exact * concurrent-entry interleaving under test. */ async function armAndRide( page: Page, opts: { parkMs: number; hammer: boolean; growHeap?: boolean }, ): Promise { return page.evaluate(async ({ parkMs, hammer, growHeap }) => { const m = (window as unknown as { Module: Mod }).Module; const before = JSON.parse(m.kicadTestTimerParkState()) as { fired: number; done: number }; const stats = { armed: false, fired: false, done: false, sawParked: false, hammerIters: 0, grewBytes: 0, errors: [] as string[], elapsedMs: 0, }; if (!m.kicadTestArmTimerPark(30, parkMs)) return stats; stats.armed = true; const t0 = performance.now(); // Bound = park length + generous resume budget; exits on completion. while (performance.now() - t0 < parkMs + 20000) { try { const st = JSON.parse(m.kicadTestTimerParkState()) as { fired: number; done: number; parked: boolean; }; if (st.fired > before.fired) stats.fired = true; if (st.parked) stats.sawParked = true; if (st.done > before.done) { stats.done = true; break; } } catch (e) { stats.errors.push(`state poll: ${String(e)}`); break; } // Heap growth mid-park (prod trace: `stage:done … GREW +187MB`): growth // detaches every JS heap view; a stale view held across it is one of // the few mechanisms that yields a bad function-table index LATER. // 256 MB per shot, deliberately leaked — the suspended chains' coroutine // stacks live in linear memory on both sides of the boundary. if (stats.fired && growHeap && !stats.grewBytes) { const alloc = ( m as unknown as { ___libc_malloc?: (n: number) => number } ).___libc_malloc; if (typeof alloc === "function") { const before = (window as unknown as { Module: { HEAPU8: Uint8Array } }).Module .HEAPU8.byteLength; alloc(256 * 1024 * 1024); const after = (window as unknown as { Module: { HEAPU8: Uint8Array } }).Module .HEAPU8.byteLength; stats.grewBytes = after - before; } else { stats.errors.push("growHeap: ___libc_malloc not exported"); } } if (stats.fired && hammer) { for (const [name, fn] of [ ["snapshotItems", () => m.kicadCollabSnapshotItems()], ["getPos", () => m.kicadCollabGetPos("fa220000-0000-0000-0000-00000000cafe")], ] as const) { try { fn(); stats.hammerIters++; } catch (e) { stats.errors.push(`${name} during park: ${String(e)}`); } } } await new Promise((r) => setTimeout(r, 10)); } stats.elapsedMs = Math.round(performance.now() - t0); return stats; }, opts); } test.describe("timer Notify() suspends during the main-loop frame yield (concurrent parks)", () => { test("runtime survives a parking timer handler, alone and under fiber hammering", async ({ page, testLogger, }) => { test.setTimeout(300000); await bootHarness(page); await openAndSettle(page, board()); const cycles: Array<{ label: string; hammer: boolean; growHeap?: boolean }> = [ { label: "park only", hammer: false }, { label: "park + fiber hammer", hammer: true }, { label: "park + fiber hammer (2nd draw)", hammer: true }, // Prod trace showed `GREW +187MB` on the crashing load: growth detaches // every JS heap view while TWO chains are parked (timer + main-loop // yield) and fibers swap through — the stale-view stale-buffer scenario. { label: "park + heap growth + fiber hammer", hammer: true, growHeap: true }, ]; for (const { label, hammer, growHeap } of cycles) { const stats = await armAndRide(page, { parkMs: 1500, hammer, growHeap }); console.log( `[TEST] ${label}: fired=${stats.fired} done=${stats.done} parked=${stats.sawParked} ` + `hammerIters=${stats.hammerIters} grew=${stats.grewBytes} ` + `elapsed=${stats.elapsedMs}ms errors=${stats.errors.length}`, ); expect(stats.armed, `${label}: timer armed`).toBe(true); expect(stats.fired, `${label}: Notify() entered`).toBe(true); expect(stats.sawParked, `${label}: the park window engaged`).toBe(true); expect(stats.done, `${label}: Notify() survived its park and rewound`).toBe(true); expect(stats.errors, `${label}: no embind entry trapped`).toEqual([]); if (growHeap) { expect(stats.grewBytes, `${label}: the heap actually grew mid-park`).toBeGreaterThan(0); } } // The runtime is still fully functional: snapshots walk the board and a // real apply lands (a poisoned suspension state fails one of these first). const itemCount = await page.evaluate( () => JSON.parse((window.Module as unknown as Mod).kicadCollabSnapshotItems()).added.length, ); expect(itemCount, "post-cycle snapshot sees the board").toBeGreaterThan(2000); await page.evaluate( (id) => (window.Module as unknown as Mod).kicadCollabApply( JSON.stringify({ added: [], changed: [ { id, type: "PCB_TRACK", sx: 12_000_000, sy: 34_000_000, ex: 17_000_000, ey: 34_000_000, width: 200000, }, ], removed: [], }), ), SEG_TARGET, ); await expect .poll( () => page.evaluate( (id) => (window.Module as unknown as Mod).kicadCollabGetPos(id), SEG_TARGET, ), { timeout: 10000, intervals: [200] }, ) .toBe("12000000,34000000"); // Console-level trap sweep: the prod signatures must not have appeared // anywhere (the page survives some of them as "Uncaught" noise). const trapLines = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) => TRAP_SIGNATURE.test(l), ); expect(trapLines, "no wasm trap signature anywhere in the run").toEqual([]); // Doc 17 S1 tripwire (arms itself when the wasm ships the C mailbox lane, // detected via the wxWasmMailboxTick export): timers are then delivered // from the mailbox only when the interlock is free, so the legacy 17 ms // parked-retry path must be SILENT. On legacy wasm this is skipped — the // retry storm there is expected and covered by the assertions above. // Both halves must be present: the wasm export (C lane compiled in) AND // the shim (variant injected) — a C-lane wasm on legacy glue keeps legacy // timer semantics, and its retry storms are expected. const cLane = await page.evaluate( () => typeof (window.Module as unknown as { _wxWasmMailboxTick?: unknown }) ._wxWasmMailboxTick === "function" && !!(globalThis as unknown as { __wxScheduler?: unknown }).__wxScheduler, ); if (cLane) expectGuardsSilent(testLogger.consoleLogs, ["timerRetry"]); // RE-PINNED AT THE FLIP (docs/features/async/22 §10, 2026-08-08), and // RE-KEYED for JSPI (2026-08-14): the asyncify scheduler's concurrent-park // beacon family retired with it, which made the old filter // vacuous. The invariant stands — the lever runs and the park survives // (asserted above) — and the observable JSPI failure modes of an overlap // are ghost/refused transitions, a stuck-window force-clear, a job-tick // trap, or a refused coroutine entry. const overlapLines = testLogger.consoleLogs.filter((l) => /\[libctx-jspi\] ghost\/refused transition|\[wx-scheduler\] (force-clearing stuck window|job tick error)|entry REJECTED/.test( l, ), ); console.log(`[TEST] overlap beacons: ${overlapLines.length} line(s)`); for (const l of overlapLines.slice(0, 10)) console.log(`[TEST] ${l}`); expect( overlapLines.length, "no ghost/stuck-window/job-tick/rejected-entry anomaly is observable post-flip", ).toBe(0); }); });