pcbjam/tests/kicad/coroutine-lifecycle.spec.ts
Viktor Vaczi 9c475a804e jspi cleanup: remove the asyncify-era residue — dead code, conditionals, pipeline scaffolding, stale prose
The runtime is JSPI-only; this removes everything that still pretended
otherwise. Three exhaustive sweeps (C++/JS+build+CI/tests+docs) drove
the inventory; every deletion verified by grep closure + full gates.

Broken-right-now fixes:
- deploy-staging.yml passed the retired opt_level input — the workflow
  could not even start. Removed.
- env.sh carried dead exports with a live -sASYNCIFY=1 inside
  (WASM_LDFLAGS/PTHREAD_LDFLAGS, zero consumers). Removed; the
  WASM_LEGACY_EXCEPTIONS rationale rewritten to the real reason.
- docker/build.sh exported PCBJAM_ASYNC_BACKEND (read nowhere). Gone.

Dead weight removed:
- binaryen submodule (nothing builds or invokes it), wasm-opt-bench
  workflow + scripts/bench/, get-wasm-opt.sh, diagnostics.js (242 lines
  of Asyncify-API-only code), the KICAD_PIPELINE background-postprocess
  scaffolding (existed to parallelize the deleted wasm-opt phase; the
  postprocess is a seconds-long node script and now runs inline),
  build-monitor's dead asyncify rows, sched-context orphan build
  output, dead .gitignore entries, the .jspi-assets spike dir (the two
  wf-result research JSONs moved to docs/features/async/migration-evidence/).
- bindings: fiber_park.h + its 12 embind registrations (broken-if-
  called under JSPI), the kicadOpenFileStart/OPEN_JOB starter route,
  main_stack_runner.h + 5 includes, the always-null context-sleep weak
  hook in nanosleep_yield.c.
- shim: the backend field (installed-flag idempotency instead),
  noteContextWait (dead both sides), the __wxAsyncifyDump alias (+ the
  WasmTool fallback and string-dump normalize branch).
- web: the emscripten-6-ignored mainScriptUrlOrBlob option in boot.ts
  (gerber-demo keeps it: it loads the deployed CDN release, which
  predates emscripten 6 — noted inline).

Conditionals: all 'backend === jspi' checks reduced to scheduler-
presence checks; races_quiescent re-keyed from Asyncify.state (vacuous)
to real backlog quiescence (resumeReady/mutatorQueue — NOT _windowLive,
which is the probing activation's own window by definition).

Renames (identifiers only, no file renames): ASYNC_LINK_FLAGS→
JSPI_LINK_FLAGS and Makefile ASYNC_LDFLAGS→JSPI_LDFLAGS,
kicadCollabFiberBusy→kicadCollabBusy (embind + web + tests),
collab_common.h fiber*→apply*/coroutine naming, asyncifySignatures→
wasmTrapSignatures (lists byte-identical).

Tests: the two remaining vacuous [wx-asyncify]/fiber-resume-refused
asserts re-keyed to live JSPI beacons; eeschema-load's failure message
no longer sends the developer to a deleted script; wait-beacons' dead
families/parser deleted; lane-0 legacy-glue guards removed (lane 0 is
unconstructible); the embind test.fail re-gated with the JSPI reason
(plain embind invokers cannot suspend — verified still failing);
lint-determinism now scans tests/jspi (166 files clean);
eeschema-collab local-move gated to chromium (~50% flaky on FF even
solo; pcbnew twin covers both engines).

Docs: DEBUG.md rewritten as the JSPI debugging guide; build.md
describes the single-phase build; docs/features/async/README.md
banner-marked historical and repointed at the NEW
23-jspi-runtime.md (current architecture: export census, turnstile,
libcontext ownership + refusal contract, embind call shapes, the
em-pthread service-wrapper trick, exception policy, known gaps).

Gates on the cleaned tree: test:e2e 725 passed / 0 failed (after the
quiescence-probe fix; the 3 other reds were verified contention flakes
solo-green or the documented FF gate), web 76/0, jspi 18/18 both
engines, vitest 295/295 + 17/17, all lints green, live-app census
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
2026-08-14 09:25:32 +02:00

193 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { loadBoard } from "./utils/threed-viewer";
import { waitForPcbnew } from "./utils/pcbnew-ready";
import { clickMenuBarItem, clickMenuItemByText } from "../e2e/utils/element-tracker";
/**
* JSPI coroutine lifecycle in the REAL editor — successor to
* fiber-resume-park.spec.ts (which pinned the retired asyncify rewind guard;
* that guard's refused-resume beacon string no longer exists, which made the
* old spec's engagement assert vacuous).
*
* The prod-shaped gate for the August 2026 ownership bug: coroutine.h's
* ~CALL_CONTEXT released a BORROWED context record (the live enterer of a
* nested dispatch); the JSPI backend read that as destroy-while-parked,
* killed the record, and the poisoned caller slot made PCB_SELECTION_TOOL's
* first Wait() dereference the raw -1 refusal: "coroutine 1 entry REJECTED:
* memory access out of bounds" at boot, then a "[wx-scheduler] job tick
* error" per tool activation and no dialog ever opening. This walks
* boot → board load → Place Footprints → chooser opens → Cancel → chooser
* closes, asserting a zero ghost/deadParked census and no rejection/trap
* lines at every stage. Deterministically RED on the pre-fix build.
*
* (The old spec's mid-park-resume staging lives on as the jspi-coroutine
* standalone harness, 18 protocol cases in both engines — the doc-15
* refusal, destroy-while-parked containment, and the phantom-release
* refusals are pinned there, against the same libcontext.cpp.)
*/
const TRAP_SIGNATURE =
/Aborted\(|index out of bounds|unreachable executed|indirect call signature|null function or function signature|memory access out of bounds|entry REJECTED|job tick error/;
interface CoroCensus {
ghosts: number;
deadParked: number;
refusedResumes: number;
quarantines: number;
parkedCoroutines: string[];
}
function census(page: Page): Promise<CoroCensus> {
return page.evaluate(() => {
const L = (globalThis as any).__libctxJspi;
const S = (globalThis as any).__wxScheduler;
const ring: [number, string, string, number][] = S?._ring ?? [];
return {
ghosts: L?.ghosts ?? -1,
deadParked: L?.deadParked ?? -1,
refusedResumes: ring.filter((r) => r[1] === "libctxRefusedResume").length,
quarantines: ring.filter((r) => r[1] === "libctxQuarantine").length,
parkedCoroutines: [...(S?._suspended?.keys?.() ?? [])].filter((k: string) =>
String(k).startsWith("lc"),
),
};
});
}
// The footprint chooser is a wxFrame, not a wxDialog — count top-level frames
// (same detection as footprint-chooser-close.spec.ts).
function frameCount(page: Page): Promise<number> {
return page.evaluate(
() =>
(window.wxElementRegistry?.findAll({ visible: true }) ?? []).filter((e) =>
/Frame$/.test(e.typeName || ""),
).length,
);
}
// Synthetic emscripten mouse events on the canvas: a Playwright click is
// intercepted by the wx scrollbar overlay (same helper as
// footprint-chooser-close.spec.ts).
async function synthClick(page: Page, x: number, y: number): Promise<void> {
await page.evaluate(
([cx, cy]) => {
const c = document.querySelector("#canvas") as HTMLCanvasElement;
const opt = (b: number) => ({
clientX: cx,
clientY: cy,
bubbles: true,
cancelable: true,
view: window,
button: 0,
buttons: b,
});
c.dispatchEvent(new MouseEvent("mousemove", opt(0)));
c.dispatchEvent(new MouseEvent("mousedown", opt(1)));
c.dispatchEvent(new MouseEvent("mouseup", opt(0)));
c.dispatchEvent(new MouseEvent("click", opt(0)));
},
[x, y],
);
}
test.describe("JSPI coroutine lifecycle (prod-shaped)", () => {
test("boot, board load and tool activation stay coroutine-clean; chooser opens and cancels", async ({
page,
testLogger,
}) => {
test.setTimeout(240000);
await page.goto("/kicad/pcbnew.html");
await waitForPcbnew(page);
const atBoot = await census(page);
console.log(`[TEST] boot census: ${JSON.stringify(atBoot)}`);
expect(atBoot.ghosts, "no ghost/refused transitions at boot").toBe(0);
expect(atBoot.deadParked, "no coroutine died parked at boot").toBe(0);
expect(
atBoot.parkedCoroutines.length,
"the always-on selection tool is parked in Wait()",
).toBeGreaterThan(0);
// The board load is the historical trigger surface (SetBoard →
// ResetTools(MODEL_RELOAD) ×2 → wake/dispatch storm over the parked
// selection tool — where the phantom release fired).
await loadBoard(page, testLogger);
const afterLoad = await census(page);
console.log(`[TEST] after-load census: ${JSON.stringify(afterLoad)}`);
expect(afterLoad.ghosts, "no ghost transitions across the board load").toBe(0);
expect(afterLoad.deadParked, "no coroutine died across the board load").toBe(0);
// Place Footprints via the menu, then a canvas click opens the chooser
// (the pre-fix build swallowed the activation: no dialog, job tick OOB).
const framesBefore = await frameCount(page);
expect(await clickMenuBarItem(page, "Place"), "Place menu findable").toBe(true);
await clickMenuItemByText(page, "Place Footprints");
const canvas = (await page.locator("#canvas").boundingBox())!;
await synthClick(page, Math.round(canvas.width * 0.35), Math.round(canvas.height * 0.45));
await expect
.poll(() => frameCount(page), { timeout: 40000, intervals: [200] })
.toBeGreaterThan(framesBefore);
const chooserOpen = await census(page);
console.log(`[TEST] chooser-open census: ${JSON.stringify(chooserOpen)}`);
expect(chooserOpen.ghosts, "no ghost transitions opening the chooser").toBe(0);
expect(chooserOpen.deadParked, "no coroutine died opening the chooser").toBe(0);
// Cancel the quasimodal. NOTE: in the harness the chooser's library
// enumeration stays parked on its `fp-lib` bridge wait forever (the page
// installs no `window.kicadLibs` provider), so the CLOSE itself is not
// assertable here — the same reason footprint-chooser-close.spec.ts
// soft-waits it. The live app (with a provider) closes on Cancel — probed
// as part of the 2026-08-13 fix verification. What IS assertable, and
// what the pre-fix build fails: the census stays clean and the app stays
// responsive across the whole exercise.
const cancel = await page.evaluate(() => {
const btn = (window.wxElementRegistry?.findAll({ visible: true }) ?? []).find(
(e) => /Button/i.test(e.typeName || "") && /cancel/i.test(e.label || ""),
);
return btn ? { x: btn.centerX, y: btn.centerY } : null;
});
expect(cancel, "Cancel button found in the chooser").not.toBeNull();
await synthClick(page, cancel!.x, cancel!.y);
await expect
.poll(() => frameCount(page), { timeout: 15000, intervals: [200] })
.toBe(framesBefore)
.catch(() =>
console.log(
"[TEST] chooser close not observable without a lib provider (fp-lib park) — soft",
),
);
// Responsiveness after the cancel: a wx timer still gets delivered (the
// doc-19 dead-app class froze the loop here).
const firedBefore = await page.evaluate(() => {
try { return JSON.parse((window as any).Module.kicadTestTimerParkState()).fired as number; }
catch { return -1; }
});
await page.evaluate(() => (window as any).Module.kicadTestArmTimerPark(30, 0));
await expect
.poll(
() =>
page.evaluate(() => {
try { return JSON.parse((window as any).Module.kicadTestTimerParkState()).fired as number; }
catch { return -1; }
}),
{ timeout: 10000, intervals: [100] },
)
.toBeGreaterThan(firedBefore);
const afterCancel = await census(page);
console.log(`[TEST] after-cancel census: ${JSON.stringify(afterCancel)}`);
expect(afterCancel.ghosts, "cancel left no ghost transitions").toBe(0);
expect(afterCancel.deadParked, "cancel killed no coroutine").toBe(0);
const trapLines = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) =>
TRAP_SIGNATURE.test(l),
);
expect(trapLines, "no trap/rejection signature anywhere in the run").toEqual([]);
});
});