pcbjam/tests/kicad/drift-trio-scenarios.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

417 lines
17 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 { execSync } from "node:child_process";
import path from "node:path";
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import {
TRIO_PCB,
TRIO_SCH,
type TabSet,
type ToolCfg,
type Trio,
SYM1,
FP1,
VIA1,
WIRE1,
bootOpen,
callHook,
closeTrio,
getPos,
hasAbort,
modelText,
openTrio,
oracleSweep,
settleConverged,
startV2,
undoDepth,
} from "./utils/trio";
/**
* Drift trio harness — scenarios S2S8 (standalone-hardening 0008 §6, phase C).
*
* S1 (per-action baseline + full catalogs) lives in drift-trio.spec.ts; this
* file drives the CONCURRENT shapes: interleaved bursts with no settle between
* them, conflicting edits on the same item, undo storms, churn, late joiners,
* and save interplay. Assertions for conflicts are convergence + drift silence
* — the winner is whatever the CRDT resolves, never a specific outcome.
*
* Concurrency pattern: fire both actors' sequences via Promise.all (each
* actor's hooks run sequentially on its own tab), then wait for MARKER content
* to land on every tab before settling — settleConverged alone can pass on the
* pre-action state (finding #7), and in concurrent scenarios there is no
* single actor save to gate on.
*/
test.beforeAll(() => {
execSync("node collab/build.mjs", { cwd: path.resolve(__dirname, ".."), stdio: "inherit" });
});
function skipFirefox(): void {
// 2026-08-13: guard retired — the JSPI build (~half the asyncify size) fits
// three editor tabs inside Firefox 153's per-process wasm budget; the
// whole file passes on kicad-firefox. Kept as a hook in case the wall
// returns with future growth.
}
/** Poll until every tab's silent save contains `marker`. */
async function waitAllContain(set: TabSet, cfg: ToolCfg, marker: string, timeout = 90000): Promise<void> {
for (const [label, page] of set.tabs) {
await expect
.poll(async () => (await modelText(page, cfg)).includes(marker), {
timeout,
intervals: [400],
message: `${label} must receive "${marker}"`,
})
.toBe(true);
}
}
/** Per-tool adapter for the scenario scripts. */
interface ToolOps {
cfg: ToolCfg;
label: string;
/** The symbol / footprint — the property-carrying primary item. */
primary: string;
/** A second fixture item (wire / via) for disjoint + move-move conflicts. */
secondary: string;
mm: number; // IU per mm
move(page: Page, uuid: string, dxIU: number): Promise<boolean>;
setValue(page: Page, text: string): Promise<boolean>;
/** Add a text-ish marker item carrying `text`; returns its uuid. */
addMarker(page: Page, text: string, slot: number): Promise<string>;
}
const SCH_OPS: ToolOps = {
cfg: TRIO_SCH,
label: "eeschema",
primary: SYM1,
secondary: WIRE1,
mm: 10000,
move: (p, uuid, dx) => callHook<boolean>(p, "kicadCollabTestMoveSchItem", uuid, dx, 0),
setValue: (p, text) => callHook<boolean>(p, "kicadCollabTestSetFieldText", SYM1, text),
addMarker: (p, text, slot) =>
callHook<string>(p, "kicadCollabTestAddLabel", "label", text, 400000 + slot * 60000, 400000),
};
const PCB_OPS: ToolOps = {
cfg: TRIO_PCB,
label: "pcbnew",
primary: FP1,
secondary: VIA1,
mm: 1000000,
move: (p, uuid, dx) => callHook<boolean>(p, "kicadCollabTestMoveBoardItem", uuid, dx, 0),
setValue: (p, text) => callHook<boolean>(p, "kicadCollabTestSetFootprintField", FP1, "Value", text),
addMarker: (p, text, slot) =>
callHook<string>(p, "kicadCollabTestAddBoardText", text, 20000000 + slot * 15000000, 130000000, "F.SilkS"),
};
for (const ops of [SCH_OPS, PCB_OPS]) {
const { cfg, label } = ops;
test.describe(`drift trio scenarios — ${label}`, () => {
test.describe.configure({ timeout: 900000 });
// ── S2: disjoint ping-pong ───────────────────────────────────────────────
test(`${label} S2: A and B alternate disjoint edits, 3 rounds`, async ({
context,
testLogger,
}) => {
skipFirefox();
const trio = await openTrio(context, cfg, `s2-${label}-${test.info().workerIndex}`);
for (let round = 0; round < 3; round++) {
await test.step(`round ${round + 1}`, async () => {
// Landed-gate per actor (finding #7), then settle covers cross-tab.
const beforeA = await getPos(trio.A, ops.primary);
const beforeB = await getPos(trio.B, ops.secondary);
await Promise.all([
ops.move(trio.A, ops.primary, ops.mm),
ops.move(trio.B, ops.secondary, ops.mm),
]);
await expect
.poll(() => getPos(trio.A, ops.primary), { timeout: 15000, intervals: [300] })
.not.toBe(beforeA);
await expect
.poll(() => getPos(trio.B, ops.secondary), { timeout: 15000, intervals: [300] })
.not.toBe(beforeB);
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
});
}
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
// ── S3: same-item interleave (the 0008 headline scenario) ────────────────
test(`${label} S3: A creates an item; B moves+renames it while A keeps editing around it`, async ({
context,
testLogger,
}) => {
skipFirefox();
const trio = await openTrio(context, cfg, `s3-${label}-${test.info().workerIndex}`);
// A creates the contested item and everyone sees it.
let target: string;
if (label === "eeschema") {
target = await callHook<string>(trio.A, "kicadCollabTestAddSymbol", "Device:R", 800000, 800000, "R9");
} else {
target = await callHook<string>(trio.A, "kicadCollabTestDuplicateBoardItem", FP1, 15000000, 0);
}
expect(target).toMatch(/[0-9a-f-]{36}/);
await waitAllContain(trio, cfg, target);
// Interleaved bursts, NO settle in between: B moves the new item and
// renames the primary's value while A wires/tracks around the new item.
await Promise.all([
(async () => {
if (label === "eeschema") {
await callHook<string>(trio.A, "kicadCollabTestAddWire", 760000, 800000, 800000, 800000);
await callHook<string>(trio.A, "kicadCollabTestAddWire", 800000, 838100, 800000, 880000);
} else {
await callHook<string>(trio.A, "kicadCollabTestAddTrack", 110000000, 100000000, 115000000, 100000000, 300000, "F.Cu");
await callHook<string>(trio.A, "kicadCollabTestAddVia", 113000000, 100000000, 800000, 400000);
}
})(),
(async () => {
await ops.move(trio.B, target, 2 * ops.mm);
await ops.setValue(trio.B, "s3-renamed");
})(),
]);
// Marker-wait on the rename (last B op) + settle covers A's adds.
await waitAllContain(trio, cfg, "s3-renamed");
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
// ── S4: conflict pairs on the SAME item ──────────────────────────────────
// QUARANTINED 2026-08-01 (whole conflict class, not just value-vs-value):
// racing edits on the SAME item genuinely fail to converge in a small
// percentage of runs — across this week's CI + local stress, THREE
// different steps have each diverged (move-vs-delete, value-vs-value,
// move-vs-move; settleConverged 90s timeout, byte equality never reached).
// Not poll timing, not the asyncify guards (zero beacons in failing
// runs). A real same-item conflict-resolution race that needs its own
// hunt — tracking: memory s4-value-race-divergence. The sequenced-edit
// scenarios (S1S3, S5S8) have never diverged and stay active.
// re-enabled 2026-08-13: converges on the JSPI build
test(`${label} S4: move-vs-delete, value-vs-value, move-vs-move`, async ({
context,
testLogger,
}) => {
skipFirefox();
const trio = await openTrio(context, cfg, `s4-${label}-${test.info().workerIndex}`);
// Victim for move-vs-delete: a marker item A adds and everyone holds.
const victim = await ops.addMarker(trio.A, "s4-victim", 0);
expect(victim).toMatch(/[0-9a-f-]{36}/);
await waitAllContain(trio, cfg, victim);
await test.step("move-vs-delete", async () => {
// Winner is CRDT policy, not asserted; both hooks may race the item
// away from under each other, so their return values are not asserted
// either — convergence + silence is the contract.
await Promise.all([
ops.move(trio.A, victim, ops.mm),
callHook<boolean>(trio.B, "kicadCollabTestRemoveItem", victim),
]);
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
});
await test.step("move-vs-move", async () => {
await Promise.all([
ops.move(trio.A, ops.secondary, 2 * ops.mm),
ops.move(trio.B, ops.secondary, -2 * ops.mm),
]);
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
});
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
// QUARANTINED 2026-08-01: two clients racing setValue GENUINELY diverge in
// ~5-8% of runs — settleConverged times out at 90s with the trio never
// reaching byte equality. NOT a timing flake (windows already widened) and
// NOT the asyncify guards (zero beacons in failing runs; reproduces
// locally at single-worker). A real CRDT/apply race that needs its own
// hunt: capture both tabs' modelText diff at timeout, then bisect the
// value-apply path. Tracking: memory s4-value-race-divergence.
// re-enabled 2026-08-13: converges on the JSPI build
test(`${label} S4b: value-vs-value converges (KNOWN ~5-8% divergence)`, async ({
context,
testLogger,
}) => {
skipFirefox();
const trio = await openTrio(context, cfg, `s4b-${label}-${test.info().workerIndex}`);
await Promise.all([ops.setValue(trio.A, "s4-from-A"), ops.setValue(trio.B, "s4-from-B")]);
// One of the two values won everywhere; poll until every tab carries
// SOME s4 value, then settle on byte equality.
for (const [tabLabel, p] of trio.tabs) {
await expect
.poll(async () => /s4-from-[AB]/.test(await modelText(p, cfg)), {
timeout: 60000,
intervals: [400],
message: `${tabLabel} must receive one of the racing values`,
})
.toBe(true);
}
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
// ── S5: undo storm ───────────────────────────────────────────────────────
test(`${label} S5: A undoes its own ops while B keeps editing`, async ({
context,
testLogger,
}) => {
skipFirefox();
const trio = await openTrio(context, cfg, `s5-${label}-${test.info().workerIndex}`);
// A's undoable ops, settled so peers hold them.
expect(await ops.move(trio.A, ops.primary, ops.mm)).toBe(true);
expect(await ops.setValue(trio.A, "s5-tmp")).toBe(true);
await waitAllContain(trio, cfg, "s5-tmp");
await settleConverged(trio, cfg);
// A undoes both while B lands fresh edits.
await Promise.all([
(async () => {
expect(await callHook<boolean>(trio.A, "kicadCollabTestUndo")).toBe(true);
expect(await callHook<boolean>(trio.A, "kicadCollabTestUndo")).toBe(true);
})(),
(async () => {
await ops.move(trio.B, ops.secondary, ops.mm);
await ops.addMarker(trio.B, "s5-b-survives", 1);
})(),
]);
// B's edits survive A's undos; the undone value is gone everywhere.
await waitAllContain(trio, cfg, "s5-b-survives");
for (const [tabLabel, p] of trio.tabs) {
await expect
.poll(async () => (await modelText(p, cfg)).includes("s5-tmp"), {
timeout: 60000,
intervals: [400],
message: `${tabLabel} must lose the undone value`,
})
.toBe(false);
}
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
expect(await undoDepth(trio.C), "observer undo stack").toBe(0);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
// ── S6: burst churn ──────────────────────────────────────────────────────
test(`${label} S6: 12-edit bursts from both sides, one settle at the end`, async ({
context,
testLogger,
}) => {
skipFirefox();
const trio = await openTrio(context, cfg, `s6-${label}-${test.info().workerIndex}`);
await Promise.all([
(async () => {
for (let i = 0; i < 12; i++) await ops.move(trio.A, ops.primary, Math.round(ops.mm / 5));
await ops.addMarker(trio.A, "s6-done-a", 2);
})(),
(async () => {
for (let i = 0; i < 12; i++) await ops.move(trio.B, ops.secondary, Math.round(ops.mm / 5));
await ops.addMarker(trio.B, "s6-done-b", 3);
})(),
]);
await waitAllContain(trio, cfg, "s6-done-a", 45000);
await waitAllContain(trio, cfg, "s6-done-b", 45000);
await settleConverged(trio, cfg, 45000);
await oracleSweep(trio, cfg);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
// ── S7: late joiner adopts a room that evolved without it ────────────────
test(`${label} S7: C joins after A+B edited; adopt converges drift-silent`, async ({
context,
testLogger,
}) => {
skipFirefox();
const room = `s7-${label}-${test.info().workerIndex}`;
const A = await context.newPage();
const B = await context.newPage();
await bootOpen(A, cfg);
await bootOpen(B, cfg);
await startV2(A, { room, seedText: cfg.fixture });
await startV2(B, { room, editorMatchesDoc: true });
const duo: TabSet = { tabs: [["A", A], ["B", B]] };
await settleConverged(duo, cfg);
// The room evolves before C exists.
await ops.move(A, ops.primary, ops.mm);
await ops.addMarker(B, "s7-early", 4);
await waitAllContain(duo, cfg, "s7-early");
await settleConverged(duo, cfg);
// C opened the ORIGINAL fixture file, so its editor does NOT match the
// doc — it must take the ADOPT branch (no editorMatchesDoc, no seed).
const C = await context.newPage();
await bootOpen(C, cfg);
await startV2(C, { room });
const trio: Trio = { A, B, C, tabs: [["A", A], ["B", B], ["C", C]] };
await waitAllContain(trio, cfg, "s7-early");
await settleConverged(trio, cfg);
await oracleSweep(trio, cfg);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
});
}
// ── S8: user-save during a peer's burst (pcbnew) ─────────────────────────────
// Ctrl+S drives the FULL save flow (the real writer + the C++→JS onSave
// notification chokepoint) while remote applies land — suspension contention
// between the save coroutine and the apply coroutines is exactly the surface.
test.describe("drift trio scenarios — pcbnew S8 save interplay", () => {
test.describe.configure({ timeout: 900000 });
test("pcbnew S8: A saves (Ctrl+S) mid B-burst", async ({ context, testLogger }) => {
skipFirefox();
const trio = await openTrio(context, TRIO_PCB, `s8-pcb-${test.info().workerIndex}`);
await Promise.all([
(async () => {
for (let i = 0; i < 6; i++)
await callHook<boolean>(trio.B, "kicadCollabTestMoveBoardItem", VIA1, 300000, 0);
await callHook<string>(trio.B, "kicadCollabTestAddBoardText", "s8-done", 20000000, 140000000, "F.SilkS");
})(),
(async () => {
// Focus the canvas on an empty margin, then user-save twice mid-burst.
await trio.A.locator("#canvas").click({ position: { x: 30, y: 300 } });
await trio.A.keyboard.press("Control+s");
await trio.A.keyboard.press("Control+s");
})(),
]);
await waitAllContain(trio, TRIO_PCB, "s8-done");
await settleConverged(trio, TRIO_PCB);
await oracleSweep(trio, TRIO_PCB);
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
await closeTrio(trio);
});
});