pcbjam/tests/kicad/utils/trio.ts

482 lines
18 KiB
TypeScript
Raw Normal View History

import path from "node:path";
import type { BrowserContext, Page } from "@playwright/test";
import { expect } from "@playwright/test";
/**
* Trio harness (standalone-hardening 0008): three tabs as three users in one
* room — A seeds + edits, B edits, C only observes. Runs the PRODUCTION v2
* items stack via collab-bundle-v2.js (window.KicadCollabV2), same plumbing as
* ysync-two-tab.spec.ts, generalized to N pages with a per-step oracle sweep:
*
* 1. per-tab drift silence — driftReport(): editor model ≡ room Y.Doc
* 2. cross-tab convergence — every tab's silent save is byte-identical
* 3. room materialization — docToFile(yToDoc(doc)) renders, identically
*
* BroadcastChannel does not cross Playwright contexts, so all tabs live in ONE
* context. Heavy editors (eeschema/pcbnew) exceed Firefox's per-content-process
* wasm budget at 2+ instances — trio specs must skip firefox and run Chromium.
*/
type FSApi = {
mkdirTree(p: string): void;
writeFile(p: string, d: string): void;
readFile(p: string, o: { encoding: "utf8" }): string;
};
type Mod = Record<string, (...a: never[]) => unknown>;
export interface ToolCfg {
html: string;
ext: string;
saveFn: string;
fixture: string;
/** Module fns (beyond the v2 bridge pair + saveFn) boot must wait for. */
fns: string[];
}
export const BOOT_TIMEOUT = 150000;
/** All trio tabs open the SAME document name (it is the same file). Symbol
* `(instances (project "trio" …))` entries must match this name. */
export const TRIO_DOC = "trio";
findings(E-10..E-22): fix the defects a code review found in the E-1..E-9 work A review of the group-E fixes found 13 further defects; ten were introduced by those fixes, two pre-existed and were merely relocated, one is deferred. Services / transport E-10 retireWorker synthesized no bg/exit frame, so sharedspice's s_bgRunning mirror stayed latched true after a mid-run worker death: Run stayed disabled and the promised fresh-worker restart was unreachable for the whole session. Retirement now dispatches a synthetic controlled-exit straight to the installed handler (never through dispatchEvt — a fabricated frame must not touch the credit ledger). Driving the repro exposed two further defects, both fixed here: a replacement worker trapped on pre-init engine reads, and the rerun's cm_input_path/circ hit that uninitialized engine before KiCad's validate() re-init (the native flow assumes a crashed engine survives in-process — true for the dll, false for a dead worker). Reads now answer their empty shapes pre-init, writes lazy-init, and init is idempotent per worker engine. E-19 dispatchEvt acked only AFTER handler(evt) returned, and the sharedspice client deliberately rethrows non-trap errors — so each throw leaked one unit of the 64-frame credit window until the stream died with a misattributed "transport exceeded". The ack moves to a finally in both service copies; the throw still propagates (the trap machinery needs it). E-20 the oversize-line path promises to transfer the accepted prefix, but with the window full that flush only DEFERS, and stopEventStream wiped the deferred queue — losing the diagnostics that explain the failure. The terminal notice now carries them as pendingEvents; both hosts deliver them in order, unacked (the fatal frame is outside the credit protocol). E-21 the 30s prefetch deadline discarded every model already collected and reported nothing. A caller-owned progress sink ships the partials and the omission reaches the export report. (Awaiting the aborted collection was rejected: an in-flight source fetch is not abortable — E-4's original disease.) Plus a serving-candidate memo, so a .wrl ref served by its .step fallback stops re-probing the miss on every export. Scheduler E-14 _terminalizeNativeTrap classified by message substring, so any plain JS error QUOTING 'Aborted(' or 'out of bounds' permanently bricked a healthy instance. Now structural only: instanceof RuntimeError plus a duck-typed name check (verified in this build's glue that abort() throws a genuine RuntimeError both pre- and post-runtime-init). Module.onAbort now latches the gate — the authoritative notification, previously ignored. E-15 the shim half: _pumpResume gates on terminal (catching wakes already queued at latch time) and resolveWait refuses on terminal WITHOUT consuming the entry, so a frame stays visibly parked rather than resuming inside a trapped module. E-16 the E-5 handler read the realm-global scheduler at dispatch instead of its installing module's; also frees the per-line buffer on the non-trap rethrow path. E-11 get_vec trusted the worker's res.length over the transferred arrays. Observed death shape: a 4 GiB std::vector threw an unhandled std::length_error that exited the editor's main loop. Now clamped, with the buffers freed on every failure path. Guardrails (replacing two deferred refactors: e2e→production-code injection and collapsing the four copies of the worker-lifecycle machinery) E-18 the source contract asserted comment-string counts — rewording failed CI while moving a guard outside its #ifdef passed. It now parses the #ifdef regions and asserts on code. service-stub-parity.ts pins what the four lifecycle copies must share: credit-window equality parsed from source, the finally-ack, boot deadlines, terminal-notice consumption. The transport numbers are now single-sourced from the worker. CI actually runs the gates: the web/standalone vitest suites (which had NEVER run in CI), the reducer, the source contract and the parity tool — with a NON_PLAYWRIGHT_GATES check so deleting a step re-fails the lint. E-22 the e2e occ stub's 60s boot watchdog, deleted in a66e109, is restored in the ngspice-stub shape with a wedgeNextBoot() repro hook. Every behavioral fix has red-then-green evidence (the reds were captured first). E-17 (a stale RUNNING cross-stamping the next run's generation under E-6's transport deferral) is DEFERRED with its analysis recorded — a real fix needs run identity on the bg frames. Test hygiene: the dwell lint now requires the mandated ": <why>" and all 47 bare markers carry their reason; three export-report dwells became modal-lease polls; exact-ledger assertions became relative deltas; the dead data-wx-dom-id branch, an unused fault hook and unused receipt plumbing are gone; abort scans, wx dialog drivers, the sim harness and the vitest FakeWorker are each one copy now. Bumps kicad and wxwidgets to their findings-group-e tips. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:27:12 +02:00
import { hasNativeFailure } from "./native-failure";
export function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean {
findings(E-10..E-22): fix the defects a code review found in the E-1..E-9 work A review of the group-E fixes found 13 further defects; ten were introduced by those fixes, two pre-existed and were merely relocated, one is deferred. Services / transport E-10 retireWorker synthesized no bg/exit frame, so sharedspice's s_bgRunning mirror stayed latched true after a mid-run worker death: Run stayed disabled and the promised fresh-worker restart was unreachable for the whole session. Retirement now dispatches a synthetic controlled-exit straight to the installed handler (never through dispatchEvt — a fabricated frame must not touch the credit ledger). Driving the repro exposed two further defects, both fixed here: a replacement worker trapped on pre-init engine reads, and the rerun's cm_input_path/circ hit that uninitialized engine before KiCad's validate() re-init (the native flow assumes a crashed engine survives in-process — true for the dll, false for a dead worker). Reads now answer their empty shapes pre-init, writes lazy-init, and init is idempotent per worker engine. E-19 dispatchEvt acked only AFTER handler(evt) returned, and the sharedspice client deliberately rethrows non-trap errors — so each throw leaked one unit of the 64-frame credit window until the stream died with a misattributed "transport exceeded". The ack moves to a finally in both service copies; the throw still propagates (the trap machinery needs it). E-20 the oversize-line path promises to transfer the accepted prefix, but with the window full that flush only DEFERS, and stopEventStream wiped the deferred queue — losing the diagnostics that explain the failure. The terminal notice now carries them as pendingEvents; both hosts deliver them in order, unacked (the fatal frame is outside the credit protocol). E-21 the 30s prefetch deadline discarded every model already collected and reported nothing. A caller-owned progress sink ships the partials and the omission reaches the export report. (Awaiting the aborted collection was rejected: an in-flight source fetch is not abortable — E-4's original disease.) Plus a serving-candidate memo, so a .wrl ref served by its .step fallback stops re-probing the miss on every export. Scheduler E-14 _terminalizeNativeTrap classified by message substring, so any plain JS error QUOTING 'Aborted(' or 'out of bounds' permanently bricked a healthy instance. Now structural only: instanceof RuntimeError plus a duck-typed name check (verified in this build's glue that abort() throws a genuine RuntimeError both pre- and post-runtime-init). Module.onAbort now latches the gate — the authoritative notification, previously ignored. E-15 the shim half: _pumpResume gates on terminal (catching wakes already queued at latch time) and resolveWait refuses on terminal WITHOUT consuming the entry, so a frame stays visibly parked rather than resuming inside a trapped module. E-16 the E-5 handler read the realm-global scheduler at dispatch instead of its installing module's; also frees the per-line buffer on the non-trap rethrow path. E-11 get_vec trusted the worker's res.length over the transferred arrays. Observed death shape: a 4 GiB std::vector threw an unhandled std::length_error that exited the editor's main loop. Now clamped, with the buffers freed on every failure path. Guardrails (replacing two deferred refactors: e2e→production-code injection and collapsing the four copies of the worker-lifecycle machinery) E-18 the source contract asserted comment-string counts — rewording failed CI while moving a guard outside its #ifdef passed. It now parses the #ifdef regions and asserts on code. service-stub-parity.ts pins what the four lifecycle copies must share: credit-window equality parsed from source, the finally-ack, boot deadlines, terminal-notice consumption. The transport numbers are now single-sourced from the worker. CI actually runs the gates: the web/standalone vitest suites (which had NEVER run in CI), the reducer, the source contract and the parity tool — with a NON_PLAYWRIGHT_GATES check so deleting a step re-fails the lint. E-22 the e2e occ stub's 60s boot watchdog, deleted in a66e109, is restored in the ngspice-stub shape with a wedgeNextBoot() repro hook. Every behavioral fix has red-then-green evidence (the reds were captured first). E-17 (a stale RUNNING cross-stamping the next run's generation under E-6's transport deferral) is DEFERRED with its analysis recorded — a real fix needs run identity on the bg frames. Test hygiene: the dwell lint now requires the mandated ": <why>" and all 47 bare markers carry their reason; three export-report dwells became modal-lease polls; exact-ledger assertions became relative deltas; the dead data-wx-dom-id branch, an unused fault hook and unused receipt plumbing are gone; abort scans, wx dialog drivers, the sim harness and the vitest FakeWorker are each one copy now. Bumps kicad and wxwidgets to their findings-group-e tips. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:27:12 +02:00
return hasNativeFailure(l);
}
// ── Fixtures ─────────────────────────────────────────────────────────────────
// Adapted from the proven roundtrip.spec.ts fixtures: the schematic carries a
// real symbol (lib_symbols + instances — required by TestSetFieldText and the
// instance-path drift class), the board's footprint carries pads (required by
// TestSetPadSize).
export const SCH_ROOT = "11111111-1111-1111-1111-111111111111";
export const SYM1 = "33333333-0000-0000-0000-000000000001";
export const WIRE1 = "22222222-0000-0000-0000-000000000001";
export const WIRE2 = "22222222-0000-0000-0000-000000000002";
export const TRIO_SCH: ToolCfg = {
html: "eeschema.html",
ext: "kicad_sch",
saveFn: "kicadSaveSchematic",
fns: [
"kicadCollabTestMoveFirst",
"kicadCollabGetPos",
"kicadCollabTestRotateItem",
"kicadCollabTestRemoveItem",
"kicadCollabTestSetFieldText",
"kicadCollabTestUndoDepth",
// drift-trio phase B action hooks
"kicadCollabTestAddWire",
"kicadCollabTestAddJunction",
"kicadCollabTestAddNoConnect",
"kicadCollabTestAddLabel",
"kicadCollabTestAddSymbol",
"kicadCollabTestMoveSchItem",
"kicadCollabTestMirrorSchItem",
"kicadCollabTestDuplicateSchItem",
],
fixture: `(kicad_sch
\t(version 20250114)
\t(generator "eeschema")
\t(generator_version "9.0")
\t(uuid "${SCH_ROOT}")
\t(paper "A4")
\t(lib_symbols
\t\t(symbol "Device:R"
\t\t\t(pin_numbers (hide yes))
\t\t\t(pin_names (offset 0))
\t\t\t(exclude_from_sim no)
\t\t\t(in_bom yes)
\t\t\t(on_board yes)
\t\t\t(property "Reference" "R" (at 2.032 0 90) (effects (font (size 1.27 1.27))))
\t\t\t(property "Value" "R" (at 0 0 90) (effects (font (size 1.27 1.27))))
\t\t\t(symbol "R_0_1"
\t\t\t\t(rectangle (start -1.016 -2.54) (end 1.016 2.54)
\t\t\t\t\t(stroke (width 0.254) (type default)) (fill (type none)))
\t\t\t)
\t\t\t(symbol "R_1_1"
\t\t\t\t(pin passive line (at 0 3.81 270) (length 1.27)
\t\t\t\t\t(name "~" (effects (font (size 1.27 1.27))))
\t\t\t\t\t(number "1" (effects (font (size 1.27 1.27)))))
\t\t\t\t(pin passive line (at 0 -3.81 90) (length 1.27)
\t\t\t\t\t(name "~" (effects (font (size 1.27 1.27))))
\t\t\t\t\t(number "2" (effects (font (size 1.27 1.27)))))
\t\t\t)
\t\t)
\t)
\t(symbol
\t\t(lib_id "Device:R")
\t\t(at 100 100 0)
\t\t(unit 1)
\t\t(exclude_from_sim no)
\t\t(in_bom yes)
\t\t(on_board yes)
\t\t(dnp no)
\t\t(uuid "${SYM1}")
\t\t(property "Reference" "R1" (at 102 99 0) (effects (font (size 1.27 1.27)) (justify left)))
\t\t(property "Value" "R" (at 102 101 0) (effects (font (size 1.27 1.27)) (justify left)))
\t\t(pin "1" (uuid "33333333-0000-0000-0000-0000000000a1"))
\t\t(pin "2" (uuid "33333333-0000-0000-0000-0000000000a2"))
\t\t(instances
\t\t\t(project "${TRIO_DOC}"
\t\t\t\t(path "/${SCH_ROOT}" (reference "R1") (unit 1))
\t\t\t)
\t\t)
\t)
\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}"))
\t(wire (pts (xy 50.8 76.2) (xy 101.6 76.2)) (stroke (width 0) (type default)) (uuid "${WIRE2}"))
\t(sheet_instances (path "/" (page "1")))
)
`,
};
export const FP1 = "66666666-0000-0000-0000-000000000001";
export const PAD1 = "66666666-0000-0000-0000-0000000000d1";
export const PAD2 = "66666666-0000-0000-0000-0000000000d2";
export const FP1_TXT = "66666666-0000-0000-0000-0000000000cc";
export const VIA1 = "77777777-0000-0000-0000-000000000001";
export const SEG1 = "88888888-0000-0000-0000-000000000001";
export const SEG2 = "88888888-0000-0000-0000-000000000002";
export const TRIO_PCB: ToolCfg = {
// pcbnew-collab.html: memory-tuned + seeds kicad_common.json so the first-run
// wizard modal never blocks boot (see pcbnew-collab.spec.ts).
html: "pcbnew-collab.html",
ext: "kicad_pcb",
saveFn: "kicadSaveBoard",
fns: [
"kicadCollabTestMoveFirst",
"kicadCollabGetPos",
"kicadCollabTestRotateItem",
"kicadCollabTestRemoveItem",
"kicadCollabTestSetPadSize",
"kicadCollabTestMoveEndpoint",
"kicadCollabTestUndoDepth",
// drift-trio phase B action hooks
"kicadCollabTestAddTrack",
"kicadCollabTestAddVia",
"kicadCollabTestAddBoardText",
"kicadCollabTestAddZone",
"kicadCollabTestFlipBoardItem",
"kicadCollabTestSetFootprintField",
"kicadCollabTestSetBoardItemLocked",
"kicadCollabTestMoveBoardItem",
"kicadCollabTestDuplicateBoardItem",
],
fixture: `(kicad_pcb
\t(version 20241229)
\t(generator "pcbnew")
\t(generator_version "9.0")
\t(general (thickness 1.6))
\t(paper "A4")
\t(layers
\t\t(0 "F.Cu" signal)
\t\t(2 "B.Cu" signal)
\t\t(37 "F.SilkS" user)
\t\t(25 "Edge.Cuts" user)
\t)
\t(setup)
\t(net 0 "")
\t(footprint "TestLib:R"
\t\t(layer "F.Cu")
\t\t(uuid "${FP1}")
\t\t(at 100 100)
\t\t(attr smd)
\t\t(property "Reference" "R1" (at 0 -4.2 0) (layer "F.SilkS") (uuid "66666666-0000-0000-0000-0000000000aa") (effects (font (size 1 1) (thickness 0.15))))
\t\t(property "Value" "R" (at 0 4.6 0) (layer "F.Fab") (uuid "66666666-0000-0000-0000-0000000000bb") (effects (font (size 1 1) (thickness 0.15))))
\t\t(property "Datasheet" "" (at 0 0 0) (layer "F.Fab") (hide yes) (uuid "66666666-0000-0000-0000-0000000000d3") (effects (font (size 1.27 1.27))))
\t\t(property "Description" "" (at 0 0 0) (layer "F.Fab") (hide yes) (uuid "66666666-0000-0000-0000-0000000000d4") (effects (font (size 1.27 1.27))))
\t\t(fp_text user "HELLO" (at 0 0 0) (layer "F.SilkS") (uuid "${FP1_TXT}") (effects (font (size 1 1) (thickness 0.15))))
\t\t(pad "1" smd rect (at -1.27 0) (size 1.2 1) (layers "F.Cu") (uuid "${PAD1}"))
\t\t(pad "2" smd rect (at 1.27 0) (size 1.2 1) (layers "F.Cu") (uuid "${PAD2}"))
\t)
\t(via (at 80 80) (size 1.4) (drill 0.6) (layers "F.Cu" "B.Cu") (net 0) (uuid "${VIA1}"))
\t(segment (start 50.8 50.8) (end 101.6 50.8) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG1}"))
\t(segment (start 50.8 76.2) (end 101.6 76.2) (width 0.2) (layer "F.Cu") (net 0) (uuid "${SEG2}"))
)
`,
};
export const PL_TEXT1 = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
export const PL_RECT = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
/** pl_editor: the small tool whose emit hook is eager (OnModify) — the green
* baseline that validates the trio plumbing itself, independent of the heavy
* editors (three pl_editor tabs even fit Firefox's wasm budget). */
export const TRIO_PL: ToolCfg = {
html: "pl_editor.html",
ext: "kicad_wks",
saveFn: "kicadSaveDrawingSheet",
// pl_editor has no undo-depth hook — the undo oracle is heavy-tools-only.
fns: ["kicadCollabTestAddText"],
fixture: `(kicad_wks (version 20220228) (generator "pl_editor") (generator_version "9.0")
(setup (textsize 1.5 1.5)(linewidth 0.15)(textlinewidth 0.15)
(left_margin 10)(right_margin 10)(top_margin 10)(bottom_margin 10))
(rect (uuid "${PL_RECT}") (name border) (start 0 0 ltcorner) (end 0 0 rbcorner))
(tbtext "Title" (uuid "${PL_TEXT1}") (name title) (pos 100 20 ltcorner) (font (size 2 2)))
)
`,
};
// ── Boot + session plumbing (bootOpen recipe from ysync-two-tab.spec.ts) ────
const BUNDLE = path.resolve(__dirname, "../../apps/kicad/collab-bundle-v2.js");
export async function bootOpen(page: Page, cfg: ToolCfg): Promise<void> {
await page.goto(`/kicad/${cfg.html}`);
await expect(page.locator("#canvas")).toBeVisible({ timeout: BOOT_TIMEOUT });
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: BOOT_TIMEOUT });
await page.waitForFunction(
(fns) => {
const m = (window as unknown as { Module?: Mod }).Module;
return !!m && fns.every((f) => typeof m[f] === "function");
},
["kicadOpenFile", "kicadCollabSnapshotItems", "kicadCollabApplyItems", cfg.saveFn, ...cfg.fns],
{ timeout: BOOT_TIMEOUT },
);
await page.waitForFunction(
() =>
!!window.wxElementRegistry &&
window.wxElementRegistry
.findAll({ visible: true })
.some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")),
null,
{ timeout: BOOT_TIMEOUT },
);
await page.evaluate(
({ content, ext, name }) => {
const w = window as unknown as { FS: FSApi; Module: { kicadOpenFile(p: string): unknown } };
try {
w.FS.mkdirTree("/home/kicad/documents");
} catch {
/* exists */
}
const p = `/home/kicad/documents/${name}.${ext}`;
w.FS.writeFile(p, content);
w.Module.kicadOpenFile(p);
},
{ content: cfg.fixture, ext: cfg.ext, name: TRIO_DOC },
);
await expect.poll(() => page.title(), { timeout: 30000 }).toMatch(new RegExp(TRIO_DOC, "i"));
await page.addScriptTag({ path: BUNDLE });
}
export function startV2(
page: Page,
opts: { room: string; settleMs?: number; seedText?: string; editorMatchesDoc?: boolean },
): Promise<void> {
return page.evaluate(async (o) => {
const w = window as unknown as {
KicadCollabV2: { start: (m: unknown, win: unknown, o: unknown) => Promise<void> };
Module: unknown;
};
await w.KicadCollabV2.start(w.Module, window, o);
}, opts);
}
/** Anything with labeled tabs — the oracles work on trios, duos, quads alike. */
export interface TabSet {
tabs: ReadonlyArray<readonly [label: string, page: Page]>;
}
export interface Trio extends TabSet {
A: Page;
B: Page;
C: Page;
}
/**
* Boot three tabs on the same fixture + room. A file-seeds (the production
* first-tab path — its listener registers via seed's snapshotItems, ysync bug
* 01's regression surface); B and C open the identical file and join with
* editorMatchesDoc (the ydoc-load style baseline-only path). Returns after all
* three converge on the seeded state.
*/
export async function openTrio(context: BrowserContext, cfg: ToolCfg, room: string): Promise<Trio> {
const [A, B, C] = [await context.newPage(), await context.newPage(), await context.newPage()];
// Sequential boots: three concurrent wasm compiles thrash; the two-tab spec
// boots serially for the same reason.
await bootOpen(A, cfg);
await bootOpen(B, cfg);
await bootOpen(C, cfg);
await startV2(A, { room, seedText: cfg.fixture });
await startV2(B, { room, editorMatchesDoc: true });
await startV2(C, { room, editorMatchesDoc: true });
const trio: Trio = {
A,
B,
C,
tabs: [
["A", A],
["B", B],
["C", C],
],
};
await settleConverged(trio, cfg);
return trio;
}
export async function closeTrio(trio: Trio): Promise<void> {
for (const [, page] of trio.tabs) await page.close();
}
// ── Per-tab probes ───────────────────────────────────────────────────────────
/** Silent save-to-MEMFS + read back — no onSave side effects. Defers while
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
* collab coroutine work is in flight: a bare-embind-stack save during a parked
* apply mis-dispatches (finding #10b) — the wait is JS-side, so it is safe. */
export function modelText(page: Page, cfg: ToolCfg): Promise<string> {
return page.evaluate(
async ({ saveFn, ext }) => {
const w = window as unknown as {
FS: FSApi;
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
Module: Mod & { kicadCollabBusy?: () => boolean };
};
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
for (let i = 0; i < 200 && w.Module.kicadCollabBusy?.(); i++) {
await new Promise((r) => setTimeout(r, 25));
}
const out = `/home/kicad/documents/_dump.${ext}`;
(w.Module[saveFn] as (p: string) => unknown)(out);
return w.FS.readFile(out, { encoding: "utf8" });
},
{ saveFn: cfg.saveFn, ext: cfg.ext },
);
}
/** docToFile(yToDoc(room doc)) in-page; { err } instead of throwing. */
export function renderDoc(page: Page): Promise<{ ok?: string; err?: string }> {
return page.evaluate(() => {
const w = window as unknown as { KicadCollabV2: { renderActiveDoc(): string } };
try {
return { ok: w.KicadCollabV2.renderActiveDoc() };
} catch (e) {
return { err: String(e) };
}
});
}
export interface DriftSummary {
added: string[];
updated: string[];
removed: string[];
reordered: string[];
layoutChanged: boolean;
layoutReordered: boolean;
metaChanged: boolean;
}
/** Item-level drift summary via the production comparator (browser-entry-v2). */
export function drift(page: Page, cfg: ToolCfg): Promise<DriftSummary | null> {
return page.evaluate(
async ({ saveFn, ext }) => {
const w = window as unknown as {
KicadCollabV2: { driftReport(f: string, p: string): DriftSummary | null };
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
Module: { kicadCollabBusy?: () => boolean };
};
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
for (let i = 0; i < 200 && w.Module.kicadCollabBusy?.(); i++) {
await new Promise((r) => setTimeout(r, 25));
}
return w.KicadCollabV2.driftReport(saveFn, `/home/kicad/documents/_drift.${ext}`);
},
{ saveFn: cfg.saveFn, ext: cfg.ext },
);
}
/** Invoke a Module.* test hook with plain args and return its result. */
export function callHook<T>(page: Page, fn: string, ...args: (string | number)[]): Promise<T> {
return page.evaluate(
({ fn, args }) => {
const w = window as unknown as { Module: Record<string, (...a: unknown[]) => unknown> };
return w.Module[fn]!(...args);
},
{ fn, args },
) as Promise<T>;
}
export function getPos(page: Page, uuid: string): Promise<string> {
return callHook<string>(page, "kicadCollabGetPos", uuid);
}
export function undoDepth(page: Page): Promise<number> {
return callHook<number>(page, "kicadCollabTestUndoDepth");
}
// ── Oracles ──────────────────────────────────────────────────────────────────
/** First differing line between two texts, for readable failure messages. */
function firstDiff(a: string, b: string): string {
const la = a.split("\n");
const lb = b.split("\n");
for (let i = 0; i < Math.max(la.length, lb.length); i++) {
if (la[i] !== lb[i]) {
return `line ${i + 1}:\n a: ${la[i] ?? "<missing>"}\n b: ${lb[i] ?? "<missing>"}`;
}
}
return "<no line diff — length/EOL mismatch>";
}
/**
* Poll until every tab's silent save is byte-identical. The convergence gate
* between scenario steps — bounded poll, no blind sleeps (tests/TESTING.md).
*/
export async function settleConverged(trio: TabSet, cfg: ToolCfg, timeout = 90000): Promise<void> {
// On timeout the failure names WHICH tab diverged and the first differing
// line — a bare `false` hid the actual divergence (memory: s4-value-race).
let last: string[] = [];
try {
await expect
.poll(
async () => {
last = await Promise.all(trio.tabs.map(([, p]) => modelText(p, cfg)));
return last.every((t) => t === last[0]);
},
{ timeout, intervals: [400] },
)
.toBe(true);
} catch (e) {
const labels = trio.tabs.map(([l]) => l);
const diffs = last
.map((t, i) => (i > 0 && t !== last[0] ? `${labels[0]} vs ${labels[i]}: ${firstDiff(last[0], t)}` : null))
.filter(Boolean);
throw new Error(`trio did not converge:\n${diffs.join("\n")}\n\n${(e as Error).message}`);
}
}
/**
* The full oracle sweep, run at every settle point:
* 1. every tab is drift-silent at the ITEM level (added/updated/removed —
* layoutChanged/metaChanged are NOT asserted: non-item state only syncs at
* seed, and the writer may normalize preamble formatting; reordered /
* layoutReordered are not drift by definition),
* 2. all three silent saves are byte-identical,
* 3. the room doc materializes, identically, on every tab.
*/
export async function oracleSweep(trio: TabSet, cfg: ToolCfg): Promise<void> {
for (const [label, page] of trio.tabs) {
const d = await drift(page, cfg);
expect(d?.added ?? [], `${label} drift added`).toEqual([]);
expect(d?.updated ?? [], `${label} drift updated`).toEqual([]);
expect(d?.removed ?? [], `${label} drift removed`).toEqual([]);
}
const texts = await Promise.all(trio.tabs.map(([, p]) => modelText(p, cfg)));
for (let i = 1; i < texts.length; i++) {
expect(
texts[i] === texts[0],
`model ${trio.tabs[i]![0]} diverged from A — ${firstDiff(texts[0]!, texts[i]!)}`,
).toBe(true);
}
const renders = await Promise.all(trio.tabs.map(([, p]) => renderDoc(p)));
for (let i = 0; i < renders.length; i++) {
expect(renders[i]!.err, `${trio.tabs[i]![0]} room doc must materialize`).toBeUndefined();
}
for (let i = 1; i < renders.length; i++) {
expect(
renders[i]!.ok === renders[0]!.ok,
`room doc render ${trio.tabs[i]![0]} diverged from A`,
).toBe(true);
}
}