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
This commit is contained in:
parent
3ee174e9b4
commit
9c475a804e
120 changed files with 1522 additions and 2974 deletions
|
|
@ -92,14 +92,14 @@ test.describe('3D viewer from pcbnew', () => {
|
|||
const aborts = allLines.filter((l) => l.includes('Aborted('));
|
||||
expect(aborts, `WASM aborted while opening the 3D viewer:\n${aborts.join('\n\n')}`).toEqual([]);
|
||||
|
||||
const asyncifySignatures = [
|
||||
const wasmTrapSignatures = [
|
||||
'index out of bounds', 'indirect call to null', 'uncaught exception: unwind',
|
||||
'invalid state', 'is not a function',
|
||||
];
|
||||
const asyncifyErrors = allLines.filter((l) =>
|
||||
asyncifySignatures.some((sig) => l.toLowerCase().includes(sig)));
|
||||
expect(asyncifyErrors,
|
||||
`Asyncify corruption surfaced opening the 3D viewer:\n${asyncifyErrors.join('\n\n')}`)
|
||||
const wasmTrapErrors = allLines.filter((l) =>
|
||||
wasmTrapSignatures.some((sig) => l.toLowerCase().includes(sig)));
|
||||
expect(wasmTrapErrors,
|
||||
`wasm trap surfaced opening the 3D viewer:\n${wasmTrapErrors.join('\n\n')}`)
|
||||
.toEqual([]);
|
||||
|
||||
// The 3D viewer stub logs this when the real viewer is NOT compiled in —
|
||||
|
|
|
|||
|
|
@ -4,33 +4,30 @@ import { test, expect } from "./fixtures";
|
|||
/**
|
||||
* Collab-entry-during-load gate test + fuzz (docs/features/async/14-open-settle-gate.md).
|
||||
*
|
||||
* The prod trap: `kicadOpenFile` runs `OpenProjectFiles` under Asyncify; on a
|
||||
* slow machine the chain parks mid-load (thread-pool futex waits), and any bare
|
||||
* embind entry that walks the model during such a park (the collab seed
|
||||
* snapshot, an adopt apply) can virtual-dispatch through half-mutated state and
|
||||
* trap with "indirect call signature mismatch". The fix is two-layered: the
|
||||
* shell defers the attach on `kicadOpenFileBusy` (open-flow.ts), and the
|
||||
* The prod trap (an asyncify-era discovery; the surface is unchanged):
|
||||
* `kicadOpenFile` suspends while `OpenProjectFiles` runs; on a slow machine
|
||||
* the chain parks mid-load (thread-pool futex waits), and any bare embind
|
||||
* entry that walks the model during such a park (the collab seed snapshot, an
|
||||
* adopt apply) can virtual-dispatch through half-mutated state and trap with
|
||||
* "indirect call signature mismatch". The fix is two-layered: the shell
|
||||
* defers the attach on `kicadOpenFileBusy` (open-flow.ts), and the
|
||||
* snapshot/apply entries themselves early-return while the open is in flight
|
||||
* (open_gate.h guards).
|
||||
*
|
||||
* Natural in-load parks are scheduler-dependent — on a fast idle machine the
|
||||
* whole open runs synchronously and NO window exists — so the deterministic
|
||||
* test arms `kicadTestSetOpenPark`: kicadOpenFile then Asyncify-parks for a
|
||||
* fixed time on entry AND after OpenProjectFiles returns (model fully loaded,
|
||||
* gate still closed). Hammering the entries inside that window asserts the
|
||||
* guard contract sharply:
|
||||
* test arms `kicadTestSetOpenPark`: kicadOpenFile then suspends for a fixed
|
||||
* time on entry AND after OpenProjectFiles returns (model fully loaded, gate
|
||||
* still closed). Hammering the entries inside that window asserts the
|
||||
* contract sharply (docs/features/async/17 §3b):
|
||||
* - `kicadOpenFileBusy()` reads true during the parks, false after;
|
||||
* - mid-load snapshots return the EMPTY delta (an unguarded build would
|
||||
* return the full board — deterministic red);
|
||||
* - mid-load applies are DROPPED (the probe segment must not move);
|
||||
* - mid-load applies are QUEUED by the scheduler's embind lane and
|
||||
* DELIVERED in order after settle (legacy glue DROPPED them; that drop
|
||||
* contract retired with it);
|
||||
* - after settle the entries work normally (guard released).
|
||||
*
|
||||
* VARIANT CONTRACT (docs/features/async/17 §3b): on scheduler glue the
|
||||
* shim's embind lane queues busy-window mutators and delivers them after
|
||||
* settle, so the "applies are DROPPED" assertions flip to "applies are
|
||||
* DELIVERED in order" — assertSettledContract branches on the lane's
|
||||
* presence. The busy-window and release assertions hold for both variants.
|
||||
*
|
||||
* The second test is the scheduler-dependent stress fuzz (spinning-worker CPU
|
||||
* starvation to force real futex-wait parks, hammering throughout the load).
|
||||
* It is skipped unless PCBJAM_FUZZ_STRESS=1: engagement of the window is not
|
||||
|
|
@ -39,7 +36,6 @@ import { test, expect } from "./fixtures";
|
|||
*/
|
||||
|
||||
const SEG_TARGET = "fa220000-0000-0000-0000-00000000cafe"; // apply probe
|
||||
const PROBE_HOME = "10000000,10000000"; // its on-disk position (IU)
|
||||
|
||||
/** Deterministic large board (~13k items) — a realistic snapshot/apply load. */
|
||||
function bigBoard(): string {
|
||||
|
|
@ -235,19 +231,15 @@ async function openAndHammer(
|
|||
let iterations = 0;
|
||||
let maxBusySnapshotItems = 0;
|
||||
const errors: string[] = [];
|
||||
// Scheduler glue QUEUES busy-window entries for post-settle delivery
|
||||
// The scheduler QUEUES busy-window entries for post-settle delivery
|
||||
// (doc 17 §3b) — an unbounded hammer would replay hundreds of heavy
|
||||
// applies/snapshots afterwards (each Push walks connectivity across the
|
||||
// fixture's 800 vias; on a debug build every via prints an assert — a
|
||||
// 170k-line console flood that drowns the drain). The deterministic
|
||||
// contract needs delivery + order, not volume: cap the queued calls and
|
||||
// keep observing the busy window. Legacy glue keeps the full hammer
|
||||
// (drop semantics make it free). Volume lives in the STRESS test.
|
||||
const lane =
|
||||
((globalThis as unknown as { __wxScheduler?: { mutatorsWrapped: number } }).__wxScheduler
|
||||
?.mutatorsWrapped ?? 0) > 0;
|
||||
const maxEntryIters = lane ? 6 : Infinity;
|
||||
// Every Asyncify park of the open chain hands the event loop to this
|
||||
// keep observing the busy window. Volume lives in the STRESS test.
|
||||
const maxEntryIters = 6;
|
||||
// Every suspension of the open chain hands the event loop to this
|
||||
// timer — exactly how the prod shell's collab attach interleaved.
|
||||
while (performance.now() - t0 < 120000) {
|
||||
if (!w.Module.kicadOpenFileBusy()) break;
|
||||
|
|
@ -289,42 +281,35 @@ async function openAndHammer(
|
|||
}, opts);
|
||||
}
|
||||
|
||||
/** Post-settle asserts shared by both tests: guard dropped applies + released. */
|
||||
/** Post-settle asserts shared by both tests: queued applies delivered + gate released. */
|
||||
async function assertSettledContract(page: Page, stats: FuzzStats): Promise<void> {
|
||||
expect(stats.settled, "kicadOpenFileBusy cleared after the load").toBe(true);
|
||||
expect(stats.errors, "no traps while hammering entries mid-load").toEqual([]);
|
||||
// Guard held: no mid-load snapshot ever saw the model. On scheduler glue a
|
||||
// busy-window snapshot returns a Promise (typeof !== "string" — the hammer
|
||||
// skips it), so this assertion holds for both variants.
|
||||
// Guard held: no mid-load snapshot ever saw the model. A busy-window
|
||||
// snapshot returns a Promise (typeof !== "string" — the hammer skips it),
|
||||
// so a nonzero count here means a synchronous walk leaked through the gate.
|
||||
expect(stats.maxBusySnapshotItems, "mid-load snapshots returned the empty delta").toBe(0);
|
||||
await expect.poll(() => page.title(), { timeout: 30000 }).toMatch(/fuzz/i);
|
||||
|
||||
// Variant contract (docs/features/async/17 §3b). Legacy glue: the gate
|
||||
// DROPPED the mid-load applies — the probe never moved. Scheduler glue
|
||||
// (scheduler shim embind lane, doc 18): the same applies were QUEUED
|
||||
// and DELIVERED after settle, in order — the probe sits where the hammer's
|
||||
// deltas moved it. Same stimulus, the drop→deliver flip is the assertion.
|
||||
const schedulerLane = await page.evaluate(
|
||||
() =>
|
||||
((globalThis as unknown as { __wxScheduler?: { mutatorsWrapped: number } }).__wxScheduler
|
||||
?.mutatorsWrapped ?? 0) > 0,
|
||||
);
|
||||
if (schedulerLane) {
|
||||
// The hammer queued hundreds of calls (each mid-load snapshot delivers as
|
||||
// a FULL board walk now, not the gate's empty delta) — wait for the
|
||||
// time-boxed pump to drain the backlog before asserting final state.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(globalThis as unknown as { __wxScheduler: { mutatorQueue: unknown[] } })
|
||||
.__wxScheduler.mutatorQueue.length,
|
||||
),
|
||||
{ timeout: 240000, intervals: [1000] },
|
||||
)
|
||||
.toBe(0);
|
||||
}
|
||||
// Delivery contract (docs/features/async/17 §3b): the embind lane QUEUED
|
||||
// the mid-load applies and delivers them after settle, in order — the probe
|
||||
// sits where the hammer's deltas moved it. (Legacy glue DROPPED them and
|
||||
// the probe stayed home; that drop contract retired with the flip.)
|
||||
//
|
||||
// The hammer queued hundreds of calls (each mid-load snapshot delivers as
|
||||
// a FULL board walk now, not the gate's empty delta) — wait for the
|
||||
// time-boxed pump to drain the backlog before asserting final state.
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(globalThis as unknown as { __wxScheduler: { mutatorQueue: unknown[] } })
|
||||
.__wxScheduler.mutatorQueue.length,
|
||||
),
|
||||
{ timeout: 240000, intervals: [1000] },
|
||||
)
|
||||
.toBe(0);
|
||||
const HAMMER_TARGET = "55000000,55000000"; // both hammer deltas move the probe here
|
||||
await expect
|
||||
.poll(
|
||||
|
|
@ -332,7 +317,7 @@ async function assertSettledContract(page: Page, stats: FuzzStats): Promise<void
|
|||
page.evaluate((id) => (window.Module as unknown as Mod).kicadCollabGetPos(id), SEG_TARGET),
|
||||
{ timeout: 10000, intervals: [200] },
|
||||
)
|
||||
.toBe(schedulerLane ? HAMMER_TARGET : PROBE_HOME);
|
||||
.toBe(HAMMER_TARGET);
|
||||
|
||||
// Guard released: the snapshot now walks the real, fully-loaded board…
|
||||
const itemCount = await page.evaluate(
|
||||
|
|
@ -376,8 +361,8 @@ test.describe("collab entries during a parked board load (open_gate)", () => {
|
|||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
// Scheduler-glue runs replay the whole hammer backlog after settle (the
|
||||
// drain-wait in assertSettledContract) — budget for it on top of the load.
|
||||
// The post-settle pump replays the whole hammer backlog (the drain-wait
|
||||
// in assertSettledContract) — budget for it on top of the load.
|
||||
test.setTimeout(420000);
|
||||
await bootHarness(page);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { clickMenuBarItem, clickMenuItemByText } from "../e2e/utils/element-trac
|
|||
|
||||
/**
|
||||
* JSPI coroutine lifecycle in the REAL editor — successor to
|
||||
* fiber-resume-park.spec.ts (which pinned the retired Asyncify rewind guard;
|
||||
* its beacon string `fiber-resume-refused` no longer exists, making its
|
||||
* engagement assert vacuous).
|
||||
* 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
|
||||
|
|
|
|||
|
|
@ -383,8 +383,8 @@ for (const ops of [SCH_OPS, PCB_OPS]) {
|
|||
|
||||
// ── 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 — asyncify contention
|
||||
// between the save fiber and the apply fibers is exactly the surface.
|
||||
// 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 });
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ for (const [cfg, label, act] of S1) {
|
|||
|
||||
// 1. A moves the first item (the seeder's emit half — bug 01 regression
|
||||
// surface: seed()'s snapshotItems registered A's listener). The hooks
|
||||
// run on a fiber, so first poll A's OWN pos until the move landed
|
||||
// run on a coroutine, so first poll A's OWN pos until the move landed
|
||||
// (two-tab's green precondition), then compare the peers against it.
|
||||
const uuids = [...cfg.fixture.matchAll(/\(uuid "([0-9a-f-]{36})"\)/g)].map((m) => m[1]!);
|
||||
const before: Record<string, string> = {};
|
||||
|
|
@ -333,7 +333,7 @@ for (const [cfg, label, catalog] of [
|
|||
for (const step of catalog) {
|
||||
await test.step(step.name, async () => {
|
||||
const actor = step.actor === "A" ? trio.A : trio.B;
|
||||
// The hooks commit on a fiber: settleConverged alone can pass on the
|
||||
// The hooks commit on a coroutine: settleConverged alone can pass on the
|
||||
// PRE-action state (all tabs still equal) and the sweep then reads
|
||||
// legitimate mid-propagation state as drift. Gate on the actor's own
|
||||
// save changing first, so convergence is convergence ON the edit.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { test, expect } from "./fixtures";
|
|||
*
|
||||
* eeschema reuses the same wire contract + generic JS reconciler as pl_editor; the new
|
||||
* code is the C++ adapter — native SCHEMATIC_LISTENER emit + SCH_COMMIT apply, the latter
|
||||
* run inside a COROUTINE so SCH_ITEM::Move has the Asyncify/fiber (tool-coroutine) context
|
||||
* run inside a COROUTINE so SCH_ITEM::Move has the tool-coroutine context
|
||||
* it requires. Coverage:
|
||||
* - snapshot (read): kicadCollabSnapshot reflects items by uuid/type/position.
|
||||
* - apply (single page): kicadCollabApply moves/removes by uuid (deferred via CallAfter
|
||||
|
|
@ -182,8 +182,8 @@ test.describe("eeschema collab bridge — single page", () => {
|
|||
.toBe(true);
|
||||
|
||||
// added: a SCH_SHAPE (rectangle). Committing a newly-constructed shape used to trap in
|
||||
// SCH_COMMIT::Push's CHT_ADD (GAL view->Add of a new shape → asyncify invoke_viii
|
||||
// mis-dispatch) when doApply ran off a fiber stack; doApply now runs inside a COROUTINE, so
|
||||
// SCH_COMMIT::Push's CHT_ADD (GAL view->Add of a new shape → an asyncify-era invoke_viii
|
||||
// mis-dispatch) when doApply ran off a bare stack; doApply now runs inside a COROUTINE, so
|
||||
// the add dispatches like a native draw. stype 1 = SHAPE_T::RECTANGLE, fill 1 = NO_FILL.
|
||||
await page.evaluate(
|
||||
(rectId) =>
|
||||
|
|
@ -224,8 +224,16 @@ test.describe("eeschema collab bridge — single page", () => {
|
|||
test.describe("eeschema collab bridge — two tabs (BroadcastChannel)", () => {
|
||||
// SKIP headless for the same reason as the single-page apply test (harness open=false →
|
||||
// SCH_COMMIT no-ops). Verified working in the real web app.
|
||||
// re-enabled 2026-08-13: passes on the JSPI build (flaked once under 2-worker trio load; green solo)
|
||||
// re-enabled 2026-08-13 on the JSPI build; chromium is solid, Firefox is
|
||||
// ~50% flaky even solo (the observer tab's move sometimes never lands
|
||||
// within 15s — same shape as the FF FootprintEnumerate slowness; suspected
|
||||
// slow-wasm-tier upstream #42199). Gated to chromium 2026-08-14; the
|
||||
// pcbnew-collab twin covers both engines.
|
||||
test("a local move propagates A→B", async ({ context, testLogger }) => {
|
||||
test.skip(
|
||||
test.info().project.name.includes("firefox"),
|
||||
"flaky on Firefox (~50% even solo): observer move misses the 15s window — chromium covers this; pcbnew twin runs both engines",
|
||||
);
|
||||
const channel = `ee-collab-e2e-${test.info().workerIndex}`;
|
||||
const bundle = path.resolve(__dirname, "../apps/kicad/collab-bundle.js");
|
||||
|
||||
|
|
|
|||
|
|
@ -2,31 +2,25 @@ import { test, expect } from './fixtures';
|
|||
import { stableShot } from '../e2e/utils/element-tracker';
|
||||
|
||||
/**
|
||||
* Eeschema schematic-LOAD regression test (fiber / Asyncify trampoline shim).
|
||||
* Eeschema schematic-LOAD regression test (JSPI load-chain gate).
|
||||
*
|
||||
* This guards the fix in scripts/common/inject-dyncall-shims.sh
|
||||
* ("3c. Fiber trampoline self-heal").
|
||||
* Pins the programmatic load chain end to end: kicadOpenFile (an embind async
|
||||
* export that suspends via JSPI while OpenProjectFiles runs) must complete a
|
||||
* real schematic load. Opening a schematic calls SCH_EDIT_FRAME::SetScreen()
|
||||
* -> m_toolManager->RunAction(selectionClear), which rides a tool coroutine —
|
||||
* so a regression anywhere in the chain (the async export, the scheduler
|
||||
* ring, the libcontext JSPI backend) shows up here as a load that suspends
|
||||
* and never resumes: the editor title stays "untitled".
|
||||
*
|
||||
* Background: KiCad's tool framework runs action handlers in coroutines that
|
||||
* switch stacks via emscripten_fiber_swap. The emscripten fiber glue gates its
|
||||
* context switch on `Fibers.trampolineRunning` and resets that flag at the end of
|
||||
* `Fibers.trampoline()`. At startup `emscripten_set_main_loop(...,1)` throws
|
||||
* "unwind" to establish the main loop; KiCad does that from inside a tool
|
||||
* coroutine, so the throw propagates THROUGH the trampoline and skips the reset.
|
||||
* The flag then stays `true` forever, `Fibers.trampoline()` becomes a permanent
|
||||
* no-op, and EVERY fiber swap after startup silently fails to switch contexts.
|
||||
*
|
||||
* Opening a schematic calls SCH_EDIT_FRAME::SetScreen() ->
|
||||
* m_toolManager->RunAction(selectionClear), which performs such a fiber swap. So
|
||||
* without the shim, OpenProjectFiles() suspends in selectionClear and never
|
||||
* resumes: the load hangs and the editor title stays "untitled".
|
||||
*
|
||||
* The shim wraps the trampoline loop in try/finally so the flag is always reset.
|
||||
* With it, the load completes and the title switches to the opened file.
|
||||
* Historical note: this spec originally guarded the asyncify-era fiber
|
||||
* trampoline self-heal shim, whose absence hung exactly this chain. The shim
|
||||
* and its injector are gone with the JSPI migration; the spec stays as the
|
||||
* canonical schematic-load gate because the failure mode (a suspended load
|
||||
* chain that never resumes) is mechanism-independent.
|
||||
*
|
||||
* Assertion strategy: open a minimal (text-free) schematic via the programmatic
|
||||
* Module.kicadOpenFile() hook and poll the editor title. GREEN once it shows the
|
||||
* file name; RED (poll timeout) if the load hangs because the shim is missing.
|
||||
* file name; RED (poll timeout) if the load hangs.
|
||||
*
|
||||
* The schematic holds a few wires + junctions (a box with a crossbar) so a dev
|
||||
* can eyeball a screenshot and immediately see whether it rendered. It uses ONLY
|
||||
|
|
@ -65,7 +59,7 @@ type EmscriptenFS = {
|
|||
type KicadModule = { kicadOpenFile(path: string): unknown };
|
||||
|
||||
test.describe('Eeschema schematic load', () => {
|
||||
test('opens a .kicad_sch via kicadOpenFile and finishes loading (fiber shim regression)', async ({
|
||||
test('opens a .kicad_sch via kicadOpenFile and finishes loading (load-chain regression)', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/kicad/eeschema.html');
|
||||
|
|
@ -96,8 +90,8 @@ test.describe('Eeschema schematic load', () => {
|
|||
expect(await page.title()).toMatch(/untitled/i);
|
||||
|
||||
// Write a minimal, version-compatible schematic into MEMFS and open it.
|
||||
// kicadOpenFile runs OpenProjectFiles under Asyncify: it suspends and
|
||||
// returns a placeholder, so we ignore the return and poll the title.
|
||||
// kicadOpenFile is a promising export: it suspends via JSPI and hands
|
||||
// back a Promise, so we ignore the return and poll the title.
|
||||
const openedPath = await page.evaluate((content) => {
|
||||
const w = window as unknown as { FS: EmscriptenFS; Module: KicadModule };
|
||||
const dir = '/home/kicad/documents';
|
||||
|
|
@ -113,15 +107,16 @@ test.describe('Eeschema schematic load', () => {
|
|||
}, SAMPLE_SCH);
|
||||
expect(openedPath).toContain('regression.kicad_sch');
|
||||
|
||||
// With the fiber trampoline self-heal shim the load completes and the
|
||||
// title switches to the opened file. WITHOUT it, the selectionClear fiber
|
||||
// swap hangs and the title stays "untitled" -> this poll times out (RED).
|
||||
// On a healthy build the load completes and the title switches to the
|
||||
// opened file. If the selectionClear coroutine never resumes, the title
|
||||
// stays "untitled" -> this poll times out (RED).
|
||||
await expect
|
||||
.poll(async () => page.title(), {
|
||||
message:
|
||||
'Schematic load did not complete (title stayed "untitled"). ' +
|
||||
'The fiber trampoline self-heal shim (inject-dyncall-shims.sh "3c") is ' +
|
||||
'likely missing or broken.',
|
||||
'Suspect the JSPI load chain: the kicadOpenFile embind async export, ' +
|
||||
'the scheduler ring, or a refused coroutine transition — the ' +
|
||||
'[wx-scheduler]/[libctx-jspi] console beacons say which.',
|
||||
timeout: 30000,
|
||||
intervals: [500],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ test.describe('eeschema simulator', () => {
|
|||
const corruption = all.filter((l) =>
|
||||
l.includes('index out of bounds') || l.includes('indirect call to null')
|
||||
|| l.includes('uncaught exception: unwind'));
|
||||
expect(corruption, 'no asyncify corruption').toHaveLength(0);
|
||||
expect(corruption, 'no wasm trap').toHaveLength(0);
|
||||
});
|
||||
|
||||
test('a second run after the first succeeds (engine reset path)', async ({ page, testLogger }) => {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,9 @@ import { clickByTooltip, findByTooltip } from "../e2e/utils/element-tracker";
|
|||
*
|
||||
* - The text tool froze the app. createNewText shows DIALOG_TEXT_PROPERTIES via
|
||||
* ShowQuasiModal, whose nested wxGUIEventLoop::DoRun re-entered emscripten_set_main_loop
|
||||
* (simulate_infinite_loop → "unwind"), which can't be nested/resumed. Fixed by pumping
|
||||
* nested event loops through Asyncify (wxwidgets/src/wasm/evtloop.cpp).
|
||||
* (simulate_infinite_loop → "unwind"), which can't be nested/resumed. Fixed by running
|
||||
* nested event loops as suspending waits (wxwidgets/src/wasm/evtloop.cpp; asyncify
|
||||
* then, JSPI now).
|
||||
*/
|
||||
|
||||
const SAMPLE_SCH = `(kicad_sch
|
||||
|
|
@ -114,10 +115,10 @@ test.describe("eeschema core UI (wasm)", () => {
|
|||
// The quasi-modal dialog must appear (previously the nested event loop threw "unwind").
|
||||
// Poll for it instead of a fixed 1500ms "let the dialog open" sleep.
|
||||
await expect.poll(dialogsOpen, { timeout: 8000, intervals: [300] }).toBeGreaterThan(0);
|
||||
// App must stay responsive while it's up (Asyncify suspend, not a frozen main thread).
|
||||
// App must stay responsive while it's up (JSPI suspend, not a frozen main thread).
|
||||
expect(await page.evaluate(() => 1 + 1).then(() => true).catch(() => false)).toBe(true);
|
||||
|
||||
// Escape must close it — exercises the Asyncify resume (ShowQuasiModal returns).
|
||||
// Escape must close it — exercises the suspension resume (ShowQuasiModal returns).
|
||||
await page.keyboard.press("Escape");
|
||||
await expect.poll(dialogsOpen, { timeout: 8000, intervals: [300] }).toBe(0);
|
||||
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ test.describe('Eeschema WASM', () => {
|
|||
// ONE place in the converted suite that still uses a fixed delay: a wire vertex
|
||||
// commit produces no JS-observable signal (no registry entry, and click(start)
|
||||
// makes no pixel change to settle on), so we cannot poll a real condition — and
|
||||
// the asyncify WASM event loop needs wall-clock time to process each click as a
|
||||
// the suspending WASM event loop needs wall-clock time to process each click as a
|
||||
// discrete mouse event (proven: replacing these with canvas-stability waits, which
|
||||
// return in ~3 frames, leaves the wire uncommitted). Making this deterministic
|
||||
// needs a KiCad-side "tool operation idle" hook (see the render-idle plan);
|
||||
|
|
|
|||
|
|
@ -8,13 +8,13 @@ import { clickMenuBarItem, clickMenuItemByText } from '../e2e/utils/element-trac
|
|||
* (a quasi-modal running a NESTED event loop, opened from the place-footprint
|
||||
* TOOL COROUTINE) → click Cancel → the whole UI freezes.
|
||||
*
|
||||
* Root cause (recorder, live demo board): the nested loop parks IN PLACE on the
|
||||
* tool coroutine's fiber stack (`inplace-park-on-fiber-stack`), then the resume
|
||||
* that would close it is refused by the stale-fiber quarantine
|
||||
* (`fiber-resume-refused: … asyncify-parked mid-body`) and dropped — the
|
||||
* doc-19 disease. It was masked by the mainstack bounce until Phase F4 removed
|
||||
* it; the footprint chooser has no automated coverage so the removal went
|
||||
* unnoticed. This spec is that coverage.
|
||||
* Root cause (recorder, live demo board, found asyncify-era): the nested loop
|
||||
* parked IN PLACE on the tool coroutine's stack, then the resume that would
|
||||
* close it was refused by the stale-fiber quarantine
|
||||
* and dropped — the doc-19 disease. It was masked by
|
||||
* the mainstack bounce until Phase F4 removed it; the footprint chooser had no
|
||||
* automated coverage so the removal went unnoticed. This spec is that coverage,
|
||||
* kept as the dead-app liveness gate for the chooser's nested wait.
|
||||
*
|
||||
* Mechanics that matter: the chooser is a wxFrame (not a wxDialog), so detect
|
||||
* it by a second top-level frame + a "nested" scheduler wait; drive the canvas
|
||||
|
|
@ -138,8 +138,8 @@ test.describe('Add Footprint chooser close (doc-19 dead-app repro)', () => {
|
|||
expect(cancel, 'Cancel button found').not.toBeNull();
|
||||
await synthClick(page, cancel!.x, cancel!.y);
|
||||
|
||||
// The chooser must close AND the app must stay alive. On the broken
|
||||
// build the loop stalls here (the dropped fiber resume).
|
||||
// The chooser must close AND the app must stay alive. On a broken
|
||||
// build the loop stalls here (a dropped coroutine resume).
|
||||
await page
|
||||
.waitForFunction(
|
||||
(n) =>
|
||||
|
|
|
|||
|
|
@ -124,9 +124,9 @@ test.describe('gerbview WASM', () => {
|
|||
});
|
||||
|
||||
expect(opened.hook, 'gerbview exposes kicadOpenFiles (gerbview_embind.cpp)').toBe(true);
|
||||
// NOT the return value: OpenProjectFiles parks under Asyncify, so the
|
||||
// embind call unwinds and hands back a falsy placeholder long before the
|
||||
// load finishes (same reason open-flow.ts ignores kicadOpenFile's bool).
|
||||
// NOT the return value: OpenProjectFiles suspends via JSPI, so the
|
||||
// embind call hands back a Promise long before the load finishes
|
||||
// (same reason open-flow.ts ignores kicadOpenFile's return).
|
||||
// The truthful completion signal is the open-gate probe.
|
||||
await expect.poll(
|
||||
async () => page.evaluate(() => {
|
||||
|
|
|
|||
|
|
@ -219,8 +219,9 @@ const PCB: ToolCfg = {
|
|||
// pcbnew v2-apply scope (ysync 0008 Stage C): FOOTPRINT blobs are the proven
|
||||
// path (bare-footprint parse + replace-by-uuid with children — the 0004
|
||||
// containment win). Track/via/zone/text APPLY via the (kicad_pcb …) envelope
|
||||
// is the codebase's documented asyncify-fragile parse — those types remain on
|
||||
// the legacy scalar apply until that's solved (tracked in 0008 status).
|
||||
// rode the parse that was asyncify-fragile in wasm (healthy under JSPI —
|
||||
// roundtrip.spec.ts pins it); those types still ship on the legacy scalar
|
||||
// apply (tracked in 0008 status).
|
||||
changed: {
|
||||
// Replace the footprint wholesale from its own snapshot blob, moved.
|
||||
fromSnapshotUuid: "66666666-0000-0000-0000-000000000001",
|
||||
|
|
@ -267,8 +268,8 @@ for (const cfg of [PL, SCH, PCB]) {
|
|||
|
||||
// 3. A genuine local edit emits an items wire carrying the touched item.
|
||||
// Run this BEFORE the applies: TestMoveFirst moves the FIRST screen item,
|
||||
// which must be a fixture wire/track (the proven off-fiber move path) — an
|
||||
// apply-added text would no-op the virtual Move (known asyncify quirk).
|
||||
// which must be a fixture wire/track (the proven off-coroutine move path) —
|
||||
// an apply-added text would no-op the virtual Move (a quirk found asyncify-era).
|
||||
// Skipped when the harness can't drive the tool's emit (see ToolCfg).
|
||||
if (cfg.localEdit) {
|
||||
const editedUuid = (await page.evaluate(cfg.localEdit)) as string;
|
||||
|
|
|
|||
|
|
@ -203,7 +203,7 @@ test.describe('PCB load probe', () => {
|
|||
|
||||
// Give the file dialog generous time to render — wxGenericFileDialog
|
||||
// populates its file list by scanning the directory, which on MEMFS
|
||||
// is fast but goes through the Asyncify loop.
|
||||
// is fast but goes through the suspending event loop.
|
||||
await page.waitForTimeout(3000); // eslint-disable-line -- diagnostic one-shot; intentional state-capture interval
|
||||
|
||||
await page.screenshot({ path: shotPath(page, 'probe-02-after-open-click.png'), scale: 'css' });
|
||||
|
|
|
|||
|
|
@ -150,8 +150,9 @@ function runLoadPcbTest(demo: DemoCfg): void {
|
|||
// If we ever need to dismiss post-load wxMessageDialogs (missing
|
||||
// libs etc.), do it INSIDE waitForBoardLoaded so the dismiss
|
||||
// side-effect lives with the polling loop — calling page.evaluate
|
||||
// from the test driver hangs once the post-load asyncify clipboard
|
||||
// runtime error breaks the wasm event loop. ───────────────────
|
||||
// from the test driver hangs if a runtime error breaks the wasm
|
||||
// event loop (the asyncify-era post-load clipboard error was the
|
||||
// proven case). ─────────────────────────────────────────────────
|
||||
|
||||
// ── Wait for the load to complete (no dialogs visible). ───────
|
||||
const result = await waitForBoardLoaded(page, testLogger, 60000);
|
||||
|
|
@ -179,24 +180,24 @@ function runLoadPcbTest(demo: DemoCfg): void {
|
|||
`WASM aborted during ${demo.name} load:\n${aborts.join('\n\n')}`,
|
||||
).toEqual([]);
|
||||
|
||||
// ── Clean-console gate: NO asyncify corruption may surface anywhere in
|
||||
// the load — not before, not after the board renders. The formerly
|
||||
// tolerated post-load clipboard/unwind RuntimeErrors are fixed
|
||||
// (sync clipboard IsSupported in wx; "unwind" sentinel handling in
|
||||
// the scheduler shim; see docs/features/asyncify-arbiter/).
|
||||
const asyncifySignatures = [
|
||||
// ── Clean-console gate: NO wasm trap may surface anywhere in the
|
||||
// load — not before, not after the board renders. (Historical: the
|
||||
// once-tolerated post-load clipboard/unwind RuntimeErrors were
|
||||
// fixed in the asyncify era — sync clipboard IsSupported in wx,
|
||||
// "unwind" sentinel handling — see docs/features/asyncify-arbiter/.)
|
||||
const wasmTrapSignatures = [
|
||||
'index out of bounds',
|
||||
'indirect call to null',
|
||||
'uncaught exception: unwind',
|
||||
'invalid state',
|
||||
'is not a function',
|
||||
];
|
||||
const asyncifyErrors = allLines.filter((l) =>
|
||||
asyncifySignatures.some((sig) => l.toLowerCase().includes(sig)),
|
||||
const wasmTrapErrors = allLines.filter((l) =>
|
||||
wasmTrapSignatures.some((sig) => l.toLowerCase().includes(sig)),
|
||||
);
|
||||
expect(
|
||||
asyncifyErrors,
|
||||
`Asyncify corruption surfaced during ${demo.name} load:\n${asyncifyErrors.join('\n\n')}`,
|
||||
wasmTrapErrors,
|
||||
`wasm trap surfaced during ${demo.name} load:\n${wasmTrapErrors.join('\n\n')}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,19 +2,16 @@ import type { Page } from "@playwright/test";
|
|||
import { test, expect } from "./fixtures";
|
||||
|
||||
/**
|
||||
* N2 — message ordering under a parked open (scheduler-build target semantics).
|
||||
* N2 — message ordering under a parked open (scheduler semantics).
|
||||
* docs/features/async/17-mailbox-scheduler-plan.md §3d N2, §3b.
|
||||
*
|
||||
* Legacy glue (open_gate, doc 14): collab entries issued while `kicadOpenFile`
|
||||
* is asyncify-parked are DROPPED — collab-load-fuzz.spec.ts asserts that drop
|
||||
* contract on legacy builds, and it is correct for the guard architecture.
|
||||
*
|
||||
* The mailbox flips drop→deliver: a mutating entry issued during the open
|
||||
* becomes a queued message, applied IN ORDER after the open completes. GREEN
|
||||
* since S1's embind lane — the scheduler shim wraps the audited mutators
|
||||
* (doc 18) at the Module boundary, queueing busy-window calls and delivering
|
||||
* after settle with promise-returned results. S4 moves queueing worker-side.
|
||||
* Self-skips on legacy glue (the lane is a build variant until S5).
|
||||
* Legacy glue (open_gate, doc 14) DROPPED collab entries issued while
|
||||
* `kicadOpenFile` was parked; the mailbox flipped drop→deliver, and the
|
||||
* scheduler's embind lane is the only glue now: a mutating entry issued
|
||||
* during the open becomes a queued message, applied IN ORDER after the open
|
||||
* completes — the shim wraps the audited mutators (doc 18) at the Module
|
||||
* boundary, queueing busy-window calls and delivering after settle with
|
||||
* promise-returned results.
|
||||
*
|
||||
* Ordering probe: apply A ADDS a segment, apply B MOVES that same segment.
|
||||
* B can only land if A landed first — the single final-position check proves
|
||||
|
|
@ -87,15 +84,6 @@ test.describe("mailbox N2: entries during a parked open are delivered in order",
|
|||
void testLogger;
|
||||
await bootHarness(page);
|
||||
|
||||
// The delivery contract under test is the scheduler build's embind lane;
|
||||
// on legacy glue the open gate drops both applies by design.
|
||||
const lane = await page.evaluate(() => {
|
||||
const s = (globalThis as unknown as { __wxScheduler?: { mutatorsWrapped: number } })
|
||||
.__wxScheduler;
|
||||
return s ? s.mutatorsWrapped : 0;
|
||||
});
|
||||
test.skip(lane === 0, "legacy glue — embind lane absent (drop contract in collab-load-fuzz)");
|
||||
|
||||
const issued = await page.evaluate(async ({ newSeg, board }) => {
|
||||
const w = window as unknown as { FS: FS; Module: Mod };
|
||||
const dir = "/home/kicad/documents";
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { test, expect } from "./fixtures";
|
|||
* pcbnew reuses the same wire contract + generic JS reconciler as pl_editor/eeschema; the new
|
||||
* code is the C++ adapter — a native BOARD_LISTENER trigger + post-settle snapshot-diff emit,
|
||||
* and a BOARD_COMMIT apply run inside a COROUTINE (so a freshly-built item's GAL view->Add has
|
||||
* the Asyncify/fiber context it needs, exactly as eeschema). Coverage:
|
||||
* the tool-coroutine context it needs, exactly as eeschema). Coverage:
|
||||
* - snapshot (read): kicadCollabSnapshot reflects items by uuid/type/position.
|
||||
* - apply (single page): kicadCollabApply moves/removes/adds tracks by uuid (deferred via
|
||||
* CallAfter + coroutine, so poll for the result).
|
||||
|
|
@ -202,7 +202,8 @@ test.describe("pcbnew collab bridge — single page", () => {
|
|||
// `added` reconstruction of a footprint, via and zone. The emit side attaches BOTH the full
|
||||
// itemToJson fields AND an s-expr clipboard blob; makeItem then reconstructs a footprint from
|
||||
// the bare `(footprint …)` blob, and a via/zone NATIVELY from the geometry fields (the
|
||||
// `(kicad_pcb …)` envelope parse is asyncify-fragile in wasm for those). Round-trip each: read
|
||||
// `(kicad_pcb …)` envelope parse was asyncify-fragile in wasm for those; healthy under JSPI —
|
||||
// roundtrip.spec.ts pins it — the native path stays as the lean route). Round-trip each: read
|
||||
// its full snapshot item + blob, delete it, re-add, confirm it returns at the same position.
|
||||
for (const [label, id, type] of [
|
||||
["footprint", FP1, "FOOTPRINT"],
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { stableShot } from '../e2e/utils/element-tracker';
|
|||
* deterministically without UI automation, and
|
||||
* 2. the seeded KiCad config that skips the first-run STARTWIZARD (the harness
|
||||
* now seeds it in preRun, matching the web app's boot.ts) — without it the
|
||||
* wizard's modal loop crashes Asyncify and no file can load.
|
||||
* wizard's modal loop wedges the boot and no file can load.
|
||||
*
|
||||
* Strategy mirrors eeschema-load.spec.ts: write a minimal .kicad_wks into MEMFS,
|
||||
* call Module.kicadOpenFile(), and poll the editor title. GREEN once it shows the
|
||||
|
|
|
|||
|
|
@ -124,7 +124,7 @@ test.describe('pl_editor WASM', () => {
|
|||
);
|
||||
|
||||
// The dialog object exists in the registry as soon as C++ constructs it, but the
|
||||
// directory enumeration (MEMFS readdir → asyncify suspend) hasn't returned yet so
|
||||
// directory enumeration (MEMFS readdir → JSPI suspend) hasn't returned yet so
|
||||
// the inner file list isn't painted. stableShot's stabilization waits for the
|
||||
// list to finish painting — deterministically replacing the old waitForTimeout(600)
|
||||
// that used to catch the dialog as a black rectangle.
|
||||
|
|
|
|||
|
|
@ -447,7 +447,7 @@ test("fitViewport applies a world rect (contain) — GetViewport round trip", as
|
|||
m.kicadCollabFitViewport(t.cx, t.cy, t.halfW, t.halfH);
|
||||
}, target);
|
||||
|
||||
// The fit is CallAfter+fiber scheduled — poll the transform until it lands.
|
||||
// The fit is CallAfter+coroutine scheduled — poll the transform until it lands.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -540,7 +540,7 @@ test("fitViewport applies a world rect (contain) — GetViewport round trip", as
|
|||
m.kicadCollabFitViewport(t.cx, t.cy, t.halfW, t.halfH);
|
||||
}, target);
|
||||
|
||||
// The fit is CallAfter+fiber scheduled — poll the transform until it lands.
|
||||
// The fit is CallAfter+coroutine scheduled — poll the transform until it lands.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
|
|
|
|||
|
|
@ -325,7 +325,7 @@ async function openSyncDialog(page: import('@playwright/test').Page): Promise<vo
|
|||
);
|
||||
}
|
||||
|
||||
/** Close the currently-open modal dialog (Escape unwinds the wx modal loop). */
|
||||
/** Close the currently-open modal dialog (Escape resolves the wx modal wait). */
|
||||
async function closeDialog(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.keyboard.press('Escape');
|
||||
await waitUntil(
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@ import { test, expect } from "./fixtures";
|
|||
*
|
||||
* The user-visible bug: Symbol Properties (any quasi-modal opened from a tool
|
||||
* action) stops responding — OK/Cancel click, nothing happens, only the
|
||||
* titlebar × closes it. Mechanism (doc 19 §4): the tool fiber that owns the
|
||||
* dialog parks mid-body in the quasi-modal wait; a concurrent park's wake
|
||||
* aliases over its live sleep buffer (`aliased-wake-live`), the stale-fiber
|
||||
* guard quarantines it, and the fiber's own legitimate resume is then REFUSED
|
||||
* (`fiber-resume-refused`) and dropped. The fiber never completes, the
|
||||
* dispatch guard it holds never releases, every later click defers forever.
|
||||
* titlebar × closes it. Mechanism (doc 19 §4, asyncify-era vocabulary): the
|
||||
* tool fiber that owned the dialog parked mid-body in the quasi-modal wait; a
|
||||
* concurrent park's wake aliased over its live sleep buffer, the stale-fiber
|
||||
* guard quarantined it, and the fiber's own legitimate resume was then
|
||||
* REFUSED and dropped. The fiber never completed, the dispatch guard it held
|
||||
* never released, every later click deferred forever.
|
||||
*
|
||||
* Staging: the strand needs concurrent parks over the dialog's parked fiber.
|
||||
* The deterministic lever is the parking timer (wasm/bindings/timer_park.h):
|
||||
|
|
@ -299,8 +299,8 @@ test.describe("quasi-modal strand (doc 19)", () => {
|
|||
await okButtonCenter(page);
|
||||
|
||||
// RE-PINNED AT THE FLIP (docs/features/async/22 §10, 2026-08-08), and
|
||||
// RE-KEYED for JSPI (2026-08-13): the `[wx-asyncify] concurrent-park|…`
|
||||
// beacons retired with the asyncify scheduler, which made the old filter
|
||||
// RE-KEYED for JSPI (2026-08-13): the asyncify scheduler's concurrent-park
|
||||
// beacon family retired with it, which made the old filter
|
||||
// vacuous. The post-migration invariant is the same — the dialog opens,
|
||||
// the timer fires and its park survives (asserted above) — and the
|
||||
// observable JSPI failure modes of an overlap are ghost/refused
|
||||
|
|
@ -351,22 +351,29 @@ test.describe("quasi-modal strand (doc 19)", () => {
|
|||
{ timeout: 15000 },
|
||||
)
|
||||
.then(() => true, () => false);
|
||||
const refusedSoFar = testLogger.consoleLogs.filter((l) =>
|
||||
l.includes("fiber-resume-refused"),
|
||||
const anomaliesSoFar = testLogger.consoleLogs.filter((l) =>
|
||||
/\[libctx-jspi\] ghost\/refused transition|\[wx-scheduler\] (force-clearing stuck window|job tick error)|entry REJECTED/.test(
|
||||
l,
|
||||
),
|
||||
).length;
|
||||
console.log(
|
||||
`[STRAND] red outcome: closed=${closed} dialogs=${await dialogCount(page)} ` +
|
||||
`refused-resumes=${refusedSoFar}`,
|
||||
`anomalies=${anomaliesSoFar}`,
|
||||
);
|
||||
|
||||
// Desired end state 1: the dialog closes.
|
||||
expect(closed, "OK closed the quasi-modal dialog").toBe(true);
|
||||
|
||||
// Desired end state 2: no refused fiber resume anywhere in the run.
|
||||
const refused = testLogger.consoleLogs.filter((l) =>
|
||||
l.includes("fiber-resume-refused"),
|
||||
// Desired end state 2: no ghost/refused transition, scheduler anomaly or
|
||||
// rejected coroutine entry anywhere in the run (the old refused-resume
|
||||
// beacon retired with the asyncify scheduler; these are the JSPI
|
||||
// equivalents of a dropped resume).
|
||||
const anomalies = testLogger.consoleLogs.filter((l) =>
|
||||
/\[libctx-jspi\] ghost\/refused transition|\[wx-scheduler\] (force-clearing stuck window|job tick error)|entry REJECTED/.test(
|
||||
l,
|
||||
),
|
||||
);
|
||||
expect(refused, `refused resumes: ${refused.join(" || ")}`).toHaveLength(0);
|
||||
expect(anomalies, `anomalies: ${anomalies.join(" || ")}`).toHaveLength(0);
|
||||
|
||||
// Desired end state 3: the wait books balance — the quasi-modal's
|
||||
// "nested" wait was resolved and consumed, nothing left parked.
|
||||
|
|
|
|||
|
|
@ -503,13 +503,11 @@ test.describe("round trip: file → yjs → file", () => {
|
|||
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
|
||||
});
|
||||
|
||||
// REMAINING KNOWN GAP (ysync 0008 status, known limit 1 — tracked, not a test
|
||||
// bug): pcbnew track/via/zone/text APPLY rides the `(kicad_pcb …)` envelope
|
||||
// parse, the codebase's documented asyncify-fragile path (even a verbatim
|
||||
// SaveSelection envelope for a segment dies silently in the commit). The full
|
||||
// fixture (via + gr_text + segments) therefore still loses those items on the
|
||||
// rebuild side. Un-fixme when the envelope parse is solved. Run with
|
||||
// --grep-invert skipped to see the live diff.
|
||||
// FORMER KNOWN GAP (ysync 0008 status, known limit 1): pcbnew
|
||||
// track/via/zone/text APPLY rides the `(kicad_pcb …)` envelope parse, which
|
||||
// was asyncify-fragile in wasm (even a verbatim SaveSelection envelope for a
|
||||
// segment died silently in the commit) and lost those items on the rebuild
|
||||
// side. Healthy under JSPI — this test is the pin.
|
||||
test( // re-enabled 2026-08-13: the asyncify-fragile envelope parse is gone with JSPI — passes both engines
|
||||
"pcbnew preserves items through a yjs round trip",
|
||||
async ({ context, testLogger }) => {
|
||||
|
|
|
|||
|
|
@ -3,36 +3,34 @@ import { test, expect } from "./fixtures";
|
|||
import { expectGuardsSilent } from "./utils/wait-beacons";
|
||||
|
||||
/**
|
||||
* Timer-park concurrent-Asyncify repro (gal-refresh-timer investigation).
|
||||
* Timer-park concurrency repro (gal-refresh-timer investigation).
|
||||
*
|
||||
* The prod trap ("index out of bounds" + "unreachable executed" in doRewind,
|
||||
* v0.1.17–19, still un-reproduced naturally): a wx timer callback is a FRESH
|
||||
* JS→wasm entry (emscripten_async_call → TimerCallbackFunc::Run → Notify()),
|
||||
* and the main loop spends most wall-clock time Asyncify-parked inside
|
||||
* wxWasmYieldToBrowser. A timer handler that itself parks therefore creates
|
||||
* TWO live Asyncify contexts over the single-slot `Asyncify.currData` — the
|
||||
* emscripten #9153 family that the scheduler shim
|
||||
* (scripts/common/shims/asyncify-scheduler.js) silently repairs. The collab entries add the third ingredient: they run on
|
||||
* TOOL_MANAGER coroutines (emscripten_fiber_swap), which bypass the shim's
|
||||
* allocateData accounting entirely — and `finishContextSwitch` is exactly
|
||||
* where the prod trap's second stack dies.
|
||||
* 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 × main-loop yield park)
|
||||
* 2. + collab entry hammering (adds fiber swaps through the window)
|
||||
* 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 — on a build where the
|
||||
* hypothesis holds this is deterministically RED, and after the real fix it
|
||||
* is the regression gate.
|
||||
* 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 concurrent-park windows — the
|
||||
* The final assert now pins ZERO observable anomaly beacons — the
|
||||
* post-migration invariant — instead of demanding the overlap engage.
|
||||
*/
|
||||
|
||||
|
|
@ -150,10 +148,10 @@ async function openAndSettle(page: Page, content: string): Promise<void> {
|
|||
|
||||
/**
|
||||
* One repro cycle in-page: arm the parking timer, then poll its state until
|
||||
* the park completes — optionally hammering the fiber-based collab entries
|
||||
* 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 Asyncify-parked and the main loop's yield
|
||||
* park keeps cycling: the exact concurrent-context interleaving under test.
|
||||
* runs while the timer chain is suspended mid-Notify(): the exact
|
||||
* concurrent-entry interleaving under test.
|
||||
*/
|
||||
async function armAndRide(
|
||||
page: Page,
|
||||
|
|
@ -176,7 +174,7 @@ async function armAndRide(
|
|||
stats.armed = true;
|
||||
|
||||
const t0 = performance.now();
|
||||
// Bound = park length + generous rewind budget; exits on completion.
|
||||
// Bound = park length + generous resume budget; exits on completion.
|
||||
while (performance.now() - t0 < parkMs + 20000) {
|
||||
try {
|
||||
const st = JSON.parse(m.kicadTestTimerParkState()) as {
|
||||
|
|
@ -197,8 +195,8 @@ async function armAndRide(
|
|||
// 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 asyncify buffers of the
|
||||
// parked chains live in linear memory on both sides of the boundary.
|
||||
// 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 }
|
||||
|
|
@ -234,7 +232,7 @@ async function armAndRide(
|
|||
}, opts);
|
||||
}
|
||||
|
||||
test.describe("timer Notify() Asyncify-park during main-loop yield (concurrent currData)", () => {
|
||||
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,
|
||||
|
|
@ -270,7 +268,7 @@ test.describe("timer Notify() Asyncify-park during main-loop yield (concurrent c
|
|||
}
|
||||
|
||||
// The runtime is still fully functional: snapshots walk the board and a
|
||||
// real apply lands (a poisoned Asyncify state fails one of these first).
|
||||
// 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,
|
||||
|
|
@ -331,21 +329,23 @@ test.describe("timer Notify() Asyncify-park during main-loop yield (concurrent c
|
|||
);
|
||||
if (cLane) expectGuardsSilent(testLogger.consoleLogs, ["timerRetry"]);
|
||||
|
||||
// RE-PINNED AT THE FLIP (docs/features/async/22 §10, 2026-08-08). This
|
||||
// lever staged "timer park × MAIN-LOOP YIELD PARK", and D5 removed the
|
||||
// main loop's per-frame in-place park — the overlap is structurally
|
||||
// impossible now, so "the shim observed the window" (>0) can never pass
|
||||
// again. The pin flips to the invariant the migration exists to
|
||||
// establish: the lever runs, the park survives (asserted above), and NO
|
||||
// concurrent-park window is observable at all.
|
||||
// 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) =>
|
||||
/\[wx-asyncify\] (concurrent-park|aliased-wake-live|overlapped-wake)/.test(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 concurrent-park window is observable post-flip",
|
||||
"no ghost/stuck-window/job-tick/rejected-entry anomaly is observable post-flip",
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -323,16 +323,16 @@ export async function closeTrio(trio: Trio): Promise<void> {
|
|||
// ── Per-tab probes ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Silent save-to-MEMFS + read back — no onSave side effects. Defers while
|
||||
* collab fiber work is in flight: a bare-embind-stack save during a parked
|
||||
* 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;
|
||||
Module: Mod & { kicadCollabFiberBusy?: () => boolean };
|
||||
Module: Mod & { kicadCollabBusy?: () => boolean };
|
||||
};
|
||||
for (let i = 0; i < 200 && w.Module.kicadCollabFiberBusy?.(); i++) {
|
||||
for (let i = 0; i < 200 && w.Module.kicadCollabBusy?.(); i++) {
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
const out = `/home/kicad/documents/_dump.${ext}`;
|
||||
|
|
@ -371,9 +371,9 @@ export function drift(page: Page, cfg: ToolCfg): Promise<DriftSummary | null> {
|
|||
async ({ saveFn, ext }) => {
|
||||
const w = window as unknown as {
|
||||
KicadCollabV2: { driftReport(f: string, p: string): DriftSummary | null };
|
||||
Module: { kicadCollabFiberBusy?: () => boolean };
|
||||
Module: { kicadCollabBusy?: () => boolean };
|
||||
};
|
||||
for (let i = 0; i < 200 && w.Module.kicadCollabFiberBusy?.(); i++) {
|
||||
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}`);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
// Wait-beacon extraction (JSPI-era successor of guard-beacons.ts; the
|
||||
// mailbox/scheduler migration doc is docs/features/async/17, step S0.3).
|
||||
//
|
||||
// Every legacy anti-collision guard announces itself on the console when it fires.
|
||||
// During the migration each superseded guard is kept as a TRIPWIRE: the mailbox is
|
||||
// only trusted once the guard it replaces is provably silent across the suite.
|
||||
// This module turns a TestLogger's consoleLogs into per-family counts so specs can
|
||||
// assert `expectGuardsSilent(...)` at the step that claims a family.
|
||||
// Every anti-collision guard announces itself on the console when it fires.
|
||||
// A superseded guard is kept as a TRIPWIRE: the mailbox is only trusted once
|
||||
// the guard it replaces is provably silent across the suite. This module turns
|
||||
// a TestLogger's consoleLogs into per-family counts so specs can assert
|
||||
// `expectGuardsSilent(...)` at the step that claims a family.
|
||||
//
|
||||
// Rate-limiting caveat: [wx-asyncify] and [collab-fcontext] beacons print the first
|
||||
// 10 occurrences, then every 100th, embedding "(occurrence N)". `linesSeen` is what
|
||||
// Rate-limiting caveat: the [wx-dispatch] ERASED beacon prints the first 10
|
||||
// occurrences, then every 100th, embedding "(occurrence N)". `linesSeen` is what
|
||||
// reached the console; `estimatedTotal` recovers the true count from the highest
|
||||
// occurrence number when present (else it equals linesSeen). Assertions on SILENCE
|
||||
// are exact either way: zero fires = zero lines.
|
||||
|
|
@ -24,32 +24,18 @@ export interface GuardBeaconCounts {
|
|||
timerRetry: BeaconFamilyCount;
|
||||
// wx dispatch interlock bookkeeping anomalies (evtloop.cpp)
|
||||
dispatchAnomaly: BeaconFamilyCount;
|
||||
// asyncify-scheduler.js shim: nested-park / wake-aliasing / stale-fiber refusals
|
||||
wxAsyncify: BeaconFamilyCount;
|
||||
// libcontext swap-layer refusals + hot-main beacons ([collab-fcontext])
|
||||
libcontext: BeaconFamilyCount;
|
||||
// open-settle gate giving up (open-flow.ts)
|
||||
openSettleFailed: BeaconFamilyCount;
|
||||
// jspi-scheduler.js turnstile/containment beacons (JSPI builds)
|
||||
// jspi-scheduler.js turnstile/containment beacons
|
||||
wxScheduler: BeaconFamilyCount;
|
||||
// libcontext JSPI backend ghost/refused-transition census
|
||||
libctxJspi: BeaconFamilyCount;
|
||||
// scheduler build marker — identifies the dual-glue variant, not a guard
|
||||
schedulerBuild: boolean;
|
||||
}
|
||||
|
||||
const FAMILY_PATTERNS: Record<
|
||||
Exclude<keyof GuardBeaconCounts, 'schedulerBuild'>,
|
||||
RegExp
|
||||
> = {
|
||||
const FAMILY_PATTERNS: Record<keyof GuardBeaconCounts, RegExp> = {
|
||||
timerRetry: /\[wx-timer\] retry storm/,
|
||||
dispatchAnomaly: /\[wx-dispatch\] (ERASED|NEGATIVE)/,
|
||||
wxAsyncify:
|
||||
/\[wx-asyncify\] (concurrent-park|reentrant-state|aliased-wake-live|overlapped-wake|fiber-resume-refused)/,
|
||||
libcontext:
|
||||
/\[collab-fcontext\] (jump-refused|jump-refused-hot-main|hot-main-swap-out|jump-hot-into-main|jump-ghost|entry-orphaned)/,
|
||||
openSettleFailed: /\[open\] load chain never settled/,
|
||||
// JSPI-era families (jspi-scheduler.js + libcontext's JSPI backend):
|
||||
wxScheduler:
|
||||
/\[wx-scheduler\] (force-clearing stuck window|job tick error|untracked promising entry|activation stack imbalance|resume window misnested)/,
|
||||
libctxJspi: /\[libctx-jspi\] ghost\/refused/,
|
||||
|
|
@ -66,19 +52,12 @@ export function countGuardBeacons(consoleLines: string[]): GuardBeaconCounts {
|
|||
const counts: GuardBeaconCounts = {
|
||||
timerRetry: emptyFamily(),
|
||||
dispatchAnomaly: emptyFamily(),
|
||||
wxAsyncify: emptyFamily(),
|
||||
libcontext: emptyFamily(),
|
||||
openSettleFailed: emptyFamily(),
|
||||
wxScheduler: emptyFamily(),
|
||||
libctxJspi: emptyFamily(),
|
||||
schedulerBuild: false,
|
||||
};
|
||||
|
||||
for (const line of consoleLines) {
|
||||
if (line.includes('[wx-scheduler] scaffolding installed')) {
|
||||
counts.schedulerBuild = true;
|
||||
continue;
|
||||
}
|
||||
for (const family of Object.keys(FAMILY_PATTERNS) as Array<
|
||||
keyof typeof FAMILY_PATTERNS
|
||||
>) {
|
||||
|
|
@ -94,24 +73,11 @@ export function countGuardBeacons(consoleLines: string[]): GuardBeaconCounts {
|
|||
return counts;
|
||||
}
|
||||
|
||||
// Last-seen fcsTotal/rootHotTotal from a __wxAsyncifyDump()/STATE line, if any.
|
||||
// rootHotTotal must stay 0 post-v0.1.28 — the standing N8 assertion.
|
||||
export function parseAsyncifyCounters(
|
||||
consoleLines: string[]
|
||||
): { fcsTotal: number; rootHotTotal: number } | null {
|
||||
let result: { fcsTotal: number; rootHotTotal: number } | null = null;
|
||||
for (const line of consoleLines) {
|
||||
const m = /fcsTotal=(\d+) rootHotTotal=(\d+)/.exec(line);
|
||||
if (m) result = { fcsTotal: parseInt(m[1], 10), rootHotTotal: parseInt(m[2], 10) };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Assert the named guard families never fired. Throws with the offending sample
|
||||
// lines so the log points straight at the collision the mailbox failed to absorb.
|
||||
export function expectGuardsSilent(
|
||||
consoleLines: string[],
|
||||
families: Array<Exclude<keyof GuardBeaconCounts, 'schedulerBuild'>>
|
||||
families: Array<keyof GuardBeaconCounts>
|
||||
): void {
|
||||
const counts = countGuardBeacons(consoleLines);
|
||||
const noisy = families
|
||||
|
|
|
|||
|
|
@ -350,7 +350,7 @@ for (const [cfg, label] of [
|
|||
}) => {
|
||||
// TWO kicad_editor instances exceed Firefox's per-content-process wasm
|
||||
// budget (the 2nd tab's #canvas never appears, even serial/isolated —
|
||||
// same SpiderMonkey wall playwright-kicad.config.ts documents for x86
|
||||
// same SpiderMonkey wall the merged playwright.config.ts documents for x86
|
||||
// CI, hit at 2× on ARM). V8 handles it: runs on chromium-ci in CI and
|
||||
// --project=chromium locally.
|
||||
// 2026-08-13: FF wasm-budget skip retired — the JSPI build fits two
|
||||
|
|
|
|||
Loading…
Reference in a new issue