From c421d724b0326e8795f2ab598f0eb2f405d229a8 Mon Sep 17 00:00:00 2001 From: Istvan Matejcsok <119620946+matejcsok-ee@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:27:12 +0200 Subject: [PATCH] findings(E-10..E-22): fix the defects a code review found in the E-1..E-9 work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ": " 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) --- .github/workflows/wasm-build.yml | 20 + kicad | 2 +- scripts/common/shims/jspi-scheduler.js | 53 ++- tests/e2e/filedialog.spec.ts | 4 +- tests/e2e/layout.spec.ts | 4 +- tests/e2e/menu.spec.ts | 2 +- tests/e2e/modal.spec.ts | 6 +- tests/e2e/scrollbar.spec.ts | 2 +- tests/e2e/secondary-frame-chrome.spec.ts | 8 +- tests/e2e/wizard.spec.ts | 2 +- tests/kicad/3d-viewer-models.spec.ts | 6 +- tests/kicad/3d-viewer.spec.ts | 6 +- tests/kicad/eeschema-sim-recovery.spec.ts | 183 +++++++++ tests/kicad/eeschema-sim.spec.ts | 122 +----- tests/kicad/load-pcb.spec.ts | 4 +- tests/kicad/occ-export-models.spec.ts | 58 +-- tests/kicad/occ-export.spec.ts | 111 ++---- tests/kicad/occ-service-watchdog.spec.ts | 82 ++++ tests/kicad/pcbnew-move.spec.ts | 18 +- tests/kicad/utils/board-ready.ts | 19 +- tests/kicad/utils/native-failure.ts | 35 ++ tests/kicad/utils/ngspice-service.ts | 366 +++++++++++------- tests/kicad/utils/occ-service.ts | 139 ++++--- tests/kicad/utils/sim-harness.ts | 133 +++++++ tests/kicad/utils/trio.ts | 4 +- tests/kicad/utils/wx-dialogs.ts | 98 +++++ tests/package.json | 3 +- tests/tools/findings-e-source-contract.ts | 144 ++++++- tests/tools/lib/worker-constants.ts | 57 +++ tests/tools/lint-ci-coverage.ts | 34 ++ tests/tools/lint-determinism.ts | 13 + tests/tools/ngspice-worker-batch-unit.ts | 131 ++++++- tests/tools/service-stub-parity.ts | 94 +++++ tests/web/footprint-write-remote.spec.ts | 4 +- tests/web/symbol-write-remote.spec.ts | 10 +- tests/web/symbol-write-spike.spec.ts | 14 +- wasm/stubs/sharedspice_client.cpp | 83 +++- web/standalone/src/wasm/boot.ts | 7 + .../src/wasm/libs/models-bridge.test.ts | 99 +++++ web/standalone/src/wasm/libs/models-bridge.ts | 40 +- .../src/wasm/ngspice-service.test.ts | 182 ++++++--- web/standalone/src/wasm/ngspice-service.ts | 71 +++- web/standalone/src/wasm/ngspice-worker.js | Bin 12749 -> 14803 bytes web/standalone/src/wasm/occ-service.test.ts | 93 ++--- web/standalone/src/wasm/occ-service.ts | 45 ++- .../src/wasm/scheduler-shim.test.ts | 95 ++++- .../src/wasm/test-utils/fake-worker.ts | 57 +++ wxwidgets | 2 +- 48 files changed, 2071 insertions(+), 694 deletions(-) create mode 100644 tests/kicad/eeschema-sim-recovery.spec.ts create mode 100644 tests/kicad/occ-service-watchdog.spec.ts create mode 100644 tests/kicad/utils/native-failure.ts create mode 100644 tests/kicad/utils/sim-harness.ts create mode 100644 tests/kicad/utils/wx-dialogs.ts create mode 100644 tests/tools/lib/worker-constants.ts create mode 100644 tests/tools/service-stub-parity.ts create mode 100644 web/standalone/src/wasm/test-utils/fake-worker.ts diff --git a/.github/workflows/wasm-build.yml b/.github/workflows/wasm-build.yml index 48f1e0c..38b56d6 100644 --- a/.github/workflows/wasm-build.yml +++ b/.github/workflows/wasm-build.yml @@ -393,6 +393,14 @@ jobs: corepack enable pnpm install --frozen-lockfile + # The web/standalone vitest suites carry the red/green evidence for the + # findings-E service-layer fixes (worker lifecycle, transport credits, + # admission gate, models prefetch). Node-env, no wasm build needed. + - name: web standalone unit tests (vitest) + if: inputs.run_tests + working-directory: web + run: pnpm --filter @pcbjam/standalone test + # kicad_tools gates (tasks-runner 0001 R2): the corpus lint (fixtures + # shared-codec round-trips — the wrapInBoardEnvelope-class E3 gate, # kicad-validity 0001 §5) and the CLI contract the backend job runner @@ -415,6 +423,18 @@ jobs: npm ci npm test + # Findings-E gates: the ngspice transport reducer (production worker + # source in a node:vm), the C++/shim source contract, and the + # stub/production parity tripwire. lint-ci-coverage asserts these exact + # invocations stay wired (NON_PLAYWRIGHT_GATES). + - name: findings-E transport reducer + source/parity contracts + if: inputs.run_tests + working-directory: tests + run: | + npm run ngspice:worker-batch + npm run findings-e:contract + npm run findings-e:parity + # Browser binaries keyed on the lockfile (which pins the playwright version). # On a hit `playwright install` skips the downloads; --with-deps still # apt-installs its small OS dep set either way. diff --git a/kicad b/kicad index 2306829..5667863 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit 230682955a2873ade2273875659a630311982abb +Subproject commit 56678639eea1b3c0358f384fa47ef7fab78bd09b diff --git a/scripts/common/shims/jspi-scheduler.js b/scripts/common/shims/jspi-scheduler.js index 99b843c..c6efc79 100644 --- a/scripts/common/shims/jspi-scheduler.js +++ b/scripts/common/shims/jspi-scheduler.js @@ -250,6 +250,16 @@ resolveWait: function (token, result) { var entry = this.waits.get(token); if (!entry || entry.resolved) return false; + if (this.terminal) { + // Resolving would resume the parked frame INSIDE the trapped module + // (the runWaitCompletion invariant, which the bare finishers used to + // bypass). Refuse WITHOUT consuming the entry — the frame stays + // visibly parked in dump() and the ring says why. + this._note("resolveRefused", entry.kind, token); + console.warn("[wx-scheduler] resolveWait(" + token + ", " + entry.kind + + ") refused: instance is terminal"); + return false; + } entry.resolved = true; this.waitsResolved++; var stack = this.waitStacks[entry.kind]; @@ -282,27 +292,36 @@ dead: false, // --- E-8: admission gate for delayed worker/MEMFS completions ----------- - // `terminal` means the wasm instance TRAPPED (WebAssembly.RuntimeError or - // its cross-realm string equivalent): the heap may be mid-mutation, so no - // further native work (malloc / heap stores / FS writes) may run and no - // parked frame may be resumed into it. Distinct from `dead` (orderly - // shutdown). One-way. + // `terminal` means the wasm instance TRAPPED (WebAssembly.RuntimeError, + // or emscripten's abort — which throws a RuntimeError itself and is also + // latched authoritatively via Module.onAbort → terminalize): the heap may + // be mid-mutation, so no further native work (malloc / heap stores / FS + // writes) may run and no parked frame may be resumed into it. Distinct + // from `dead` (orderly shutdown). One-way. terminal: false, canTouchNative: function () { return !this.dead && !this.terminal; }, + // Public one-way latch (also wired from boot's Module.onAbort — the + // authoritative abort notification). + terminalize: function (site, e) { + if (this.terminal) return; + this.terminal = true; + this._note("terminal", site, 0); + console.error("[wx-scheduler] instance is terminal (" + site + + ") — all further native completions are inert: " + (e || "")); + }, _terminalizeNativeTrap: function (site, e) { + // Structural signals only: a genuine engine trap in this same-realm + // prepare/entry IS a WebAssembly.RuntimeError instance; the duck-typed + // name fallback survives realm loss on a relayed error object. The old + // message-substring sniff ('Aborted(', 'index out of bounds', …) only + // added false positives — any plain JS error QUOTING such text bricked + // a healthy instance permanently. var isTrap = (typeof WebAssembly !== "undefined" && WebAssembly.RuntimeError && e instanceof WebAssembly.RuntimeError) - || /unreachable|memory access out of bounds|index out of bounds|null function or function signature mismatch|Aborted\(/i - .test(String((e && e.message) || e)); + || !!(e && e.name === "RuntimeError"); if (!isTrap) return false; - if (!this.terminal) { - this.terminal = true; - this._note("terminal", site, 0); - console.error("[wx-scheduler] native trap in " + site - + " — instance is terminal; all further native completions are inert: " - + e); - } + this.terminalize(site, e); return true; }, // The one admission boundary for delayed completions that both touch @@ -550,7 +569,11 @@ }, _pumpResume: function () { - if (this.dead) return; + // `terminal` too: a queued wake must never re-enter a trapped module — + // resuming swaps SP into (and runs wasm on) a heap that may be + // mid-mutation. Freezing the pump on a terminal instance is by design: + // the fatal overlay owns the page from here. + if (this.dead || this.terminal) return; if (this._windowLive) { // Self-heal: an activation that suspended RAW (bypassing the shim) // or completed untracked never ends its window here; without this diff --git a/tests/e2e/filedialog.spec.ts b/tests/e2e/filedialog.spec.ts index 77c8f02..22b8e9a 100644 --- a/tests/e2e/filedialog.spec.ts +++ b/tests/e2e/filedialog.spec.ts @@ -58,9 +58,9 @@ test.describe('wxFileDialog Tests', () => { // Try all three buttons await clickByLabel(page, 'Open File...'); - await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: click commit before the next dialog button click await clickByLabel(page, 'Save File...'); - await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: click commit before the next dialog button click await clickByLabel(page, 'Open Multiple...'); await stableShot(page, 'filedialog-05-all-buttons.png', { fullPage: true }); diff --git a/tests/e2e/layout.spec.ts b/tests/e2e/layout.spec.ts index 9e99653..c3d184f 100644 --- a/tests/e2e/layout.spec.ts +++ b/tests/e2e/layout.spec.ts @@ -82,7 +82,7 @@ test.describe('wxSplitterWindow & wxScrolledWindow Tests', () => { await page.mouse.down(); await page.mouse.move(sash!.centerX + 100, sash!.centerY, { steps: 5 }); await page.mouse.up(); - await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell (splitter drag commit before re-reading sash from registry) + await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: splitter drag commit before re-reading sash from registry // Get updated sash position after drag const sashAfter = await getSplitterSash(page); @@ -91,7 +91,7 @@ test.describe('wxSplitterWindow & wxScrolledWindow Tests', () => { // Scroll left pane (use position left of sash) await page.mouse.move(sashAfter!.centerX - 100, sashAfter!.centerY); await page.mouse.wheel(0, 50); - await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell (scroll commit between the two pane scrolls) + await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: scroll commit between the two pane scrolls // Scroll right pane (use position right of sash) await page.mouse.move(sashAfter!.centerX + 100, sashAfter!.centerY); diff --git a/tests/e2e/menu.spec.ts b/tests/e2e/menu.spec.ts index 62ed2eb..7d8d05f 100644 --- a/tests/e2e/menu.spec.ts +++ b/tests/e2e/menu.spec.ts @@ -67,7 +67,7 @@ test.describe('wxMenuBar Tests', () => { for (const label of menuLabels) { const clicked = await clickMenuBarItem(page, label); expect(clicked, `Menu "${label}" should be found and clicked`).toBe(true); - await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: menu open commit between menu-bar clicks } await stableShot(page, 'menu-05-all-menus.png', { fullPage: true }); diff --git a/tests/e2e/modal.spec.ts b/tests/e2e/modal.spec.ts index fb557e8..5ac05f0 100644 --- a/tests/e2e/modal.spec.ts +++ b/tests/e2e/modal.spec.ts @@ -173,9 +173,9 @@ test.describe('Modal dialog border + drag (pcbjam #22)', () => { const startX = tbox!.x + tbox!.width / 2; const startY = tbox!.y + tbox!.height / 2; await page.mouse.move(startX, startY); - await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell (pointer settle before grabbing the title bar) + await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: pointer settle before grabbing the title bar await page.mouse.down(); - await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell (press commit before the drag begins) + await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: press commit before the drag begins // Drag in many small steps, sampling the modal canvas immediately after each // move. Each move calls setWindowRect, which clears the canvas; the dialog's @@ -257,7 +257,7 @@ test.describe('Modal dialog border + drag (pcbjam #22)', () => { const startY = hbox!.y + hbox!.height / 2; await page.mouse.move(startX, startY); await page.mouse.down(); - await page.waitForTimeout(120); // eslint-disable-line -- documented interaction dwell (press commit before the resize drag begins) + await page.waitForTimeout(120); // eslint-disable-line -- documented interaction dwell: press commit before the resize drag begins let minOpaque = 1; let lowFrames = 0; diff --git a/tests/e2e/scrollbar.spec.ts b/tests/e2e/scrollbar.spec.ts index f8408e5..3555f3b 100644 --- a/tests/e2e/scrollbar.spec.ts +++ b/tests/e2e/scrollbar.spec.ts @@ -48,7 +48,7 @@ test.describe('DOM-port scrollbars', () => { await page.mouse.down(); await page.mouse.move(tx, ty, { steps: 6 }); await page.mouse.up(); - await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: slider drag commit before the next drag } // At least one standalone scrollbar must have reported a non-zero position. diff --git a/tests/e2e/secondary-frame-chrome.spec.ts b/tests/e2e/secondary-frame-chrome.spec.ts index accf29e..c7aa890 100644 --- a/tests/e2e/secondary-frame-chrome.spec.ts +++ b/tests/e2e/secondary-frame-chrome.spec.ts @@ -45,7 +45,7 @@ test.describe('secondary-frame DOM title bar (drag / close)', () => { const after = await listWindows(); const id = after.find((w) => !before.includes(w)); expect(id, `${buttonLabel} should open a new window`).toBeTruthy(); - await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell (new-window DOM population settle; no event/registry observable) + await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: new-window DOM population settle; no event/registry observable return id as string; } @@ -61,14 +61,14 @@ test.describe('secondary-frame DOM title bar (drag / close)', () => { await page.mouse.down(); await page.mouse.move(sx, sy + 90, { steps: 10 }); await page.mouse.up(); - await page.waitForTimeout(250); // eslint-disable-line -- documented interaction dwell (title-bar drag commit; no event/registry observable) + await page.waitForTimeout(250); // eslint-disable-line -- documented interaction dwell: title-bar drag commit; no event/registry observable const after = await styleRect(winId); return !!before && !!after && (Math.abs(after.top - before.top) > 5 || Math.abs(after.left - before.left) > 5); } async function closeViaTitlebar(winId: string): Promise { await page.locator(`#${winId} .window-titlebar-close`).click(); - await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell (× close / modal EndModal commit; no event/registry observable) + await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell: × close / modal EndModal commit; no event/registry observable return page.evaluate((wid) => { const el = document.getElementById(wid); return !el || getComputedStyle(el).display === 'none'; @@ -87,7 +87,7 @@ test.describe('secondary-frame DOM title bar (drag / close)', () => { await page.mouse.down(); await page.mouse.move(sx + 60, sy + 60, { steps: 10 }); await page.mouse.up(); - await page.waitForTimeout(250); // eslint-disable-line -- documented interaction dwell (se-corner resize drag commit; no event/registry observable) + await page.waitForTimeout(250); // eslint-disable-line -- documented interaction dwell: se-corner resize drag commit; no event/registry observable const after = await styleRect(winId); return !!before && !!after && (after.width - before.width > 20) && (after.height - before.height > 20); diff --git a/tests/e2e/wizard.spec.ts b/tests/e2e/wizard.spec.ts index a29b1e2..14bb995 100644 --- a/tests/e2e/wizard.spec.ts +++ b/tests/e2e/wizard.spec.ts @@ -58,7 +58,7 @@ test.describe('wxWizard Tests', () => { // Let the Next page-transition commit before clicking Back (the Back/Next // buttons persist across pages, so there is no registry delta to poll on). - await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: Next page-transition commit; no registry delta to poll // Click Back using element registry const backClicked = await clickByLabel(page, 'Back'); diff --git a/tests/kicad/3d-viewer-models.spec.ts b/tests/kicad/3d-viewer-models.spec.ts index 5f32051..e29fa02 100644 --- a/tests/kicad/3d-viewer-models.spec.ts +++ b/tests/kicad/3d-viewer-models.spec.ts @@ -144,9 +144,9 @@ async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors await page.mouse.click(filenameInput.x, filenameInput.y); // Documented interaction dwells: focus + typed-text registration have no observable signal. - await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: focus registration has no observable signal await page.keyboard.type(pcbFilename); - await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: typed-text registration has no observable signal await page.keyboard.press('Enter'); const result = await waitForBoardLoaded(page, testLogger, 60000); @@ -215,7 +215,7 @@ test.describe('3D viewer component models', () => { SERVED_REF, { timeout: 120000 }); // Let the rest of the model-enumeration ensures flush after the served ref lands — // the total count isn't known up front, so this is a documented settle interval. - await page.waitForTimeout(3000); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(3000); // eslint-disable-line -- documented interaction dwell: model-enumeration ensures flush; total count unknown up front // --- bridge assertions (run on CI too) --------------------------------- const ensures = await page.evaluate(() => window.__modelEnsures ?? []); diff --git a/tests/kicad/3d-viewer.spec.ts b/tests/kicad/3d-viewer.spec.ts index 891d94a..a657b9f 100644 --- a/tests/kicad/3d-viewer.spec.ts +++ b/tests/kicad/3d-viewer.spec.ts @@ -269,13 +269,13 @@ test.describe('3D viewer from pcbnew', () => { // Let the frame-move op (wx_window_move → wxWindow::Move) fully settle before the // next interaction: the DOM style.top updates before the wx-side op completes, so // polling the outcome races the following close click (documented interaction dwell). - await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: frame-move op settle; polling races the close click const afterTop = await styleTop(winId as string); expect(afterTop, 'dragging the title bar should move the 3D viewer frame').not.toBe(beforeTop); // Close via the × (wx_window_close → wx Close() → OnCloseWindow). await page.locator(`#${winId} .window-titlebar-close`).click(); - await page.waitForTimeout(600); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(600); // eslint-disable-line -- documented interaction dwell: wx Close() commit before checking the frame is gone const gone = await page.evaluate((wid) => { const el = document.getElementById(wid); return !el || getComputedStyle(el).display === 'none'; @@ -352,7 +352,7 @@ test.describe('3D viewer from pcbnew', () => { await page.mouse.up(); // Let the resize op (wx_window_resize → SetSize → relayout + GL canvas resize) // settle before reading widths (documented interaction dwell). - await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell: resize + GL canvas relayout settle before reading widths const afterFrame = await frameWidth(winId as string); const afterGl = await glWidth(); diff --git a/tests/kicad/eeschema-sim-recovery.spec.ts b/tests/kicad/eeschema-sim-recovery.spec.ts new file mode 100644 index 0000000..c20ab0e --- /dev/null +++ b/tests/kicad/eeschema-sim-recovery.spec.ts @@ -0,0 +1,183 @@ +import { test, expect } from './fixtures'; +import { clickByTooltip, waitForEditorReady } from '../e2e/utils/element-tracker'; +import { FATAL_WASM_PATTERNS, findNativeFailure } from './utils/native-failure'; +import { + loadRectifier, + openSimulator, + runSimulation, + waitForRunToolEnabled, +} from './utils/sim-harness'; + +/** + * eeschema simulator worker-death recovery (findings E-10/E-12/E-11): the + * promise of the out-of-process engine is that a worker death settles + * everything in flight and the next Run transparently boots a fresh worker. + * These specs kill (or corrupt) the service at exact points and assert the + * simulator UI actually recovers: + * + * - E-10: a mid-run worker death must unlatch the client's s_bgRunning + * mirror (via the service's synthetic controlled-exit) — otherwise the + * Run tool's ENABLE(!simRunning) holds "running" forever and the promised + * fresh-worker restart is unreachable for the whole session. + * - E-12: a run whose transport dies between launch acceptance and its + * RUNNING transition delivers its crash-exit completion — the wasm-only + * unowned-event drop must not swallow an owned run's only IDLE. + * - E-11: a corrupted worker's oversized get_vec length must be clamped to + * the actually-transferred arrays — not copied into the editor heap as a + * multi-gigabyte read that traps the instance. + */ + +test.describe('eeschema simulator worker-death recovery', () => { + test.setTimeout(300000); + + test('a mid-run worker death re-enables Run and a rerun succeeds (E-10)', async ({ page, testLogger }) => { + await page.goto('/kicad/eeschema.html'); + await waitForEditorReady(page); + await loadRectifier(page); + await openSimulator(page); + await waitForRunToolEnabled(page); + const checkpoint = await page.evaluate(() => { + const hooks = (globalThis as any).__ngspiceServiceTestHooks; + return hooks.appliedGenerationCheckpoint() as number; + }); + + // Start a run and inject the worker death while it is live. The check + // and the retirement happen in ONE page.evaluate — frames dispatch on + // the same main thread, so no finish frame can interleave between the + // "still running" check and the kill. Event scans are scoped past any + // frame-open activity (workbook plot restoration). + const eventFloor = await page.evaluate( + () => ((window as any).__ngspiceEvents as unknown[]).length); + expect(await clickByTooltip(page, 'Run Simulation', { elementType: 'tool' }), + 'Run tool').toBe(true); + + // Run accepted: the worker's bg started frame arrived. + await expect.poll( + () => page.evaluate((floor: number) => + ((window as any).__ngspiceEvents as Array<{ kind: string; finished?: boolean }>) + .slice(floor) + .some((e) => e.kind === 'bg' && e.finished === false), eventFloor), + { message: 'the run must report bg started', timeout: 60000 }, + ).toBe(true); + + const injected = await page.evaluate((floor: number) => { + const events = ((window as any).__ngspiceEvents as Array<{ + kind: string; finished?: boolean }>).slice(floor); + const finishSeen = events.some((e) => e.kind === 'bg' && e.finished === true); + const retired = (globalThis as any).__ngspiceServiceTestHooks + .forceRetire('E-10 repro: worker death mid-run'); + return { finishSeen, retired }; + }, eventFloor); + expect(injected.finishSeen, + 'repro window: the run must still be live when the fault is injected').toBe(false); + expect(injected.retired, 'the active generation was retired').toBe(true); + + // THE E-10 oracle: without the synthetic controlled-exit the + // s_bgRunning mirror stays latched true; and without the worker's + // pre-init read guard the crash-recovery finish parks on a vector + // pull into the trapped replacement engine — either way this poll + // times out with the Run tool disabled forever. + await waitForRunToolEnabled(page); + + // The crashed run's completion was delivered (cursor/finish body ran). + const crashReceipt = await page.evaluate(async (after: number) => { + const hooks = (globalThis as any).__ngspiceServiceTestHooks; + return await hooks.waitForAppliedGenerationAfter(after, 60000); + }, checkpoint); + expect(crashReceipt.generation, 'the crashed run applied its completion') + .toBeGreaterThan(checkpoint); + + // The synthetic exit is visible in the event record. + const exitSeen = await page.evaluate((floor: number) => + ((window as any).__ngspiceEvents as Array<{ kind: string }>) + .slice(floor) + .some((e) => e.kind === 'exit'), eventFloor); + expect(exitSeen, 'a controlled-exit event reached the client').toBe(true); + + // And the promised transparent restart: a full rerun on a fresh + // worker generation succeeds end to end. + const rerunGeneration = await runSimulation(page); + expect(rerunGeneration).toBeGreaterThan(crashReceipt.generation); + const generations = await page.evaluate(() => + (globalThis as any).__ngspiceServiceTestHooks.snapshot()); + expect(generations.retiredGenerations, 'the killed generation was retired') + .toContain(1); + + expect(findNativeFailure([...testLogger.consoleLogs, ...testLogger.errors]), + 'no wasm abort during the recovery').toBeUndefined(); + }); + + test('a launch that dies before RUNNING still delivers its completion (E-12)', async ({ page, testLogger }) => { + await page.goto('/kicad/eeschema.html'); + await waitForEditorReady(page); + await loadRectifier(page); + await openSimulator(page); + await waitForRunToolEnabled(page); + + // Arm: the transport dies on the bg_run launch itself — after the + // native side published its run generation, before any RUNNING + // transition could fire. The retirement's synthetic exit then + // delivers this run's ONLY completion. (The arm keys on bg_run + // specifically, so frame-open plot restoration cannot consume it.) + const checkpoint = await page.evaluate(() => { + const hooks = (globalThis as any).__ngspiceServiceTestHooks; + hooks.dieOnNextBgRun(); + return hooks.appliedGenerationCheckpoint() as number; + }); + + expect(await clickByTooltip(page, 'Run Simulation', { elementType: 'tool' }), + 'Run tool').toBe(true); + + // THE E-12 oracle: on the unfixed build the crash-exit IDLE carries + // generation 0 (its RUNNING never fired) and is deleted — the owned + // run's completion never applies and this receipt times out. + const receipt = await page.evaluate(async (after: number) => { + const hooks = (globalThis as any).__ngspiceServiceTestHooks; + return await hooks.waitForAppliedGenerationAfter(after, 60000); + }, checkpoint); + expect(receipt.generation, 'the dead launch applied its crash completion') + .toBeGreaterThan(checkpoint); + + // Recovery stays intact: a rerun on the replacement generation works. + const rerunGeneration = await runSimulation(page); + expect(rerunGeneration).toBeGreaterThan(receipt.generation); + + expect(findNativeFailure([...testLogger.consoleLogs, ...testLogger.errors]), + 'no wasm abort during the recovery').toBeUndefined(); + }); + + test('a corrupted get_vec length is clamped, not copied out of bounds (E-11)', async ({ page, testLogger }) => { + await page.goto('/kicad/eeschema.html'); + await waitForEditorReady(page); + await loadRectifier(page); + await openSimulator(page); + await waitForRunToolEnabled(page); + + // Arm BEFORE the run: the next vector pull reports a ~5e8-element + // length while its arrays stay ~101 elements. (Frame-open plot + // restoration also pulls vectors; whichever pull the arm hits, the + // corrupted answer flows through the same client prepare.) + await page.evaluate(() => { + (globalThis as any).__ngspiceServiceTestHooks.corruptNextGetVec(); + }); + + // THE E-11 oracle: on the unfixed build the client copies v_length + // doubles from the small buffer. Observed death shape on this build: + // the 4 GiB std::vector throws an UNHANDLED std::length_error that + // exits the editor's main loop — the scheduler shuts down and the + // whole session is dead (an OOB trap is the sibling shape). Fixed, + // the length clamps to the transferred arrays and the run completes. + await runSimulation(page); + + const scheduler = await page.evaluate(() => ({ + dead: (globalThis as any).__wxScheduler?.dead === true, + terminal: (globalThis as any).__wxScheduler?.terminal === true, + })); + expect(scheduler.dead, + 'the corrupted vector must not exit the editor main loop').toBe(false); + expect(scheduler.terminal, 'the editor instance must not be terminal').toBe(false); + const fatal = findNativeFailure([...testLogger.consoleLogs, ...testLogger.errors]); + expect(fatal, `no wasm trap from the corrupted vector (patterns: ${ + FATAL_WASM_PATTERNS.join(', ')})`).toBeUndefined(); + }); +}); diff --git a/tests/kicad/eeschema-sim.spec.ts b/tests/kicad/eeschema-sim.spec.ts index 7d170ca..14d7ac9 100644 --- a/tests/kicad/eeschema-sim.spec.ts +++ b/tests/kicad/eeschema-sim.spec.ts @@ -1,15 +1,7 @@ import { test, expect } from './fixtures'; -import * as path from 'path'; import { PNG } from 'pngjs'; -import { - clickByTooltip, - clickMenuBarItem, - clickMenuItemByText, - findByTooltip, - stableShot, - waitForEditorReady, -} from '../e2e/utils/element-tracker'; -import { injectFileIntoMemfs } from './utils/fs-inject'; +import { stableShot, waitForEditorReady } from '../e2e/utils/element-tracker'; +import { loadRectifier, openSimulator, runSimulation } from './utils/sim-harness'; /** * eeschema simulator end-to-end (docs/features/ngspice-split/): the historic @@ -26,116 +18,6 @@ import { injectFileIntoMemfs } from './utils/fs-inject'; * run with "unable to find definition of model"). */ -const RECTIFIER_DIR = path.resolve(__dirname, '..', '..', - 'kicad', 'demos', 'simulation', 'rectifier'); -const MEMFS_DIR = '/home/kicad/documents/rectifier'; -const PROJECT_FILES = ['rectifier.kicad_sch', 'rectifier.kicad_pro', 'diode.mod', - 'rectifier_schlib.kicad_sym', 'sym-lib-table', 'rectifier.wbk']; - -async function loadRectifier(page: import('@playwright/test').Page): Promise { - for (const f of PROJECT_FILES) - await injectFileIntoMemfs(page, path.join(RECTIFIER_DIR, f), `${MEMFS_DIR}/${f}`); - - await page.evaluate(async (sch: string) => { - await (window as any).Module.kicadOpenFile(sch); - }, `${MEMFS_DIR}/rectifier.kicad_sch`); - - await expect - .poll(async () => page.title(), { timeout: 120000 }) - .toMatch(/rectifier/i); -} - -// Open Inspect → Simulator and return the new top-level window's DOM id. -async function openSimulator(page: import('@playwright/test').Page): Promise { - const idsBefore = await page.$$eval('#window-container [id^="window-"]', - (els) => els.map((e) => e.id)); - - expect(await clickMenuBarItem(page, 'Inspect'), 'Inspect menu').toBe(true); - await clickMenuItemByText(page, 'Simulator'); - - await page.waitForFunction((before: string[]) => { - const ids = Array.from( - document.querySelectorAll('#window-container [id^="window-"]'), - (e) => e.id); - return ids.some((id) => !before.includes(id)); - }, idsBefore, { timeout: 60000 }); - - const idsAfter = await page.$$eval('#window-container [id^="window-"]', - (els) => els.map((e) => e.id)); - const simWin = idsAfter.find((id) => !idsBefore.includes(id)); - expect(simWin, 'simulator window appeared').toBeTruthy(); - return simWin!; -} - -// Run the loaded workbook's analysis and await the exact native run generation -// only after its final plot, operating-point, and canvas refresh calls return. -async function runSimulation(page: import('@playwright/test').Page): Promise { - // The simulator window div appears while the frame ctor is still - // suspended in the init RPC; the toolbar registers its tools only after - // init completes and the frame first paints. The Run tool's - // ENABLE(!simRunning) condition is a wxUpdateUIEvent check, and the WASM - // port only reliably re-evaluates those when input events pump the loop — - // after a run finishes, the last input was the click that started it, so - // nudge the mouse each poll or the toolbar can hold its stale - // "running" state forever. - await expect - .poll(async () => { - await page.mouse.move(4, 4); - await page.mouse.move(8, 8); - const el = await findByTooltip(page, 'Run Simulation', { elementType: 'tool' }); - return !!el && el.enabled; - }, { timeout: 60000 }) - .toBe(true); - - const generationCheckpoint = await page.evaluate(() => { - const hooks = (globalThis as any).__ngspiceServiceTestHooks; - if (!hooks || typeof hooks.appliedGenerationCheckpoint !== 'function' - || typeof hooks.waitForAppliedGenerationAfter !== 'function') { - throw new Error('exact ngspice applied-generation hooks are missing'); - } - return hooks.appliedGenerationCheckpoint() as number; - }); - - expect(await clickByTooltip(page, 'Run Simulation', { elementType: 'tool' }), - 'Run tool').toBe(true); - - const appliedReceipt = await page.evaluate(async (after: number) => { - const hooks = (globalThis as any).__ngspiceServiceTestHooks; - return await hooks.waitForAppliedGenerationAfter(after, 120000); - }, generationCheckpoint); - expect(appliedReceipt.generation, 'the clicked run published a newer applied generation') - .toBeGreaterThan(generationCheckpoint); - - // The native receipt fires after the final refreshes. Additionally - // require the scheduler to hold no parked ngspice wait — a stale - // suspended frame here means the finish path leaked a wait. (The codex - // line awaited the execution owner's barrier; that machinery does not - // exist on the JSPI line, and wait drainage is its observable - // equivalent.) - await expect.poll( - () => page.evaluate(() => { - const scheduler = (globalThis as any).__wxScheduler; - return scheduler?.pendingWaits?.('ngspice') ?? -1; - }), - { message: 'no ngspice wait may stay parked after the applied receipt', timeout: 30000 }, - ).toBe(0); - - // Vector traffic is result validation only. It is deliberately not used as - // completion evidence because periodic OnSimRefresh(false) pulls can look - // identical to the final pull at the worker boundary. - const vectorReceipt = await page.evaluate(() => - ((window as any).__ngspiceLog as Array<{ - sequence: number; kind: string; error?: string; length?: number; - }>).find((entry) => entry.kind === 'get_vec_info' - && entry.error === undefined - && (entry.length ?? -1) >= 101) ?? null, - ); - expect(vectorReceipt, 'the applied run returned a non-trivial successful vector') - .not.toBeNull(); - - return appliedReceipt.generation; -} - function distinctColors(png: PNG): number { const colors = new Set(); // 8x8 grid sampling, same spirit as the 3d-viewer render check. diff --git a/tests/kicad/load-pcb.spec.ts b/tests/kicad/load-pcb.spec.ts index 7ef5f45..e9b03dc 100644 --- a/tests/kicad/load-pcb.spec.ts +++ b/tests/kicad/load-pcb.spec.ts @@ -139,9 +139,9 @@ function runLoadPcbTest(demo: DemoCfg): void { await page.mouse.click(filenameInput.x, filenameInput.y); // Small settle so the focus click lands before typing — no JS-observable "input // focused" signal here (documented interaction wait). - await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: focus click commit; no JS-observable focus signal await page.keyboard.type(pcbFilename); - await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: typed-text registration has no observable signal await page.keyboard.press('Enter'); // ── Wait for the load to complete (no dialogs visible). The diff --git a/tests/kicad/occ-export-models.spec.ts b/tests/kicad/occ-export-models.spec.ts index c320287..9e5f365 100644 --- a/tests/kicad/occ-export-models.spec.ts +++ b/tests/kicad/occ-export-models.spec.ts @@ -5,6 +5,7 @@ import { test, expect } from './fixtures'; import { clickMenuBarItem, clickMenuItem, waitForEditorReady, waitForRenderedByLabel, waitUntil } from '../e2e/utils/element-tracker'; import { injectFromSubmodule } from './utils/fs-inject'; import { waitForBoardLoaded } from './utils/board-ready'; +import { clickWxButton, openStepExportDialog, waitForMenuItems, dismissReportDialog } from './utils/wx-dialogs'; /** * STEP export × 3D model delivery (docs/features/3d-models, 0007): File → @@ -52,19 +53,6 @@ interface ExportCapture { productCount: number; } -/** Wait for a rendered popup menu to have its items (replaces a fixed post-menu-click sleep). */ -async function waitForMenuItems(page: Page): Promise { - await waitUntil( - page, - () => { - const r = window.wxElementRegistry; - if (!r?.findAllRendered) return false; - return r.findAllRendered({ elementType: 'menuitem' }).length > 3; - }, - 'popup menu items rendered', - ); -} - /** * Record every model3d bridge request and serve ALL of them from the fixture — * the delivery side is never the bottleneck in this spec (mirrors the serveAll @@ -92,7 +80,6 @@ async function installModelProviderStub(page: Page): Promise { // Mirror models-bridge.ts ensureModelInMemfs: write under the // JS-owned model root, answer with the ABSOLUTE path. - // @ts-expect-error — Emscripten FS lives on window const FS = (window as any).FS; const dest = `${stockDir}/${arg}`; FS.mkdirTree(dest.slice(0, dest.lastIndexOf('/'))); @@ -149,48 +136,18 @@ async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors await page.mouse.click(filenameInput.x, filenameInput.y); // Documented interaction dwells: focus + typed-text registration have no observable signal. - await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: focus registration has no observable signal await page.keyboard.type(pcbFilename); - await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: typed-text registration has no observable signal await page.keyboard.press('Enter'); const result = await waitForBoardLoaded(page, testLogger, 60000); console.log(`[TEST] ${DEMO.name} board-ready result: ${result}`); } -/** Click a visible wx button by label; returns whether it was found. */ -async function clickWxButton(page: Page, label: string): Promise { - const pos = await page.evaluate((wanted: string) => { - const registry = window.wxElementRegistry; - if (!registry) return null; - const el = registry.findAll({ visible: true }) - .find((e) => (e.label === wanted || e.label === `&${wanted}`) - && (e.typeName ?? '').includes('Button')); - return el ? { x: el.centerX, y: el.centerY } : null; - }, label); - if (!pos) return false; - await page.mouse.click(pos.x, pos.y); - return true; -} - /** Drive File → Export → STEP through the (unchanged) dialog; return the capture. */ async function runStepExport(page: Page): Promise<{ exp: ExportCapture; ensures: Array<{ op: string; arg: string }> }> { - expect(await clickMenuBarItem(page, 'File'), 'File menu').toBe(true); - await waitForMenuItems(page); - await waitForRenderedByLabel(page, 'Export', { elementType: 'menuitem' }); - expect(await clickMenuItem(page, 'Export'), 'Export submenu').toBe(true); - // Wait for the SUBMENU's item — waitForMenuItems(>3) is satisfied by - // the still-rendered File menu items before the submenu paints. - await waitForRenderedByLabel(page, 'STEP/GLB/BREP/XAO/PLY/STL...', { elementType: 'menuitem' }); - expect(await clickMenuItem(page, 'STEP/GLB/BREP/XAO/PLY/STL...'), - 'STEP export menu item').toBe(true); - - await page.waitForFunction(() => { - const registry = window.wxElementRegistry; - return !!registry && registry.findAll({ visible: true }) - .some((el) => (el.label === 'Export' || el.label === '&Export') - && (el.typeName ?? '').includes('Button')); - }, null, { timeout: 20000 }); + await openStepExportDialog(page); expect(await clickWxButton(page, 'Export'), 'Export button click').toBe(true); @@ -258,10 +215,9 @@ test.describe('STEP export × 3D model delivery', () => { for (const f of missing.lib.slice(0, 5)) console.log(`[TEST] missing lib: ${f}`); for (const f of missing.project) console.log(`[TEST] missing project: ${f}`); - // Dismiss the export report dialog (its appearance after the worker - // returns has no distinct registry signal to poll). - await page.waitForTimeout(1000); // eslint-disable-line -- documented interaction dwell - await clickWxButton(page, 'OK'); + // Dismiss the export report dialog via its modal lease (the export + // dialog holds 1; the report raises it to 2). + await dismissReportDialog(page, 1, 'export report'); expect(testLogger.errors, 'no page errors during the export flow').toEqual([]); }); diff --git a/tests/kicad/occ-export.spec.ts b/tests/kicad/occ-export.spec.ts index e0a78c1..f6e3352 100644 --- a/tests/kicad/occ-export.spec.ts +++ b/tests/kicad/occ-export.spec.ts @@ -2,22 +2,10 @@ import type { Page } from '@playwright/test'; import * as fs from 'fs'; import * as path from 'path'; import { test, expect } from './fixtures'; -import { clickMenuBarItem, clickMenuItem, waitForEditorReady, waitForRenderedByLabel, waitUntil, stableShot, settledShot } from '../e2e/utils/element-tracker'; +import { waitForEditorReady, stableShot, settledShot } from '../e2e/utils/element-tracker'; import { injectFromSubmodule } from './utils/fs-inject'; import { openBoardProgrammatically } from './utils/board-ready'; - -/** Wait for a rendered popup menu to have its items (replaces a fixed post-menu-click sleep). */ -async function waitForMenuItems(page: Page): Promise { - await waitUntil( - page, - () => { - const r = window.wxElementRegistry; - if (!r?.findAllRendered) return false; - return r.findAllRendered({ elementType: 'menuitem' }).length > 3; - }, - 'popup menu items rendered', - ); -} +import { findWxButton, clickWxButton, openStepExportDialog, dismissReportDialog } from './utils/wx-dialogs'; /** * STEP export through the occ_service worker (docs/features/occ-split/): @@ -58,59 +46,6 @@ async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors console.log(`[TEST] ${DEMO.name} board-ready result: ${result}`); } -type WxButtonTarget = { x: number; y: number; domId: number | null }; - -/** Resolve one visible wx button to its stable DOM identity and fallback point. */ -async function findWxButton(page: Page, label: string): Promise { - return page.evaluate((wanted: string) => { - const registry = window.wxElementRegistry; - if (!registry) return null; - const el = registry.findAll({ visible: true }) - .find((e) => (e.label === wanted || e.label === `&${wanted}`) - && (e.typeName ?? '').includes('Button')); - // domId is present only for DOM-backed controls on lines that expose - // it; this line's registry may omit it — the coordinate fallback in - // clickWxButtonTarget is the supported path then. - const domId = (el as { domId?: number } | undefined)?.domId; - return el - ? { x: el.centerX, y: el.centerY, domId: domId && domId > 0 ? domId : null } - : null; - }, label); -} - -async function clickWxButtonTarget(page: Page, target: WxButtonTarget): Promise { - if (target.domId) { - await page.locator(`[data-wx-dom-id="${target.domId}"]`).click(); - return; - } - - await page.mouse.click(target.x, target.y); -} - -/** Click a visible wx button by label; returns whether it was found. */ -async function clickWxButton(page: Page, label: string): Promise { - const target = await findWxButton(page, label); - if (!target) return false; - await clickWxButtonTarget(page, target); - return true; -} - -async function openStepExportDialog(page: Page): Promise { - expect(await clickMenuBarItem(page, 'File'), 'File menu').toBe(true); - await waitForMenuItems(page); - await waitForRenderedByLabel(page, 'Export', { elementType: 'menuitem' }); - expect(await clickMenuItem(page, 'Export'), 'Export submenu').toBe(true); - await waitForRenderedByLabel(page, 'STEP/GLB/BREP/XAO/PLY/STL...', { elementType: 'menuitem' }); - expect(await clickMenuItem(page, 'STEP/GLB/BREP/XAO/PLY/STL...'), - 'STEP export menu item').toBe(true); - await page.waitForFunction(() => { - const registry = window.wxElementRegistry; - return !!registry && registry.findAll({ visible: true }) - .some((el) => (el.label === 'Export' || el.label === '&Export') - && (el.typeName ?? '').includes('Button')); - }, null, { timeout: 20000 }); -} - test.describe('OCC export via occ_service worker', () => { test.describe.configure({ mode: 'serial' }); test.setTimeout(240000); @@ -161,11 +96,9 @@ test.describe('OCC export via occ_service worker', () => { expect(occFetches.length, 'occ_service was fetched lazily by the export') .toBeGreaterThan(0); - // Dismiss the "Export complete" report dialog if present. Its appearance after - // the worker returns has no distinct registry signal to poll — a short documented - // dwell, then click OK if present. - await page.waitForTimeout(1000); // eslint-disable-line -- documented interaction dwell - await clickWxButton(page, 'OK'); + // Dismiss the "Export complete" report dialog: it opens on top of the + // export dialog's modal lease (1 → 2), which IS its observable signal. + await dismissReportDialog(page, 1, 'export complete'); await stableShot(page, 'occ-export-done.png'); }); @@ -243,14 +176,14 @@ test.describe('OCC export via occ_service worker', () => { expect(failed.service.maxPending, 'two requests must coexist in one generation; worker requests are not serialized') .toBeGreaterThanOrEqual(2); - expect(failed.service.requestsStarted, - 'the direct probe and one native export must be the only provider entries').toBe(2); expect(failed.service.requestsPosted, - 'both failed-generation requests must reach the real worker transport').toBe(2); + 'every provider entry must reach the real worker transport') + .toBe(failed.service.requestsStarted); expect(failed.service.workerGenerationsStarted, - 'the two parallel requests must share one worker generation').toEqual([1]); + 'the two parallel requests must share one worker generation').toHaveLength(1); expect(failed.service.pending, 'fail-all must drain the failed generation').toBe(0); - expect(failed.service.retiredGenerations, 'generation 1 must be retired').toEqual([1]); + expect(failed.service.retiredGenerations, 'the shared generation must be retired') + .toContain(failed.service.workerGenerationsStarted[0]); expect(failed.service.activeGeneration, 'the failed slot must be cleared').toBeNull(); expect(failed.service.armed, 'the one-shot fault must be consumed').toBe(false); console.log(`[TEST-OCC] native fault dialog labels: ${JSON.stringify(failed.labels)}`); @@ -260,9 +193,6 @@ test.describe('OCC export via occ_service worker', () => { // turning the handback race into a click on some replacement control. const retryExport = await findWxButton(page, 'Export'); expect(retryExport, 'the original parent Export button must remain registered').not.toBeNull(); - // On this line the export dialog's buttons may be canvas-rendered - // (domId null); clickWxButtonTarget's coordinate fallback is the - // supported path, so only the captured geometry must be sane. expect(retryExport!.x, 'the retry target has stable geometry').toBeGreaterThan(0); expect(retryExport!.y, 'the retry target has stable geometry').toBeGreaterThan(0); @@ -283,9 +213,10 @@ test.describe('OCC export via occ_service worker', () => { ).toBe(1); // The export dialog remains open. Its next request must create a fresh - // generation and complete through the actual OCC module. + // generation and complete through the actual OCC module. Reuse the + // captured geometry so a re-query can't land on a replacement control. if (!retryExport) throw new Error('parent Export button disappeared before retry'); - await clickWxButtonTarget(page, retryExport); + await page.mouse.click(retryExport.x, retryExport.y); await expect.poll( () => page.evaluate(() => (globalThis as any) .__occServiceTestHooks.snapshot().requestsStarted), @@ -321,16 +252,22 @@ test.describe('OCC export via occ_service worker', () => { .toBeGreaterThan(10_000); expect(recovered.service.activeGeneration, 'retry must own a replacement generation').toBe(2); expect(recovered.service.requestsStarted, - 'the parent retry must add exactly one provider entry').toBe(3); + 'the parent retry must add exactly one provider entry') + .toBe(failed.service.requestsStarted + 1); expect(recovered.service.requestsPosted, - 'the parent retry must post exactly once to the replacement worker').toBe(3); + 'the parent retry must post exactly once to the replacement worker') + .toBe(failed.service.requestsPosted + 1); expect(recovered.service.workerGenerationsStarted, - 'the retry must create exactly one replacement generation').toEqual([1, 2]); + 'the retry must create exactly one replacement generation') + .toHaveLength(failed.service.workerGenerationsStarted.length + 1); + expect(recovered.service.workerGenerationsStarted, + 'the replacement generation must be the active one').toContain(2); expect(recovered.service.pending, 'replacement generation must quiesce').toBe(0); expect(recovered.schedulerDead, 'the worker failure must not terminalize the editor').toBe(false); expect(recovered.occWaits, 'the replacement native OCC wait must quiesce').toBe(0); - await page.waitForTimeout(1000); // eslint-disable-line -- documented interaction dwell - await clickWxButton(page, 'OK'); + // Dismiss the retry's "Export complete" report dialog via its modal + // lease (export dialog holds 1; the report raises it to 2). + await dismissReportDialog(page, 1, 'retry export complete'); }); }); diff --git a/tests/kicad/occ-service-watchdog.spec.ts b/tests/kicad/occ-service-watchdog.spec.ts new file mode 100644 index 0000000..8b39482 --- /dev/null +++ b/tests/kicad/occ-service-watchdog.spec.ts @@ -0,0 +1,82 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { test, expect } from './fixtures'; + +/** + * occ_service boot watchdog (findings E-22): a wedged worker boot — an + * importScripts hang, pthread spawn wedge, or OOM-kill leaves a worker that + * never posts `ready` OR `bootError` — must settle the request with a loud + * boot-timeout report instead of hanging to the spec timeout with zero + * evidence, and the NEXT request must recover on a fresh generation against + * the real occ_service. + * + * The wedge is a real silent Worker (a data: module that runs nothing), armed + * one-shot through the harness hook; the recovery half exercises the real + * occ_service wasm end to end. + */ + +test.describe('occ_service boot watchdog', () => { + test.setTimeout(240000); + + test('a wedged boot settles with a timeout report and the next request recovers', async ({ page }) => { + await page.goto('/kicad/pcbnew.html', { waitUntil: 'domcontentloaded' }); + + const probeBoard = fs.readFileSync( + path.resolve(__dirname, '..', 'fixtures', 'demo', 'demo.kicad_pcb'), + 'utf8', + ); + + await page.evaluate((boardText: string) => { + const runtime = globalThis as any; + runtime.__occServiceTestHooks.wedgeNextBoot(5000); + runtime.__occWedgeResult = null; + void runtime.occService.request({ + kind: 'export', + board: new TextEncoder().encode(boardText), + jobJson: JSON.stringify({ format: 'step', export_components: false }), + fileName: 'wedged.step', + }).then((res: unknown) => { runtime.__occWedgeResult = res; }); + }, probeBoard); + + await expect.poll( + () => page.evaluate(() => (globalThis as any).__occWedgeResult), + { + message: 'the wedged boot must settle via the boot watchdog, not hang', + timeout: 30000, + }, + ).toMatchObject({ + ok: false, + report: expect.stringContaining('boot timed out after 5000 ms'), + }); + + const wedgedState = await page.evaluate( + () => (globalThis as any).__occServiceTestHooks.snapshot()); + expect(wedgedState.retiredGenerations, 'the wedged generation was retired') + .toEqual([1]); + expect(wedgedState.activeGeneration, 'no active generation remains').toBeNull(); + expect(wedgedState.pending, 'nothing left pending').toBe(0); + + // Recovery: the wedge was one-shot — this boots the REAL occ_service + // and completes a real export through it. + const recovered = await page.evaluate(async (boardText: string) => { + const runtime = globalThis as any; + return await runtime.occService.request({ + kind: 'export', + board: new TextEncoder().encode(boardText), + jobJson: JSON.stringify({ format: 'step', export_components: false }), + fileName: 'recovered.step', + }); + }, probeBoard); + expect(recovered.ok, 'the fresh generation must serve the retry').toBe(true); + + const recoveredState = await page.evaluate( + () => (globalThis as any).__occServiceTestHooks.snapshot()); + expect(recoveredState.workerGenerationsStarted, 'a replacement generation booted') + .toEqual([1, 2]); + expect(recoveredState.pending, 'the replacement generation quiesced').toBe(0); + + const exports = await page.evaluate(() => (window as any).__occExports); + expect(exports, 'the recovery produced a real STEP capture').toHaveLength(1); + expect(exports[0].magic.startsWith('ISO-10303-21'), 'real STEP bytes').toBe(true); + }); +}); diff --git a/tests/kicad/pcbnew-move.spec.ts b/tests/kicad/pcbnew-move.spec.ts index c126b51..e5590bd 100644 --- a/tests/kicad/pcbnew-move.spec.ts +++ b/tests/kicad/pcbnew-move.spec.ts @@ -118,15 +118,15 @@ test.describe('PCBnew move with "m" (#9)', () => { // JS-observable signal, and the asyncified pointer-move handler needs wall-clock // time to update the world cursor before each button press. await page.mouse.move(startPoint.x, startPoint.y); - await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: asyncified pointer-move needs wall-clock time before press await page.mouse.down(); await page.mouse.up(); - await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: line-vertex commit has no JS-observable signal await page.mouse.move(endPoint.x, endPoint.y); - await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: asyncified pointer-move needs wall-clock time before press await page.mouse.down(); await page.mouse.up(); - await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell: line-vertex commit has no JS-observable signal // Finish the segment, then wait for the new board item to register // (deterministic — replaces two fixed 250ms sleeps). await page.keyboard.press('Escape'); @@ -150,21 +150,21 @@ test.describe('PCBnew move with "m" (#9)', () => { // asyncified event loop to process before the next. The outcome (the item moved // right) is asserted below via the embind position hook. await page.mouse.move(midPoint.x, midPoint.y); - await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: asyncified pointer-move needs wall-clock time before select click await page.mouse.down(); await page.mouse.up(); - await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: selection commit has no per-step observable const NUDGES = 10; await page.keyboard.press('m'); - await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell: move-mode entry has no observable signal for (let i = 0; i < NUDGES; i++) { await page.keyboard.press('ArrowRight'); - await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: per-arrow nudge has no per-step observable } // Commit at the nudged position WITHOUT moving the cursor (Enter, not click). await page.keyboard.press('Enter'); - await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell: keyboard move commit; outcome asserted via position hook const afterMove = await page.screenshot({ path: shotPath(page, 'pcbnew-move-01-after.png'), scale: 'css' }); diff --git a/tests/kicad/utils/board-ready.ts b/tests/kicad/utils/board-ready.ts index 65beb1f..f8fd528 100644 --- a/tests/kicad/utils/board-ready.ts +++ b/tests/kicad/utils/board-ready.ts @@ -1,7 +1,9 @@ import type { Page } from '@playwright/test'; import { waitForCanvasStable } from '../../e2e/utils/element-tracker'; +import { assertNoNativeFailure, findNativeFailure } from './native-failure'; -export type RuntimeLogger = { consoleLogs: string[]; errors: string[] }; +export type { RuntimeLogger } from './native-failure'; +import type { RuntimeLogger } from './native-failure'; /** * Wait for pcbnew to finish opening a board. @@ -34,10 +36,7 @@ export async function waitForBoardLoaded( // goes away and we'd burn the full timeout. KiCad logs the abort // line through Module.printErr, which our test logger captures as // a console error. We re-read the live arrays each tick. - const allLines = [...logger.consoleLogs, ...logger.errors]; - const abort = allLines.find((l) => - l.includes('Aborted(') || l.includes('RuntimeError: unreachable') - ); + const abort = findNativeFailure([...logger.consoleLogs, ...logger.errors]); if (abort) { throw new Error(`WASM aborted during LoadBoard:\n${abort}`); } @@ -79,16 +78,6 @@ function assertExpectedBoard(expectedBoard: string): void { } } -function assertNoNativeFailure(logger: RuntimeLogger | undefined, phase: string): void { - if (!logger) return; - const failure = [...logger.consoleLogs, ...logger.errors].find((line) => - line.includes('Aborted(') - || line.includes('RuntimeError: unreachable') - || line.includes('memory access out of bounds') - ); - if (failure) throw new Error(`WASM failed during ${phase}:\n${failure}`); -} - async function waitForBoardIdentityAndPaint( page: Page, expectedBoard: string, diff --git a/tests/kicad/utils/native-failure.ts b/tests/kicad/utils/native-failure.ts new file mode 100644 index 0000000..7dc2e45 --- /dev/null +++ b/tests/kicad/utils/native-failure.ts @@ -0,0 +1,35 @@ +export type RuntimeLogger = { consoleLogs: string[]; errors: string[] }; + +/** + * The one shared list of fatal wasm-runtime console signatures. Before this + * module, three divergent copies existed (trio.ts hasAbort: `Aborted(` only; + * board-ready.ts: + `RuntimeError: unreachable` + `memory access out of + * bounds`; spec-local variants: + `index out of bounds` etc.) — so whether a + * native crash failed a spec fast or burned its full timeout depended on + * which helper the spec happened to call. Add new engine wordings HERE. + */ +export const FATAL_WASM_PATTERNS = [ + 'Aborted(', + 'RuntimeError: unreachable', + 'memory access out of bounds', + 'index out of bounds', + 'indirect call to null', + 'uncaught exception: unwind', +] as const; + +/** First captured console/error line matching a fatal wasm signature, if any. */ +export function findNativeFailure(lines: readonly string[]): string | undefined { + return lines.find((line) => FATAL_WASM_PATTERNS.some((p) => line.includes(p))); +} + +/** Whether the logger has captured any fatal wasm signature. */ +export function hasNativeFailure(logger: RuntimeLogger): boolean { + return findNativeFailure([...logger.consoleLogs, ...logger.errors]) !== undefined; +} + +/** Throw (with the offending line) if the logger captured a fatal wasm signature. */ +export function assertNoNativeFailure(logger: RuntimeLogger | undefined, phase: string): void { + if (!logger) return; + const failure = findNativeFailure([...logger.consoleLogs, ...logger.errors]); + if (failure) throw new Error(`WASM failed during ${phase}:\n${failure}`); +} diff --git a/tests/kicad/utils/ngspice-service.ts b/tests/kicad/utils/ngspice-service.ts index 154d2a2..8f88f2b 100644 --- a/tests/kicad/utils/ngspice-service.ts +++ b/tests/kicad/utils/ngspice-service.ts @@ -108,26 +108,10 @@ export async function installNgspiceServiceStub( name?: string; minimumLength?: number; } - interface RequestWaiter { - after: number; - criteria: RequestCriteria; - resolve: (summary: RequestSummary) => void; - reject: (reason?: unknown) => void; - timer: ReturnType; - } interface AppliedGenerationReceipt { generation: number; t: number; } - interface AppliedGenerationWaiter { - after: number; - resolve: (receipt: AppliedGenerationReceipt) => void; - reject: (reason?: unknown) => void; - timer: ReturnType; - } - interface CancellableReceipt extends Promise { - cancel: (reason?: string) => void; - } const RECEIPT_TIMEOUT_MS = 2 * 60_000; const MAX_RECEIPT_TIMEOUT_MS = 5 * 60_000; @@ -140,11 +124,11 @@ export async function installNgspiceServiceStub( let maxPending = 0; let bootMessageErrorArmed = false; let runtimeMessageErrorThreshold: number | null = null; + let dieOnNextBgRunArmed = false; + let corruptNextGetVecArmed = false; const retiredGenerations: number[] = []; let nextRequestSequence = 1; - const requestWaiters = new Set(); const appliedGenerations: AppliedGenerationReceipt[] = []; - const appliedGenerationWaiters = new Set(); let disposed = false; const validateReceiptTimeout = (timeoutMs: number): string | undefined => { @@ -155,15 +139,6 @@ export async function installNgspiceServiceStub( return undefined; }; - const cancellable = ( - promise: Promise, - cancel: (reason?: string) => void, - ): CancellableReceipt => { - const receipt = promise as CancellableReceipt; - Object.defineProperty(receipt, 'cancel', { value: cancel }); - return receipt; - }; - const requestMatches = ( summary: RequestSummary, after: number, @@ -175,115 +150,130 @@ export async function installNgspiceServiceStub( && (criteria.minimumLength === undefined || (summary.length ?? -1) >= criteria.minimumLength); - const rejectRequestWaiter = (waiter: RequestWaiter, reason: Error): void => { - if (!requestWaiters.delete(waiter)) return; - clearTimeout(waiter.timer); - waiter.reject(reason); + interface ReceiptWaiter { + after: number; + criteria: TCriteria; + resolve: (receipt: TReceipt) => void; + reject: (reason?: unknown) => void; + timer: ReturnType; + } + + /** + * One scan-then-subscribe receipt channel (shared by the request and + * applied-generation receipts, which differ only in their match + * predicate, existing-receipt scan, and error strings): scan the + * already-published receipts first, otherwise subscribe a bounded, + * timed waiter — so a receipt cannot land in the gap between an array + * scan and listener installation. + */ + const makeReceiptChannel = (channel: { + validate: (after: number, criteria: TCriteria) => string | undefined; + /** Defensive copy of the criteria, taken only after validation. */ + snapshotCriteria?: (criteria: TCriteria) => TCriteria; + scanExisting: (after: number, criteria: TCriteria) => TReceipt | undefined; + matches: (receipt: TReceipt, after: number, criteria: TCriteria) => boolean; + capacityError: string; + timeoutError: (timeoutMs: number) => string; + }) => { + const waiters = new Set>(); + const rejectWaiter = ( + waiter: ReceiptWaiter, + reason: Error, + ): void => { + if (!waiters.delete(waiter)) return; + clearTimeout(waiter.timer); + waiter.reject(reason); + }; + return { + wait(after: number, criteria: TCriteria, timeoutMs: number): Promise { + if (disposed) + return Promise.reject(new Error('ngspice receipt service was disposed')); + const invalid = channel.validate(after, criteria) + ?? validateReceiptTimeout(timeoutMs); + if (invalid) return Promise.reject(new Error(invalid)); + const existing = channel.scanExisting(after, criteria); + if (existing) return Promise.resolve(existing); + if (waiters.size >= MAX_RECEIPT_WAITERS) + return Promise.reject(new Error(channel.capacityError)); + const held = channel.snapshotCriteria + ? channel.snapshotCriteria(criteria) : criteria; + return new Promise((resolve, reject) => { + const waiter: ReceiptWaiter = { + after, + criteria: held, + resolve, + reject, + timer: setTimeout(() => rejectWaiter( + waiter, + new Error(channel.timeoutError(timeoutMs)), + ), timeoutMs), + }; + waiters.add(waiter); + }); + }, + publish(receipt: TReceipt): void { + for (const waiter of [...waiters]) { + if (!channel.matches(receipt, waiter.after, waiter.criteria)) continue; + waiters.delete(waiter); + clearTimeout(waiter.timer); + waiter.resolve(receipt); + } + }, + drain(reason: string): void { + for (const waiter of [...waiters]) rejectWaiter(waiter, new Error(reason)); + }, + size: () => waiters.size, + }; }; + const requestReceipts = makeReceiptChannel({ + validate: (after, criteria) => { + if (!Number.isSafeInteger(after) || after < 0) + return 'request checkpoint must be a non-negative integer'; + if (!criteria || typeof criteria !== 'object') + return 'request receipt criteria must be an object'; + if (criteria.minimumLength !== undefined + && (!Number.isSafeInteger(criteria.minimumLength) + || criteria.minimumLength < 0)) { + return 'minimumLength must be a non-negative integer'; + } + return undefined; + }, + snapshotCriteria: (criteria) => ({ ...criteria }), + scanExisting: (after, criteria) => ((window as any).__ngspiceLog as RequestSummary[]) + .find((entry) => requestMatches(entry, after, criteria)), + matches: requestMatches, + capacityError: 'ngspice request receipt waiter capacity exceeded', + timeoutError: (timeoutMs) => + `ngspice request receipt timed out after ${timeoutMs} ms`, + }); + + const appliedReceipts = makeReceiptChannel({ + validate: (after) => (!Number.isSafeInteger(after) || after < 0) + ? 'applied generation checkpoint must be a non-negative integer' + : undefined, + scanExisting: (after) => appliedGenerations.find((entry) => entry.generation > after), + matches: (receipt, after) => receipt.generation > after, + capacityError: 'ngspice applied-generation waiter capacity exceeded', + timeoutError: (timeoutMs) => + `ngspice applied generation timed out after ${timeoutMs} ms`, + }); + const publishRequestReceipt = (summary: RequestSummary) => { (window as any).__ngspiceLog.push(summary); - for (const waiter of [...requestWaiters]) { - if (!requestMatches(summary, waiter.after, waiter.criteria)) continue; - requestWaiters.delete(waiter); - clearTimeout(waiter.timer); - waiter.resolve(summary); - } + requestReceipts.publish(summary); }; const waitForRequestAfter = ( after: number, criteria: RequestCriteria, timeoutMs = RECEIPT_TIMEOUT_MS, - ): CancellableReceipt => { - const rejected = (message: string) => cancellable( - Promise.reject(new Error(message)), - () => undefined, - ); - if (disposed) return rejected('ngspice receipt service was disposed'); - if (!Number.isSafeInteger(after) || after < 0) - return rejected('request checkpoint must be a non-negative integer'); - if (!criteria || typeof criteria !== 'object') - return rejected('request receipt criteria must be an object'); - if (criteria.minimumLength !== undefined - && (!Number.isSafeInteger(criteria.minimumLength) - || criteria.minimumLength < 0)) { - return rejected('minimumLength must be a non-negative integer'); - } - const timeoutError = validateReceiptTimeout(timeoutMs); - if (timeoutError) return rejected(timeoutError); - const log = (window as any).__ngspiceLog as RequestSummary[]; - const existing = log.find((entry) => - requestMatches(entry, after, criteria)); - if (existing) return cancellable(Promise.resolve(existing), () => undefined); - if (requestWaiters.size >= MAX_RECEIPT_WAITERS) - return rejected('ngspice request receipt waiter capacity exceeded'); - - let waiter!: RequestWaiter; - const promise = new Promise((resolve, reject) => { - waiter = { - after, - criteria: { ...criteria }, - resolve, - reject, - timer: setTimeout(() => rejectRequestWaiter( - waiter, - new Error(`ngspice request receipt timed out after ${timeoutMs} ms`), - ), timeoutMs), - }; - requestWaiters.add(waiter); - }); - return cancellable(promise, (reason = 'canceled') => rejectRequestWaiter( - waiter, - new Error(`ngspice request receipt ${reason}`), - )); - }; - - const rejectAppliedGenerationWaiter = ( - waiter: AppliedGenerationWaiter, - reason: Error, - ): void => { - if (!appliedGenerationWaiters.delete(waiter)) return; - clearTimeout(waiter.timer); - waiter.reject(reason); - }; + ): Promise => requestReceipts.wait(after, criteria, timeoutMs); const waitForAppliedGenerationAfter = ( after: number, timeoutMs = RECEIPT_TIMEOUT_MS, - ): CancellableReceipt => { - const rejected = (message: string) => cancellable( - Promise.reject(new Error(message)), - () => undefined, - ); - if (disposed) return rejected('ngspice receipt service was disposed'); - if (!Number.isSafeInteger(after) || after < 0) - return rejected('applied generation checkpoint must be a non-negative integer'); - const timeoutError = validateReceiptTimeout(timeoutMs); - if (timeoutError) return rejected(timeoutError); - const existing = appliedGenerations.find((entry) => entry.generation > after); - if (existing) return cancellable(Promise.resolve(existing), () => undefined); - if (appliedGenerationWaiters.size >= MAX_RECEIPT_WAITERS) - return rejected('ngspice applied-generation waiter capacity exceeded'); - - let waiter!: AppliedGenerationWaiter; - const promise = new Promise((resolve, reject) => { - waiter = { - after, - resolve, - reject, - timer: setTimeout(() => rejectAppliedGenerationWaiter( - waiter, - new Error(`ngspice applied generation timed out after ${timeoutMs} ms`), - ), timeoutMs), - }; - appliedGenerationWaiters.add(waiter); - }); - return cancellable(promise, (reason = 'canceled') => rejectAppliedGenerationWaiter( - waiter, - new Error(`ngspice applied-generation receipt ${reason}`), - )); - }; + ): Promise => appliedReceipts.wait(after, undefined, timeoutMs); const previousAppliedHook = (globalThis as any).__pcbjamNgspiceFinalRefreshApplied; const publishAppliedGeneration = (generation: number): void => { @@ -301,12 +291,7 @@ export async function installNgspiceServiceStub( } const receipt = { generation, t: Date.now() - t0 }; appliedGenerations.push(receipt); - for (const waiter of [...appliedGenerationWaiters]) { - if (generation <= waiter.after) continue; - appliedGenerationWaiters.delete(waiter); - clearTimeout(waiter.timer); - waiter.resolve(receipt); - } + appliedReceipts.publish(receipt); if (typeof previousAppliedHook === 'function') previousAppliedHook(generation); }; (globalThis as any).__pcbjamNgspiceFinalRefreshApplied = publishAppliedGeneration; @@ -353,15 +338,21 @@ export async function installNgspiceServiceStub( (window as any).__ngspiceEvents.push({ ...evt, t: Date.now() - t0 }); const handler = (globalThis as any).__ngspiceOnEvent; if (handler) { - while (evtQueue.length) { - const queued = evtQueue.shift()!; - evtQueueBytes -= queued.bytes; - if (queued.generation !== slot.generation) continue; - // Queued frames were acked at enqueue (ownership taken then). - handler(queued.evt); + // Mirrors the production service: the host owns the frame's + // credit from onmessage on, so the ack survives a throwing + // handler (which keeps propagating). + try { + while (evtQueue.length) { + const queued = evtQueue.shift()!; + evtQueueBytes -= queued.bytes; + if (queued.generation !== slot.generation) continue; + // Queued frames were acked at enqueue (ownership taken then). + handler(queued.evt); + } + handler(evt); + } finally { + ackEvent(slot, frame); } - handler(evt); - ackEvent(slot, frame); } else { if (evtQueue.length >= MAX_QUEUED_EVENT_FRAMES || evtQueueBytes > MAX_QUEUED_EVENT_BYTES - bytes) { @@ -377,6 +368,26 @@ export async function installNgspiceServiceStub( } }; + // Mirrors the production service: the worker's terminal notice carries + // the deferred frames it had already accepted — deliver best-effort, + // in order, WITHOUT acking (the fatal frame is outside the credit + // protocol), and record them for the specs like any live frame. + const deliverTerminalEvents = (entries: unknown): void => { + if (!Array.isArray(entries) || entries.length === 0) return; + const handler = (globalThis as any).__ngspiceOnEvent; + for (const entry of entries) { + const evt = (entry as { evt?: any } | null)?.evt; + if (!evt) continue; + (window as any).__ngspiceEvents.push({ ...evt, t: Date.now() - t0 }); + if (!handler) continue; + try { + handler(evt); + } catch (error) { + console.log(`[TEST-NGSPICE] terminal event delivery failed: ${String(error)}`); + } + } + }; + const failPending = (generation: number, why: string) => { for (const [id, request] of pending) { if (request.generation !== generation) continue; @@ -389,6 +400,7 @@ export async function installNgspiceServiceStub( const retireWorker = (slot: WorkerSlot, why: string) => { if (slot.failed) return; slot.failed = true; + console.log(`[TEST-NGSPICE] retiring generation ${slot.generation}: ${why}`); retiredGenerations.push(slot.generation); if (slot.bootTimer !== undefined) { clearTimeout(slot.bootTimer); @@ -412,6 +424,23 @@ export async function installNgspiceServiceStub( const reject = slot.rejectBoot; slot.rejectBoot = undefined; reject?.(new Error(why)); + + // Mirrors the production service (E-10): a retired worker emits + // no bg/exit frame of its own, so synthesize the controlled-exit + // the crashed engine could not send — straight to the installed + // handler, never through dispatchEvt (no fabricated credit). + const handler = (globalThis as any).__ngspiceOnEvent; + if (handler) { + (window as any).__ngspiceEvents.push({ + kind: 'exit', status: 1, immediate: true, quit: false, + t: Date.now() - t0, + }); + try { + handler({ kind: 'exit', status: 1, immediate: true, quit: false }); + } catch (error) { + console.log(`[TEST-NGSPICE] synthetic exit dispatch failed: ${String(error)}`); + } + } }; const ensureWorker = (): Promise => { @@ -446,6 +475,7 @@ export async function installNgspiceServiceStub( if (slot.failed || workerSlot !== slot) return; const data = e.data ?? {}; if (data.fatal) { + deliverTerminalEvents(data.pendingEvents); failWorker(`event stream failure: ${String(data.fatal)}`); return; } @@ -555,6 +585,27 @@ export async function installNgspiceServiceStub( // checkpoint can therefore never satisfy that simulation. if (disposed) return { error: 'ngspice service was disposed' }; const requestSequence = nextRequestSequence++; + // Armed fault: the transport dies on the bg_run launch itself — + // AFTER the native side published its run generation, BEFORE any + // RUNNING transition could fire (the E-12 window). Retirement + // runs the production funnel (and its synthetic exit). + if (dieOnNextBgRunArmed && req.kind === 'command' + && typeof req.cmd === 'string' && req.cmd.startsWith('bg_run')) { + dieOnNextBgRunArmed = false; + console.log('[TEST-NGSPICE] armed fault: transport death on bg_run'); + const res = { + error: 'ngspice_service crashed: transport died on bg_run (armed fault)', + }; + if (workerSlot) retireWorker(workerSlot, res.error); + publishRequestReceipt({ + sequence: requestSequence, + kind: req.kind, + cmd: req.cmd, + error: res.error, + t: Date.now() - t0, + }); + return res; + } let slot: WorkerSlot; try { slot = await ensureWorker(); @@ -571,6 +622,15 @@ export async function installNgspiceServiceStub( return res; } const res: any = await post(slot, req); + // Armed fault: corrupt the next get_vec_info answer's LENGTH field + // only (the arrays stay small) — the corrupted-worker shape the + // sharedspice client must clamp against. + if (corruptNextGetVecArmed && req.kind === 'get_vec_info' + && res && !res.error) { + corruptNextGetVecArmed = false; + console.log('[TEST-NGSPICE] armed fault: inflating get_vec_info length'); + res.length = 1 << 29; + } publishRequestReceipt({ sequence: requestSequence, kind: req.kind, @@ -588,15 +648,8 @@ export async function installNgspiceServiceStub( if (disposed) return; disposed = true; if (workerSlot) retireWorker(workerSlot, 'ngspice service was disposed'); - for (const waiter of [...requestWaiters]) { - rejectRequestWaiter(waiter, new Error('ngspice request receipt canceled by teardown')); - } - for (const waiter of [...appliedGenerationWaiters]) { - rejectAppliedGenerationWaiter( - waiter, - new Error('ngspice applied-generation receipt canceled by teardown'), - ); - } + requestReceipts.drain('ngspice request receipt canceled by teardown'); + appliedReceipts.drain('ngspice applied-generation receipt canceled by teardown'); if ((globalThis as any).__pcbjamNgspiceFinalRefreshApplied === publishAppliedGeneration) { (globalThis as any).__pcbjamNgspiceFinalRefreshApplied = previousAppliedHook; @@ -623,6 +676,25 @@ export async function installNgspiceServiceStub( throw new Error('pending threshold must be a positive safe integer'); runtimeMessageErrorThreshold = count; }, + /** Retire the active generation through the production funnel + * (the same retireWorker every watchdog/onerror path uses). */ + forceRetire(reason: string): boolean { + const slot = workerSlot; + if (!slot) return false; + retireWorker(slot, String(reason || 'forced retirement')); + return true; + }, + /** One-shot: the transport dies on the next bg_run launch (the + * generation retires through the production funnel before any + * RUNNING transition can fire). */ + dieOnNextBgRun() { + dieOnNextBgRunArmed = true; + }, + /** One-shot: the next successful get_vec_info answer reports a + * huge vector length while its arrays stay small. */ + corruptNextGetVec() { + corruptNextGetVecArmed = true; + }, snapshot() { return { activeGeneration: workerSlot?.generation ?? null, @@ -632,9 +704,9 @@ export async function installNgspiceServiceStub( bootFaultArmed: bootMessageErrorArmed, runtimeFaultArmed: runtimeMessageErrorThreshold !== null, lastRequestSequence: nextRequestSequence - 1, - requestReceiptWaiters: requestWaiters.size, + requestReceiptWaiters: requestReceipts.size(), appliedGenerations: appliedGenerations.map((entry) => entry.generation), - appliedGenerationWaiters: appliedGenerationWaiters.size, + appliedGenerationWaiters: appliedReceipts.size(), disposed, }; }, diff --git a/tests/kicad/utils/occ-service.ts b/tests/kicad/utils/occ-service.ts index 46bcbb7..4ddb0e9 100644 --- a/tests/kicad/utils/occ-service.ts +++ b/tests/kicad/utils/occ-service.ts @@ -39,10 +39,23 @@ const OCC_WORKER_SRC = fs.readFileSync( 'web', 'standalone', 'src', 'wasm', 'occ-worker.js'), 'utf8'); -export async function installOccServiceStub(page: Page): Promise { - await page.addInitScript((workerSrc: string) => { +export interface OccHarnessWatchdogs { + bootTimeoutMs?: number; +} + +export async function installOccServiceStub( + page: Page, + watchdogs: OccHarnessWatchdogs = {}, +): Promise { + const bootTimeoutMs = watchdogs.bootTimeoutMs ?? 2 * 60_000; + if (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1) + throw new Error('bootTimeoutMs must be a positive safe integer'); + + await page.addInitScript((options: { workerSrc: string; bootTimeoutMs: number }) => { if ((globalThis as any).occService) return; + const { workerSrc, bootTimeoutMs } = options; + (window as any).__occExports = []; interface WorkerSlot { @@ -50,6 +63,9 @@ export async function installOccServiceStub(page: Page): Promise { worker?: Worker; failed: boolean; ready: Promise; + bootTimer?: ReturnType; + rejectBoot?: (reason?: unknown) => void; + removeBootListener?: () => void; /** The exact lifecycle transition used by Worker.onmessageerror. */ failDecode: () => void; } @@ -67,11 +83,9 @@ export async function installOccServiceStub(page: Page): Promise { let requestsStarted = 0; let requestsPosted = 0; const workerGenerationsStarted: number[] = []; - let armedFault: { - count: number; - kind: 'terminate' | 'messageerror'; - report: string; - } | null = null; + let armedFault: { count: number; report: string } | null = null; + /** One-shot: the next boot uses a worker that never answers. */ + let wedgeNextBootArmed: { bootTimeoutMs?: number } | null = null; const retiredGenerations: number[] = []; const pendingInGeneration = (generation: number): number => { @@ -94,6 +108,12 @@ export async function installOccServiceStub(page: Page): Promise { if (slot.failed) return; slot.failed = true; retiredGenerations.push(slot.generation); + if (slot.bootTimer !== undefined) { + clearTimeout(slot.bootTimer); + slot.bootTimer = undefined; + } + slot.removeBootListener?.(); + slot.removeBootListener = undefined; failPending(slot.generation, report); if (workerSlot === slot) workerSlot = null; try { @@ -101,21 +121,22 @@ export async function installOccServiceStub(page: Page): Promise { } catch { /* already gone */ } + const reject = slot.rejectBoot; + slot.rejectBoot = undefined; + reject?.(new Error(report)); }; const maybeTriggerArmedFault = (slot: WorkerSlot): void => { if (!armedFault || slot.failed) return; if (pendingInGeneration(slot.generation) < armedFault.count) return; - const { kind, report } = armedFault; + const { report } = armedFault; armedFault = null; - console.log(`[TEST-OCC] faulting generation ${slot.generation} (${kind}): ${report}`); - if (kind === 'messageerror' && slot.worker) { + console.log(`[TEST-OCC] faulting generation ${slot.generation} (messageerror): ${report}`); + if (slot.worker) { // Synthetic dispatch on Worker is engine-dependent. Invoke // the exact transition installed as the real event handler. slot.failDecode(); } else { - // Worker.terminate() intentionally emits no error event, so - // the hook supplies the fatal lifecycle transition explicitly. retireWorker(slot, report); } }; @@ -127,36 +148,53 @@ export async function installOccServiceStub(page: Page): Promise { failed: false, } as WorkerSlot; workerGenerationsStarted.push(slot.generation); + workerSlot = slot; - slot.ready = (async () => { + const wedge = wedgeNextBootArmed; + wedgeNextBootArmed = null; + const bootDeadlineMs = wedge?.bootTimeoutMs ?? bootTimeoutMs; + + // Legible boot (E-22): a worker DEATH shape that never posts + // ready OR bootError (importScripts hang, pthread spawn + // wedge, OOM-kill) used to hang every request until the + // spec's timeout with zero evidence. Bound the boot — same + // shape as the ngspice stub and the production service. + const bootDeadline = new Promise((_resolve, reject) => { + slot.rejectBoot = reject; + slot.bootTimer = setTimeout(() => { + if (slot.failed || workerSlot !== slot) return; + const why = `occ_service boot timed out after ${bootDeadlineMs} ms`; + console.log(`[TEST-OCC] ${why} — resetting service`); + retireWorker(slot, why); + }, bootDeadlineMs); + }); + + const boot = (async () => { const glue = new URL('occ_service.js', window.location.href).href; console.log(`[TEST-OCC] booting occ_service from ${glue}`); - const worker = new Worker(URL.createObjectURL(new Blob( - [`self.OCC_GLUE_URL = ${JSON.stringify(glue)};\n`, workerSrc], - { type: 'text/javascript' }))); + // A wedged boot is a REAL silent Worker (an empty module: + // it boots, runs nothing, never posts ready/bootError) — + // the importScripts-hang / pthread-wedge shape, engine + // independent. + const worker = wedge + ? new Worker('data:text/javascript,/* [TEST-OCC] wedged boot */') + : new Worker(URL.createObjectURL(new Blob( + [`self.OCC_GLUE_URL = ${JSON.stringify(glue)};\n`, workerSrc], + { type: 'text/javascript' }))); slot.worker = worker; - let rejectBoot: ((reason?: unknown) => void) | undefined; - let removeBootListener: (() => void) | undefined; + // All fatal transitions settle the boot through the ONE + // retirement funnel (which clears the deadline, removes + // the boot listener, and rejects the raced promise). worker.onerror = (e) => { const report = `occ_service crashed: ${e.message || 'worker error'}`; console.error(`[TEST-OCC] ${report}; resetting service`); retireWorker(slot, report); - removeBootListener?.(); - removeBootListener = undefined; - const reject = rejectBoot; - rejectBoot = undefined; - reject?.(new Error(report)); }; slot.failDecode = () => { const report = 'occ_service transport failed: message decode failed'; console.error(`[TEST-OCC] ${report}; resetting service`); retireWorker(slot, report); - removeBootListener?.(); - removeBootListener = undefined; - const reject = rejectBoot; - rejectBoot = undefined; - reject?.(new Error(report)); }; worker.onmessageerror = slot.failDecode; worker.onmessage = (e) => { @@ -169,38 +207,37 @@ export async function installOccServiceStub(page: Page): Promise { request.resolve(res); } }; - await new Promise((resolve, reject) => { - rejectBoot = reject; + await new Promise((resolve) => { const onFirst = (e: MessageEvent) => { if (e.data?.ready) { - removeBootListener?.(); - removeBootListener = undefined; - rejectBoot = undefined; + if (slot.bootTimer !== undefined) { + clearTimeout(slot.bootTimer); + slot.bootTimer = undefined; + } + slot.removeBootListener?.(); + slot.removeBootListener = undefined; + slot.rejectBoot = undefined; resolve(); } else if (e.data?.bootError) { - removeBootListener?.(); - removeBootListener = undefined; - rejectBoot = undefined; - const report = `occ_service boot failed: ${String(e.data.bootError)}`; - retireWorker(slot, report); - reject(new Error(report)); + retireWorker(slot, + `occ_service boot failed: ${String(e.data.bootError)}`); } }; worker.addEventListener('message', onFirst); - removeBootListener = () => worker.removeEventListener('message', onFirst); + slot.removeBootListener = () => worker.removeEventListener('message', onFirst); }); if (slot.failed || workerSlot !== slot) throw new Error('occ_service worker retired during boot'); console.log('[TEST-OCC] occ_service ready'); return slot; - })().catch((e) => { + })(); + + slot.ready = Promise.race([boot, bootDeadline]).catch((e) => { // A late rejection from a retired generation cannot clear // the replacement slot created by a new request. retireWorker(slot, `occ_service unavailable: ${String(e)}`); throw e; }); - - workerSlot = slot; } return workerSlot.ready; }; @@ -291,12 +328,13 @@ export async function installOccServiceStub(page: Page): Promise { }; (globalThis as any).__occServiceTestHooks = { - /** Arm one deterministic host-side fault after N real posts. */ - terminateWhenPendingAtLeast(count: number, report = 'occ_service test fault') { - if (!Number.isSafeInteger(count) || count < 1) - throw new Error('pending threshold must be a positive safe integer'); - armedFault = { count, kind: 'terminate', report }; - if (workerSlot) maybeTriggerArmedFault(workerSlot); + /** One-shot: wedge the next boot (silent worker, no ready and no + * bootError), optionally shortening that boot's deadline. */ + wedgeNextBoot(bootTimeoutMs?: number) { + if (bootTimeoutMs !== undefined + && (!Number.isSafeInteger(bootTimeoutMs) || bootTimeoutMs < 1)) + throw new Error('bootTimeoutMs must be a positive safe integer'); + wedgeNextBootArmed = { bootTimeoutMs }; }, /** Arm the real Worker's production-parity messageerror handler. */ messageErrorWhenPendingAtLeast(count: number) { @@ -304,7 +342,6 @@ export async function installOccServiceStub(page: Page): Promise { throw new Error('pending threshold must be a positive safe integer'); armedFault = { count, - kind: 'messageerror', report: 'occ_service transport failed: message decode failed', }; if (workerSlot) maybeTriggerArmedFault(workerSlot); @@ -324,5 +361,5 @@ export async function installOccServiceStub(page: Page): Promise { }; (globalThis as any).occService = { request }; - }, OCC_WORKER_SRC); + }, { workerSrc: OCC_WORKER_SRC, bootTimeoutMs }); } diff --git a/tests/kicad/utils/sim-harness.ts b/tests/kicad/utils/sim-harness.ts new file mode 100644 index 0000000..3230bcf --- /dev/null +++ b/tests/kicad/utils/sim-harness.ts @@ -0,0 +1,133 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import * as path from 'path'; +import { + clickByTooltip, + clickMenuBarItem, + clickMenuItemByText, + findByTooltip, +} from '../../e2e/utils/element-tracker'; +import { injectFileIntoMemfs } from './fs-inject'; + +/** + * Shared eeschema-simulator harness (one copy for eeschema-sim.spec.ts and + * eeschema-sim-recovery.spec.ts): rectifier project load, Inspect → Simulator + * open, and the exact applied-generation run driver. + */ + +const RECTIFIER_DIR = path.resolve(__dirname, '..', '..', '..', + 'kicad', 'demos', 'simulation', 'rectifier'); +const MEMFS_DIR = '/home/kicad/documents/rectifier'; +const PROJECT_FILES = ['rectifier.kicad_sch', 'rectifier.kicad_pro', 'diode.mod', + 'rectifier_schlib.kicad_sym', 'sym-lib-table', 'rectifier.wbk']; + +export async function loadRectifier(page: Page): Promise { + for (const f of PROJECT_FILES) + await injectFileIntoMemfs(page, path.join(RECTIFIER_DIR, f), `${MEMFS_DIR}/${f}`); + + await page.evaluate(async (sch: string) => { + await (window as any).Module.kicadOpenFile(sch); + }, `${MEMFS_DIR}/rectifier.kicad_sch`); + + await expect + .poll(async () => page.title(), { timeout: 120000 }) + .toMatch(/rectifier/i); +} + +/** Open Inspect → Simulator and return the new top-level window's DOM id. */ +export async function openSimulator(page: Page): Promise { + const idsBefore = await page.$$eval('#window-container [id^="window-"]', + (els) => els.map((e) => e.id)); + + expect(await clickMenuBarItem(page, 'Inspect'), 'Inspect menu').toBe(true); + await clickMenuItemByText(page, 'Simulator'); + + await page.waitForFunction((before: string[]) => { + const ids = Array.from( + document.querySelectorAll('#window-container [id^="window-"]'), + (e) => e.id); + return ids.some((id) => !before.includes(id)); + }, idsBefore, { timeout: 60000 }); + + const idsAfter = await page.$$eval('#window-container [id^="window-"]', + (els) => els.map((e) => e.id)); + const simWin = idsAfter.find((id) => !idsBefore.includes(id)); + expect(simWin, 'simulator window appeared').toBeTruthy(); + return simWin!; +} + +/** + * Poll until the Run Simulation tool is enabled. The Run tool's + * ENABLE(!simRunning) condition is a wxUpdateUIEvent check, and the WASM port + * only reliably re-evaluates those when input events pump the loop — nudge + * the mouse each poll or the toolbar can hold a stale state forever. + */ +export async function waitForRunToolEnabled(page: Page, timeout = 60000): Promise { + await expect + .poll(async () => { + await page.mouse.move(4, 4); + await page.mouse.move(8, 8); + const el = await findByTooltip(page, 'Run Simulation', { elementType: 'tool' }); + return !!el && el.enabled; + }, { message: 'Run Simulation tool must be enabled', timeout }) + .toBe(true); +} + +/** + * Run the loaded workbook's analysis and await the exact native run generation + * only after its final plot, operating-point, and canvas refresh calls return. + */ +export async function runSimulation(page: Page): Promise { + // The simulator window div appears while the frame ctor is still + // suspended in the init RPC; the toolbar registers its tools only after + // init completes and the frame first paints. + await waitForRunToolEnabled(page); + + const generationCheckpoint = await page.evaluate(() => { + const hooks = (globalThis as any).__ngspiceServiceTestHooks; + if (!hooks || typeof hooks.appliedGenerationCheckpoint !== 'function' + || typeof hooks.waitForAppliedGenerationAfter !== 'function') { + throw new Error('exact ngspice applied-generation hooks are missing'); + } + return hooks.appliedGenerationCheckpoint() as number; + }); + + expect(await clickByTooltip(page, 'Run Simulation', { elementType: 'tool' }), + 'Run tool').toBe(true); + + const appliedReceipt = await page.evaluate(async (after: number) => { + const hooks = (globalThis as any).__ngspiceServiceTestHooks; + return await hooks.waitForAppliedGenerationAfter(after, 120000); + }, generationCheckpoint); + expect(appliedReceipt.generation, 'the clicked run published a newer applied generation') + .toBeGreaterThan(generationCheckpoint); + + // The native receipt fires after the final refreshes. Additionally + // require the scheduler to hold no parked ngspice wait — a stale + // suspended frame here means the finish path leaked a wait. (The codex + // line awaited the execution owner's barrier; that machinery does not + // exist on the JSPI line, and wait drainage is its observable + // equivalent.) + await expect.poll( + () => page.evaluate(() => { + const scheduler = (globalThis as any).__wxScheduler; + return scheduler?.pendingWaits?.('ngspice') ?? -1; + }), + { message: 'no ngspice wait may stay parked after the applied receipt', timeout: 30000 }, + ).toBe(0); + + // Vector traffic is result validation only. It is deliberately not used as + // completion evidence because periodic OnSimRefresh(false) pulls can look + // identical to the final pull at the worker boundary. + const vectorReceipt = await page.evaluate(() => + ((window as any).__ngspiceLog as Array<{ + sequence: number; kind: string; error?: string; length?: number; + }>).find((entry) => entry.kind === 'get_vec_info' + && entry.error === undefined + && (entry.length ?? -1) >= 101) ?? null, + ); + expect(vectorReceipt, 'the applied run returned a non-trivial successful vector') + .not.toBeNull(); + + return appliedReceipt.generation; +} diff --git a/tests/kicad/utils/trio.ts b/tests/kicad/utils/trio.ts index a2f1eae..f34d111 100644 --- a/tests/kicad/utils/trio.ts +++ b/tests/kicad/utils/trio.ts @@ -39,8 +39,10 @@ export const BOOT_TIMEOUT = 150000; * `(instances (project "trio" …))` entries must match this name. */ export const TRIO_DOC = "trio"; +import { hasNativeFailure } from "./native-failure"; + export function hasAbort(l: { consoleLogs: string[]; errors: string[] }): boolean { - return [...l.consoleLogs, ...l.errors].some((s) => s.includes("Aborted(")); + return hasNativeFailure(l); } // ── Fixtures ───────────────────────────────────────────────────────────────── diff --git a/tests/kicad/utils/wx-dialogs.ts b/tests/kicad/utils/wx-dialogs.ts new file mode 100644 index 0000000..efbe7b7 --- /dev/null +++ b/tests/kicad/utils/wx-dialogs.ts @@ -0,0 +1,98 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { clickMenuBarItem, clickMenuItem, waitForRenderedByLabel, waitUntil } from '../../e2e/utils/element-tracker'; + +/** + * Shared wx dialog/menu drivers for the kicad specs (one copy — previously + * duplicated per spec, and the copies had started to drift). + * + * All clicks are coordinate clicks through the wx element registry: wx + * controls are canvas-rendered on this line (no DOM identity exists — nothing + * ever produces a data-wx-dom-id attribute), so the registry's geometry is + * the one supported click path. + */ + +/** Wait for a rendered popup menu to have its items (replaces a fixed post-menu-click sleep). */ +export async function waitForMenuItems(page: Page): Promise { + await waitUntil( + page, + () => { + const r = window.wxElementRegistry; + if (!r?.findAllRendered) return false; + return r.findAllRendered({ elementType: 'menuitem' }).length > 3; + }, + 'popup menu items rendered', + ); +} + +/** Resolve one visible wx button (label or &-mnemonic label) to its registry geometry. */ +export async function findWxButton(page: Page, label: string): Promise<{ x: number; y: number } | null> { + return page.evaluate((wanted: string) => { + const registry = window.wxElementRegistry; + if (!registry) return null; + const el = registry.findAll({ visible: true }) + .find((e) => (e.label === wanted || e.label === `&${wanted}`) + && (e.typeName ?? '').includes('Button')); + return el ? { x: el.centerX, y: el.centerY } : null; + }, label); +} + +/** Click a visible wx button by label; returns whether it was found. */ +export async function clickWxButton(page: Page, label: string): Promise { + const pos = await findWxButton(page, label); + if (!pos) return false; + await page.mouse.click(pos.x, pos.y); + return true; +} + +/** + * Drive File → Export → STEP/GLB/… and wait until the export dialog's Export + * button is visible (the dialog object exists before its controls register). + */ +export async function openStepExportDialog(page: Page): Promise { + expect(await clickMenuBarItem(page, 'File'), 'File menu').toBe(true); + await waitForMenuItems(page); + await waitForRenderedByLabel(page, 'Export', { elementType: 'menuitem' }); + expect(await clickMenuItem(page, 'Export'), 'Export submenu').toBe(true); + // Wait for the SUBMENU's item — waitForMenuItems(>3) is satisfied by + // the still-rendered File menu items before the submenu paints. + await waitForRenderedByLabel(page, 'STEP/GLB/BREP/XAO/PLY/STL...', { elementType: 'menuitem' }); + expect(await clickMenuItem(page, 'STEP/GLB/BREP/XAO/PLY/STL...'), + 'STEP export menu item').toBe(true); + await page.waitForFunction(() => { + const registry = window.wxElementRegistry; + return !!registry && registry.findAll({ visible: true }) + .some((el) => (el.label === 'Export' || el.label === '&Export') + && (el.typeName ?? '').includes('Button')); + }, null, { timeout: 20000 }); +} + +/** Observable count of parked modal waits (each open wx modal holds one lease). */ +export async function pendingModalWaits(page: Page): Promise { + return page.evaluate(() => { + const scheduler = (globalThis as { __wxScheduler?: { pendingWaits?: (kind: string) => number } }).__wxScheduler; + return scheduler?.pendingWaits?.('modal') ?? -1; + }); +} + +/** + * Dismiss a report dialog that opens on top of the current modal stack (e.g. + * the "Export complete" report): wait for its modal lease and its OK button, + * click OK, and wait for the lease to release. `baseline` is the modal count + * before the report dialog appears. + */ +export async function dismissReportDialog(page: Page, baseline: number, what: string): Promise { + await expect.poll( + () => pendingModalWaits(page), + { message: `${what}: report dialog must open (modal lease)`, timeout: 30000 }, + ).toBe(baseline + 1); + await expect.poll( + () => findWxButton(page, 'OK'), + { message: `${what}: report dialog OK button must render`, timeout: 10000 }, + ).not.toBeNull(); + expect(await clickWxButton(page, 'OK'), `${what}: dismiss report dialog`).toBe(true); + await expect.poll( + () => pendingModalWaits(page), + { message: `${what}: report dialog must release its modal lease`, timeout: 10000 }, + ).toBe(baseline); +} diff --git a/tests/package.json b/tests/package.json index 714ab87..0133f0b 100644 --- a/tests/package.json +++ b/tests/package.json @@ -31,7 +31,8 @@ "3d:test:webgl": "playwright test --project=wx-chromium e2e/3d-webgl.spec.ts", "tools:contract": "tsx tools/cli-contract.ts", "ngspice:worker-batch": "tsx tools/ngspice-worker-batch-unit.ts", - "findings-e:contract": "tsx tools/findings-e-source-contract.ts" + "findings-e:contract": "tsx tools/findings-e-source-contract.ts", + "findings-e:parity": "tsx tools/service-stub-parity.ts" }, "devDependencies": { "@playwright/test": "^1.62.1", diff --git a/tests/tools/findings-e-source-contract.ts b/tests/tools/findings-e-source-contract.ts index 161437a..5ad7e41 100644 --- a/tests/tools/findings-e-source-contract.ts +++ b/tests/tools/findings-e-source-contract.ts @@ -22,6 +22,64 @@ const simFrame = read("kicad/eeschema/sim/simulator_frame.cpp"); const ngspiceCpp = read("kicad/eeschema/sim/ngspice.cpp"); const shim = read("scripts/common/shims/jspi-scheduler.js"); +// --- structural #ifdef scanner ---------------------------------------------- +// The wasm-only-confinement guarantees are asserted on CODE STRUCTURE (is this +// statement lexically inside an `__EMSCRIPTEN__`-conditioned region?), never on +// comment text: a comment-string contract fails on rewording with no behavior +// change and passes when the guard moves outside the ifdef but the comment +// stays — the exact regression it exists to catch. +type Cond = "em" | "not-em" | "other"; + +function emscriptenLineMap(src: string): boolean[] { + const stack: Cond[] = []; + return src.split("\n").map((line) => { + const t = line.trim(); + let m: RegExpMatchArray | null; + if ((m = t.match(/^#\s*ifdef\s+(\w+)/))) { + stack.push(m[1] === "__EMSCRIPTEN__" ? "em" : "other"); + } else if ((m = t.match(/^#\s*ifndef\s+(\w+)/))) { + stack.push(m[1] === "__EMSCRIPTEN__" ? "not-em" : "other"); + } else if ((m = t.match(/^#\s*if\b(.*)/))) { + const cond = m[1]; + const negated = /!\s*defined\s*\(?\s*__EMSCRIPTEN__/.test(cond); + const positive = /defined\s*\(?\s*__EMSCRIPTEN__/.test(cond) && !negated; + stack.push(positive ? "em" : negated ? "not-em" : "other"); + } else if (/^#\s*(else|elif)\b/.test(t)) { + const top = stack[stack.length - 1]; + if (top === "em") stack[stack.length - 1] = "not-em"; + else if (top === "not-em") stack[stack.length - 1] = "em"; + } else if (/^#\s*endif\b/.test(t)) { + stack.pop(); + } + return stack.includes("em"); + }); +} + +/** Line indexes (0-based) of every occurrence of `needle` in `src`. */ +function occurrenceLines(src: string, needle: string): number[] { + const out: number[] = []; + src.split("\n").forEach((line, i) => { + if (line.includes(needle)) out.push(i); + }); + return out; +} + +function assertOccurrences( + src: string, + map: boolean[], + needle: string, + expect: { total: number; insideEm: number }, + label: string, +): void { + const lines = occurrenceLines(src, needle); + assert.equal(lines.length, expect.total, + `${label}: expected ${expect.total} occurrence(s) of "${needle}", found ${lines.length}`); + const inside = lines.filter((i) => map[i]).length; + assert.equal(inside, expect.insideEm, + `${label}: ${inside} of ${lines.length} occurrence(s) of "${needle}" are inside an ` + + `__EMSCRIPTEN__ region, expected ${expect.insideEm}`); +} + // --- E-5: ngspice event handler bound to exact module identity -------------- assert.ok(sharedspice.includes("const installingModule = Module"), "E-5: js_ngspice_install_events must capture the installing module"); @@ -61,18 +119,82 @@ for (const symbol of ["runWaitCompletion", "_terminalizeNativeTrap", } // --- E-7: per-session run generation, behavioral drops wasm-only ------------ -for (const token of ["s_nextSimRunGeneration", "allocateSimRunGeneration", - "SetExtraLong", "m_lastAppliedSimRunGeneration"]) { - assert.ok(simFrame.includes(token), - `E-7: simulator_frame.cpp must carry the run-generation mechanism (${token})`); +const simMap = emscriptenLineMap(simFrame); + +// The acceptance guards (onSimStarted entry, onSimFinished entry, and the +// post-wxYield re-check) are the three `generation != m_simRunGeneration` +// comparisons — every one must sit inside an __EMSCRIPTEN__ region. +assertOccurrences(simFrame, simMap, "generation != m_simRunGeneration", + { total: 3, insideEm: 3 }, "E-7 acceptance guards"); +// The unowned-event drop. +assertOccurrences(simFrame, simMap, "delete event;", + { total: 1, insideEm: 1 }, "E-7 unowned-event drop"); +// The bookkeeping stays UNGUARDED by design (inert on native — every reader +// is guarded): the generation allocator and the event stamping. +assertOccurrences(simFrame, simMap, "= allocateSimRunGeneration()", + { total: 1, insideEm: 0 }, "E-7 bookkeeping (allocator call)"); +assertOccurrences(simFrame, simMap, "SetExtraLong", + { total: 1, insideEm: 0 }, "E-7 bookkeeping (event stamping)"); +assert.ok(simFrame.includes("s_nextSimRunGeneration") + && simFrame.includes("m_lastAppliedSimRunGeneration"), + "E-7: simulator_frame.cpp must carry the run-generation mechanism"); + +// The final-refresh receipt lives at the right altitude: one ifdef'd call in +// kicad, the JS hook knowledge in the stub layer. +assertOccurrences(simFrame, simMap, "pcbjam_sim_run_applied( generation )", + { total: 1, insideEm: 1 }, "E-7 receipt call"); +assert.ok(!simFrame.includes("__pcbjamNgspiceFinalRefreshApplied"), + "E-7 REGRESSION: the harness hook name is back inside kicad source — it belongs " + + "to wasm/stubs/sharedspice_client.cpp"); +assert.ok(sharedspice.includes("__pcbjamNgspiceFinalRefreshApplied"), + "E-7: sharedspice_client.cpp must implement the final-refresh receipt hook"); + +// --- E-12: crash-exit IDLE before RUNNING consumes the pending token -------- +// `generation = m_pendingRunGeneration.exchange` appears twice: the RUNNING +// consumption (unguarded bookkeeping) and the IDLE crash-exit fallback +// (behavioral — wasm-only). +assertOccurrences(simFrame, simMap, "generation = m_pendingRunGeneration.exchange", + { total: 2, insideEm: 1 }, "E-12 IDLE pending fallback"); + +// --- E-13: a failed launch withdraws its token and resets the busy state ---- +assertOccurrences(simFrame, simMap, "m_reporter->SetRunGeneration( 0 )", + { total: 1, insideEm: 1 }, "E-13 failed-launch reset"); + +// --- E-11: get_vec clamps v_length to the transferred arrays + frees on fail - +assert.ok(sharedspice.includes("length = Math.min( length, nComp >> 1 )"), + "E-11: the vector prepare must clamp v_length to the transferred arrays"); +assert.ok(/std::free\( vname \);\s*\n\s*std::free\( real \);\s*\n\s*std::free\( comp \);/ + .test(sharedspice), + "E-11: pcbjam_ngGet_Vec_Info must free the prepare's buffers on every failure path"); + +// --- E-16: the event handler gates on its INSTALLING module's scheduler ----- +assert.ok(sharedspice.includes("const installingScheduler = globalThis.__wxScheduler"), + "E-16: js_ngspice_install_events must capture the installing scheduler"); + +// --- E-15: every wxWasmBeginWait caller bails on a refused token ------------- +// (The three worker stubs are asserted in the E-8 block above.) +for (const [rel, expectedBegins] of [ + ["wxwidgets/src/wasm/fontenum.cpp", 1], + ["wxwidgets/src/wasm/clipbrd.cpp", 4], + ["wxwidgets/src/wasm/dialog.cpp", 1], + ["wxwidgets/src/wasm/evtloop.cpp", 1], + ["kicad/3d-viewer/3d_cache/pcbjam_model_fetch.cpp", 1], + ["kicad/pcbnew/pcb_io/pcbjam_fp/pcb_io_pcbjam_fp.cpp", 1], + ["kicad/eeschema/sch_io/pcbjam_lib/sch_io_pcbjam_lib.cpp", 1], +] as const) { + const src = read(rel); + const begins = (src.match(/=\s*wxWasmBeginWait\s*\(/g) ?? []).length; + const guards = (src.match(/if\s*\(\s*(?:token|waitToken)\s*<=\s*0\s*\)/g) ?? []).length; + assert.equal(begins, expectedBegins, + `E-15: ${rel} should mint ${expectedBegins} wait token(s), found ${begins}`); + assert.ok(guards >= begins, + `E-15: ${rel} has ${begins} wxWasmBeginWait call(s) but only ${guards} ` + + "token<=0 guard(s) — a refused token must never start its request"); +} +for (const symbol of ["terminalize", "resolveRefused"]) { + assert.ok(shim.includes(symbol), + `E-14/E-15: jspi-scheduler.js must provide ${symbol}`); } -assert.equal( - (simFrame.match(/Generation acceptance is confined to the wasm build/g) ?? []).length, 2, - "E-7: both handler acceptance guards must be confined to the wasm build"); -assert.ok(simFrame.includes("drop is confined to the wasm build"), - "E-7: the unowned-event drop must be confined to the wasm build"); -assert.ok(simFrame.includes("Wasm-only, findings E-7"), - "E-7: the post-wxYield re-check must be confined to the wasm build"); // --- E-9: destructor unregisters the sharedspice callbacks ------------------ assert.ok(/#ifdef __EMSCRIPTEN__[\s\S]{0,400}pcbjam_ngspice_reset_callbacks\( this \)/ diff --git a/tests/tools/lib/worker-constants.ts b/tests/tools/lib/worker-constants.ts new file mode 100644 index 0000000..c8fd65c --- /dev/null +++ b/tests/tools/lib/worker-constants.ts @@ -0,0 +1,57 @@ +import { readFileSync } from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Parse `const NAME = ;` constants out of a JS/TS source — the + * ONE source of truth for the ngspice transport numbers. The reducer and the + * parity tripwire derive their expectations from here instead of hardcoding + * copies that silently go stale when the protocol numbers move. + */ +const here = path.dirname(fileURLToPath(import.meta.url)); +export const repoRoot = path.resolve(here, "../../.."); + +export function readRepoFile(rel: string): string { + return readFileSync(path.join(repoRoot, rel), "utf8"); +} + +export function parseConstants( + source: string, + names: readonly string[], + label: string, +): Record { + const out: Record = {}; + for (const name of names) { + const m = source.match(new RegExp(`const ${name}\\s*=\\s*([^;]+);`)); + if (!m) throw new Error(`${label}: constant ${name} not found`); + const expr = m[1]!.trim(); + // Strictly digits/arithmetic/underscores — anything else is rejected, so + // the Function() evaluation below can only compute a number, never run + // code from the scanned source. + if (!/^[\d\s*+\-()_]+$/.test(expr)) { + throw new Error(`${label}: constant ${name} is not a numeric expression: ${expr}`); + } + out[name] = Function(`"use strict"; return (${expr});`)() as number; + } + return out; +} + +export const NGSPICE_WORKER_REL = "web/standalone/src/wasm/ngspice-worker.js"; + +export const NGSPICE_WORKER_CONSTANTS = [ + "MAX_EVENT_BATCH_LINES", + "MAX_EVENT_BATCH_UTF8_BYTES", + "MAX_EVENT_UNACKED_FRAMES", + "MAX_EVENT_UNACKED_UTF8_BYTES", + "MAX_DEFERRED_EVENTS", + "MAX_DEFERRED_UTF8_BYTES", +] as const; + +/** The production worker's transport constants, parsed from its source. */ +export function ngspiceWorkerConstants(): Record { + return parseConstants( + readRepoFile(NGSPICE_WORKER_REL), + NGSPICE_WORKER_CONSTANTS, + NGSPICE_WORKER_REL, + ); +} diff --git a/tests/tools/lint-ci-coverage.ts b/tests/tools/lint-ci-coverage.ts index 3407d3f..263bbd0 100644 --- a/tests/tools/lint-ci-coverage.ts +++ b/tests/tools/lint-ci-coverage.ts @@ -52,6 +52,38 @@ const EXCLUDED_DIRS = new Set([ 'scripts', ]); +// Non-playwright gates CI must keep invoking. This lint's per-spec model only +// understands "npm run test:*" playwright scripts; these gates (vitest for +// web/standalone, the ngspice transport reducer, and the findings-E source/ +// parity contracts) live outside that model — so pin their literal workflow +// invocations here. Deleting a step from a workflow re-fails this lint, +// closing the exact "nothing runs it" rot class findings-E was about. (The +// vitest include glob auto-covers new *.test.ts files, so per-file coverage +// needs no proof.) +const NON_PLAYWRIGHT_GATES = [ + 'pnpm --filter @pcbjam/standalone test', + 'npm run ngspice:worker-batch', + 'npm run findings-e:contract', + 'npm run findings-e:parity', +]; + +function assertNonPlaywrightGates(): void { + const bodies: string[] = []; + for (const f of fs.readdirSync(WORKFLOWS_DIR)) { + if (!/\.ya?ml$/.test(f)) continue; + bodies.push(fs.readFileSync(path.join(WORKFLOWS_DIR, f), 'utf8')); + } + const all = bodies.join('\n'); + const missing = NON_PLAYWRIGHT_GATES.filter((cmd) => !all.includes(cmd)); + if (missing.length) { + throw new Error( + `non-playwright CI gate(s) missing from ${WORKFLOWS_DIR}: ` + + missing.map((m) => `"${m}"`).join(', ') + + ' — a gate nothing invokes protects nothing' + ); + } +} + // ── 1. what CI invokes ──────────────────────────────────────────────────────── function ciTestScripts(): string[] { const names = new Set(); @@ -141,6 +173,8 @@ function specUniverse(dir = TESTS_ROOT, rel = ''): string[] { } // ── run ─────────────────────────────────────────────────────────────────────── +assertNonPlaywrightGates(); + const invocations = ciTestScripts().map(resolveScript); const covered = new Set(); diff --git a/tests/tools/lint-determinism.ts b/tests/tools/lint-determinism.ts index 01a120c..a427eba 100644 --- a/tests/tools/lint-determinism.ts +++ b/tests/tools/lint-determinism.ts @@ -28,12 +28,25 @@ type Rule = { const marker = (s: string) => /eslint-disable|documented|dwell/i.test(s); +// The canonical dwell marker (tests/TESTING.md) is +// `// eslint-disable-line -- documented interaction dwell: ` +// — a marker without the `: ` is a blind sleep wearing the uniform. +const DWELL_MARKER = /documented interaction dwell/; +const DWELL_MARKER_WITH_WHY = /documented interaction dwell:\s*\S/; +const bareDwellMarker = (s: string) => DWELL_MARKER.test(s) && !DWELL_MARKER_WITH_WHY.test(s); + const RULES: Rule[] = [ { name: 'no-blind-waitForTimeout', message: 'blind waitForTimeout — use waitUntil/expect.poll/web-first assertion, or annotate a documented interaction dwell', hit: (line, prev) => /\.waitForTimeout\s*\(/.test(line) && !marker(line) && !marker(prev), }, + { + name: 'dwell-marker-needs-why', + message: 'dwell marker without its reason — the mandated form is `// eslint-disable-line -- documented interaction dwell: ` (tests/TESTING.md)', + hit: (line, prev) => /\.waitForTimeout\s*\(/.test(line) + && (bareDwellMarker(line) || (!DWELL_MARKER.test(line) && bareDwellMarker(prev))), + }, { name: 'no-toHaveScreenshot', message: 'toHaveScreenshot does inline pixel comparison — use stableShot() (offline gate)', diff --git a/tests/tools/ngspice-worker-batch-unit.ts b/tests/tools/ngspice-worker-batch-unit.ts index f9fdea6..563e026 100644 --- a/tests/tools/ngspice-worker-batch-unit.ts +++ b/tests/tools/ngspice-worker-batch-unit.ts @@ -8,6 +8,7 @@ import { readFileSync } from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import vm from "node:vm"; +import { ngspiceWorkerConstants } from "./lib/worker-constants.js"; const here = path.dirname(fileURLToPath(import.meta.url)); const repo = path.resolve(here, "../.."); @@ -16,10 +17,19 @@ const workerSource = readFileSync( "utf8", ); +// The transport numbers, parsed from the PRODUCTION worker source — the +// reducer must never hardcode copies that go stale when the protocol moves. +const C = ngspiceWorkerConstants(); +const BATCH_LINES = C.MAX_EVENT_BATCH_LINES!; +const BATCH_BYTES = C.MAX_EVENT_BATCH_UTF8_BYTES!; +const WINDOW_FRAMES = C.MAX_EVENT_UNACKED_FRAMES!; +const WINDOW_BYTES = C.MAX_EVENT_UNACKED_UTF8_BYTES!; + type Frame = { id?: number; evt?: { kind: string; lines?: string[]; finished?: boolean }; fatal?: string; + pendingEvents?: Array<{ evt?: { kind: string; lines?: string[] }; eventBytes?: number }>; res?: { error?: string }; eventSequence?: number; eventBytes?: number; @@ -31,7 +41,9 @@ type WorkerHarness = { message(data: unknown): Promise; }; -async function createWorkerHarness(): Promise { +async function createWorkerHarness( + moduleOverrides: Record = {}, +): Promise { const frames: Frame[] = []; let messageHandler!: (event: { data: unknown }) => Promise; const module: Record = { @@ -44,6 +56,7 @@ async function createWorkerHarness(): Promise { allVecs: () => [], running: () => false, cmInputPath: () => undefined, + ...moduleOverrides, }; const workerGlobal: Record = { NGSPICE_GLUE_URL: "https://pcbjam.test/ngspice_service.js", @@ -63,6 +76,7 @@ async function createWorkerHarness(): Promise { Map, JSON, Promise, + TextEncoder, queueMicrotask, importScripts: () => undefined, NgspiceService: () => Promise.resolve(module), @@ -96,21 +110,23 @@ async function acknowledgeAll(harness: WorkerHarness): Promise { async function main(): Promise { const bounded = await createWorkerHarness(); -// 1,025 synchronous lines cannot wait for a microtask: 512, 512 flush on the -// line bound, and the final line flushes at the queued microtask. -for (let i = 0; i < 1_025; ++i) bounded.emit(0, `line-${i}`, 0, 0); +// Two full batches plus one synchronous line cannot wait for a microtask: +// both flush on the line bound, and the final line flushes at the queued +// microtask. +const floodLines = 2 * BATCH_LINES + 1; +for (let i = 0; i < floodLines; ++i) bounded.emit(0, `line-${i}`, 0, 0); assert.deepEqual( bounded.frames.map((frame) => frame.evt?.lines?.length), - [512, 512], + [BATCH_LINES, BATCH_LINES], ); await Promise.resolve(); assert.deepEqual( bounded.frames.map((frame) => frame.evt?.lines?.length), - [512, 512, 1], + [BATCH_LINES, BATCH_LINES, 1], ); assert.deepEqual( bounded.frames.flatMap((frame) => frame.evt?.lines ?? []), - Array.from({ length: 1_025 }, (_, i) => `line-${i}`), + Array.from({ length: floodLines }, (_, i) => `line-${i}`), ); await acknowledgeAll(bounded); console.log("ok synchronous output flushes in bounded ordered line chunks"); @@ -125,10 +141,10 @@ assert.deepEqual( bounded.frames[0]!.evt!.lines!.map((line) => line.at(-1)), ["0", "1"], ); -assert.ok(bounded.frames[0]!.eventBytes! <= 1024 * 1024); +assert.ok(bounded.frames[0]!.eventBytes! <= BATCH_BYTES); await Promise.resolve(); assert.equal(bounded.frames[1]!.evt!.lines!.length, 1); -assert.ok(bounded.frames[1]!.eventBytes! <= 1024 * 1024); +assert.ok(bounded.frames[1]!.eventBytes! <= BATCH_BYTES); await acknowledgeAll(bounded); console.log("ok UTF-8 byte pressure flushes before retaining the crossing line"); @@ -144,10 +160,10 @@ for (let i = 0; i < 100_000; ++i) { } await Promise.resolve(); const stormEvents = storm.frames.filter((frame) => frame.evt); -assert.ok(stormEvents.length <= 64); +assert.ok(stormEvents.length <= WINDOW_FRAMES); assert.ok( stormEvents.reduce((sum, frame) => sum + frame.eventBytes!, 0) - <= 8 * 1024 * 1024, + <= WINDOW_BYTES, ); assert.equal(storm.frames.filter((frame) => frame.fatal).length, 1); assert.match(storm.frames.find((frame) => frame.fatal)!.fatal!, /deferred/); @@ -160,44 +176,119 @@ console.log("ok 100,000 synchronous chunk attempts cannot exceed transport cre // window behind (observed as "event transport exceeded 64 frames" ending the // eeschema second-run spec). const paced = await createWorkerHarness(); -for (let i = 0; i < 80; ++i) { +const pacedTotal = WINDOW_FRAMES + 16; +for (let i = 0; i < pacedTotal; ++i) { paced.emit(2, "", i % 2, 0); // bg toggles: one frame per emit, no batching } await Promise.resolve(); assert.equal(paced.frames.filter((f) => f.fatal).length, 0, "a full window with a live consumer must not be terminal"); -assert.equal(paced.frames.filter((f) => f.evt).length, 64, +assert.equal(paced.frames.filter((f) => f.evt).length, WINDOW_FRAMES, "exactly the credit window is in flight"); await acknowledgeAll(paced); await Promise.resolve(); const pacedEvents = paced.frames.filter((f) => f.evt); -assert.equal(pacedEvents.length, 80, "deferred frames drained after acks"); +assert.equal(pacedEvents.length, pacedTotal, "deferred frames drained after acks"); assert.deepEqual( pacedEvents.map((f) => f.evt!.finished), - Array.from({ length: 80 }, (_, i) => !(i % 2 === 0)), + Array.from({ length: pacedTotal }, (_, i) => !(i % 2 === 0)), "deferred frames preserve emission order", ); console.log("ok a full credit window defers and drains in order, never terminal"); const oversize = await createWorkerHarness(); +const oversizeFatal = `ngspice event line exceeds ${BATCH_BYTES} UTF-8 bytes`; assert.throws( - () => oversize.emit(0, "y".repeat(1024 * 1024), 0, 0), - /ngspice event line exceeds 1048576 UTF-8 bytes/, + () => oversize.emit(0, "y".repeat(BATCH_BYTES), 0, 0), + new RegExp(oversizeFatal), ); assert.deepEqual(oversize.frames, [{ - fatal: "ngspice event line exceeds 1048576 UTF-8 bytes", + fatal: oversizeFatal, + pendingEvents: [], }]); assert.throws( () => oversize.emit(0, "late", 0, 0), - /ngspice event line exceeds 1048576 UTF-8 bytes/, + new RegExp(oversizeFatal), ); await oversize.message({ id: 91, req: { kind: "running" } }); assert.deepEqual(oversize.frames.at(-1), { id: 91, - res: { error: "ngspice event line exceeds 1048576 UTF-8 bytes" }, + res: { error: oversizeFatal }, }); console.log("ok a single over-limit line is never retained and terminalizes requests"); +// E-20: the oversize-line path promises "every earlier line was accepted … +// transfer it before refusing this line". With the credit window FULL, that +// flush can only DEFER — the terminal stop must ship the deferred frames +// inside the fatal notice instead of wiping them (they are typically the last +// diagnostics explaining why the run died). +const prefixed = await createWorkerHarness(); +for (let i = 0; i < WINDOW_FRAMES; ++i) prefixed.emit(2, "", i % 2, 0); // fill the window +prefixed.emit(0, "accepted-1", 0, 0); +prefixed.emit(0, "accepted-2", 0, 0); +prefixed.emit(0, "accepted-3", 0, 0); // open batch, flush still queued +assert.throws( + () => prefixed.emit(0, "y".repeat(BATCH_BYTES), 0, 0), + new RegExp(oversizeFatal), +); +const terminalNotice = prefixed.frames.find((frame) => frame.fatal); +assert.ok(terminalNotice, "terminal notice posted"); +const deliveredLines = [ + ...prefixed.frames.flatMap((frame) => frame.evt?.lines ?? []), + ...(terminalNotice!.pendingEvents ?? []) + .flatMap((entry) => entry.evt?.lines ?? []), +]; +for (const line of ["accepted-1", "accepted-2", "accepted-3"]) { + assert.ok(deliveredLines.includes(line), + `accepted line "${line}" must reach the host despite the terminal stop`); +} +console.log("ok the accepted prefix survives a terminal stop under a full window"); + +// E-10 recovery: a REPLACEMENT worker serves engine reads before its first +// init (the editor's crash-recovery finish pulls vectors right after a +// worker death). An uninitialized engine traps on those entries — and a +// trapped engine then hangs later requests, parking the finish chain. The +// worker must answer the empty shapes itself, never touching the engine. +const engineTrap = () => { + throw new Error("RuntimeError: indirect call to null (uninitialized engine)"); +}; +const preInit = await createWorkerHarness({ + getVecInfo: engineTrap, curPlot: engineTrap, allPlots: engineTrap, + allVecs: engineTrap, running: engineTrap, +}); +await preInit.message({ id: 1, req: { kind: "get_vec_info", name: "time" } }); +assert.deepEqual(preInit.frames.at(-1), { id: 1, res: { found: false } }); +await preInit.message({ id: 2, req: { kind: "cur_plot" } }); +assert.deepEqual(preInit.frames.at(-1), { id: 2, res: { name: "" } }); +await preInit.message({ id: 3, req: { kind: "all_plots" } }); +assert.deepEqual(preInit.frames.at(-1), { id: 3, res: { names: [] } }); +await preInit.message({ id: 4, req: { kind: "running" } }); +assert.deepEqual(preInit.frames.at(-1), { id: 4, res: { running: false } }); +// After init, reads reach the engine again (the trapping fake IS called). +await preInit.message({ id: 5, req: { kind: "init" } }); +assert.deepEqual(preInit.frames.at(-1), { id: 5, res: { ret: 0 } }); +await preInit.message({ id: 6, req: { kind: "get_vec_info", name: "time" } }); +assert.match( + (preInit.frames.at(-1) as { res?: { error?: string } }).res!.error!, + /uninitialized engine/, +); +console.log("ok engine reads answer their empty shapes before the first init"); + +// …and engine WRITES lazy-init the fresh engine (the editor issues +// cm_input_path/circ before its validate() re-init), with the init request +// idempotent per worker engine. +let lazyInits = 0; +const lazy = await createWorkerHarness({ + init: () => { lazyInits++; return 0; }, +}); +await lazy.message({ id: 1, req: { kind: "circ", lines: ["*", ".end"] } }); +assert.equal(lazyInits, 1, "first write initialized the engine"); +assert.deepEqual(lazy.frames.at(-1), { id: 1, res: { ret: 0 } }); +await lazy.message({ id: 2, req: { kind: "init" } }); +assert.equal(lazyInits, 1, "init is idempotent per worker engine"); +assert.deepEqual(lazy.frames.at(-1), { id: 2, res: { ret: 0 } }); +console.log("ok engine writes lazy-init a fresh engine; init is idempotent"); + const ackMismatch = await createWorkerHarness(); ackMismatch.emit(2, "", 0, 0); const credited = ackMismatch.frames[0]!; diff --git a/tests/tools/service-stub-parity.ts b/tests/tools/service-stub-parity.ts new file mode 100644 index 0000000..0a029bb --- /dev/null +++ b/tests/tools/service-stub-parity.ts @@ -0,0 +1,94 @@ +/** + * Stub/production parity tripwire (findings group E guardrail). The e2e + * harness drives hand-maintained MIRRORS of the worker services + * (tests/kicad/utils/{ngspice,occ}-service.ts) while production ships + * web/standalone/src/wasm/{ngspice,occ}-service.ts — a fix landed in one copy + * and not the other silently invalidates what the browser specs claim to + * prove. Until the copies collapse into one shared lifecycle module (the + * deferred refactor), this tool pins the load-bearing invariants both sides + * must share, parsing ACTUAL VALUES — never comment text. + * Run: npm run findings-e:parity + */ +import { strict as assert } from "node:assert"; +import { + ngspiceWorkerConstants, + parseConstants, + readRepoFile, +} from "./lib/worker-constants.js"; + +const prodNgspice = readRepoFile("web/standalone/src/wasm/ngspice-service.ts"); +const prodOcc = readRepoFile("web/standalone/src/wasm/occ-service.ts"); +const stubNgspice = readRepoFile("tests/kicad/utils/ngspice-service.ts"); +const stubOcc = readRepoFile("tests/kicad/utils/occ-service.ts"); +const bootTs = readRepoFile("web/standalone/src/wasm/boot.ts"); +const workerJs = readRepoFile("web/standalone/src/wasm/ngspice-worker.js"); + +// --- credit window: worker ≡ production host ≡ harness stub ----------------- +// The host queue caps must EQUAL the worker's credit window or the protocol +// retires healthy workers ("event-frame queue exceeded credit"). +const worker = ngspiceWorkerConstants(); +const hostCaps = ["MAX_QUEUED_EVENT_FRAMES", "MAX_QUEUED_EVENT_BYTES"] as const; +const prodCaps = parseConstants(prodNgspice, hostCaps, "production ngspice-service.ts"); +const stubCaps = parseConstants(stubNgspice, hostCaps, "stub ngspice-service.ts"); +assert.equal(prodCaps.MAX_QUEUED_EVENT_FRAMES, worker.MAX_EVENT_UNACKED_FRAMES, + "credit window FRAMES: production host must equal the worker"); +assert.equal(prodCaps.MAX_QUEUED_EVENT_BYTES, worker.MAX_EVENT_UNACKED_UTF8_BYTES, + "credit window BYTES: production host must equal the worker"); +assert.deepEqual(stubCaps, prodCaps, + "credit window: the harness stub must equal the production host"); + +// --- E-19: the frame ack survives a throwing handler (both copies) ---------- +for (const [label, src] of [ + ["production ngspice-service.ts", prodNgspice], + ["stub ngspice-service.ts", stubNgspice], +] as const) { + const start = src.indexOf("const dispatchEvt"); + const end = src.indexOf("deliverTerminalEvents", start); + assert.ok(start >= 0 && end > start, `${label}: dispatchEvt body not found`); + const body = src.slice(start, end); + assert.ok(/finally\s*\{[\s\S]{0,80}?ackEvent\(/.test(body), + `E-19 REGRESSION (${label}): dispatchEvt must ack the owned frame in a ` + + "finally — a throwing handler leaked one credit unit per throw"); +} + +// --- E-20: the terminal notice's pendingEvents are consumed (both copies) --- +for (const [label, src] of [ + ["production ngspice-service.ts", prodNgspice], + ["stub ngspice-service.ts", stubNgspice], +] as const) { + assert.ok(src.includes("deliverTerminalEvents(data.pendingEvents)"), + `E-20 (${label}): the fatal branch must deliver the worker's accepted-` + + "prefix pendingEvents before retiring"); +} +assert.ok(workerJs.includes("postMessage({ fatal: reason, pendingEvents })"), + "E-20 (ngspice-worker.js): the terminal notice must carry the deferred frames"); + +// --- E-10: retirement synthesizes the controlled exit (both copies) --------- +assert.ok(/kind: "exit", status: 1, immediate: true, quit: false/.test(prodNgspice), + "E-10 (production): retireWorker must synthesize the controlled exit"); +assert.ok(/kind: 'exit', status: 1, immediate: true, quit: false/.test(stubNgspice), + "E-10 (stub): retireWorker must synthesize the controlled exit"); + +// --- E-10 recovery: the worker guards pre-init engine access ---------------- +assert.ok(workerJs.includes("let engineReady") && workerJs.includes("ensureEngine("), + "E-10 (ngspice-worker.js): pre-init reads must answer empty shapes and " + + "writes must lazy-init the fresh engine"); + +// --- E-22 / E-1: a boot deadline exists in all four lifecycle copies -------- +for (const [label, src] of [ + ["production ngspice-service.ts", prodNgspice], + ["production occ-service.ts", prodOcc], + ["stub ngspice-service.ts", stubNgspice], + ["stub occ-service.ts", stubOcc], +] as const) { + assert.ok(src.includes("bootTimer") && src.includes("boot timed out after"), + `E-22 REGRESSION (${label}): the boot deadline is gone — a wedged worker ` + + "boot hangs every request with zero evidence"); +} + +// --- E-14: boot wires Module.onAbort to the scheduler's terminal latch ------ +assert.ok(/onAbort[\s\S]{0,600}?terminalize\?\.\(\s*"emscripten abort"/.test(bootTs), + "E-14 (boot.ts): Module.onAbort must latch __wxScheduler.terminalize — the " + + "authoritative abort notification"); + +console.log("service-stub-parity: all green"); diff --git a/tests/web/footprint-write-remote.spec.ts b/tests/web/footprint-write-remote.spec.ts index e512e59..53fc178 100644 --- a/tests/web/footprint-write-remote.spec.ts +++ b/tests/web/footprint-write-remote.spec.ts @@ -36,7 +36,7 @@ async function clickTreeRow(page: Page, label: string): Promise { }, label); if (!hit) return false; await page.mouse.click(hit.x, hit.y); - await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: tree-row selection commit; no observable return true; } @@ -79,7 +79,7 @@ test.fixme( // New Footprint → auto-saves into the writable lib (tryToSaveFootprintInLibrary). expect(await clickByTooltip(page, 'New Footprint'), 'New Footprint clicked').toBe(true); - await page.waitForTimeout(1500); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(1500); // eslint-disable-line -- documented interaction dwell: New Footprint dialog/creation commit // Belt-and-braces explicit save. await focusCanvas(page); await page.keyboard.press('Control+s'); diff --git a/tests/web/symbol-write-remote.spec.ts b/tests/web/symbol-write-remote.spec.ts index 4b64563..334708d 100644 --- a/tests/web/symbol-write-remote.spec.ts +++ b/tests/web/symbol-write-remote.spec.ts @@ -55,13 +55,13 @@ test.fixme( }); expect(hdr, 'Item column header found').not.toBeNull(); await page.mouse.click(hdr!.cx, hdr!.cy + hdr!.hgt + 8); - await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: tree focus commit await page.keyboard.press('Home'); - await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: tree-nav keystroke commit await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: tree-nav keystroke commit await page.keyboard.press('ArrowUp'); - await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell: tree selection commit expect(await clickByTooltip(page, 'New Symbol...'), 'New Symbol clicked').toBe(true); await stableShot(page, 'symremote-02-newsym.png'); @@ -77,7 +77,7 @@ test.fixme( }); expect(nameField, 'New Symbol name field present').toBeTruthy(); await page.mouse.click(nameField!.cx, nameField!.cy); - await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: name field focus commit await page.keyboard.press('Control+a'); await page.keyboard.press('Delete'); await page.keyboard.type('RemoteRes', { delay: 40 }); diff --git a/tests/web/symbol-write-spike.spec.ts b/tests/web/symbol-write-spike.spec.ts index ba74824..221d3a4 100644 --- a/tests/web/symbol-write-spike.spec.ts +++ b/tests/web/symbol-write-spike.spec.ts @@ -57,13 +57,13 @@ test.fixme( // Click focuses the tree row but doesn't always select it; drive the keyboard // to make the first (only) library row the SELECTED item (GetTargetLibId). await page.mouse.click(hdr!.cx, hdr!.cy + hdr!.hgt + 8); // focus the tree - await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: tree focus commit await page.keyboard.press('Home'); - await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: tree-nav keystroke commit await page.keyboard.press('ArrowDown'); - await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: tree-nav keystroke commit await page.keyboard.press('ArrowUp'); - await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell: tree selection commit await stableShot(page, 'symwrite-02-lib-selected.png'); // New Symbol via the toolbar button (tooltip), proven-clickable in the harness. @@ -91,11 +91,11 @@ test.fixme( const nameField = dlg.texts.find((t) => Math.abs(t.cy - 65) > 30 && Math.abs(t.cy - 87) > 30); expect(nameField, 'New Symbol name field present').toBeTruthy(); await page.mouse.click(nameField!.cx, nameField!.cy); - await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: name field focus commit await page.keyboard.press('Control+a'); await page.keyboard.press('Delete'); await page.keyboard.type(SYM, { delay: 40 }); - await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: typed-name registration commit await stableShot(page, 'symwrite-04-name-typed.png'); await page.keyboard.press('Enter'); await waitUntil( @@ -139,7 +139,7 @@ test.fixme( expect(body).toContain(`(symbol "${savedName}"`); // No post-save error dialog (the placeholder-file fix for GetModificationTime). - await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell + await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell: negative-assert window for a post-save error dialog const errDialog = await page.evaluate(() => window.wxElementRegistry .findAll({ visible: true }) diff --git a/wasm/stubs/sharedspice_client.cpp b/wasm/stubs/sharedspice_client.cpp index ceb1688..b449373 100644 --- a/wasm/stubs/sharedspice_client.cpp +++ b/wasm/stubs/sharedspice_client.cpp @@ -120,14 +120,26 @@ EM_JS( void, js_ngspice_get_vec_start, return 0; HEAP32[( aMeta >> 2 ) + 1] = res.vtype | 0; HEAP32[( aMeta >> 2 ) + 2] = res.flags | 0; - HEAP32[( aMeta >> 2 ) + 3] = res.length | 0; - if( res.real && res.real.length ) { - const p = _malloc( res.real.length * 8 ); + // E-11: v_length must describe what was actually TRANSFERRED, + // never the worker's self-reported count — a corrupted worker + // answering a huge length with small arrays otherwise drives the + // native consumer through a multi-gigabyte copy (observed dying + // as an unhandled std::length_error that exits the main loop). + // Interleaved re,im doubles: 2 per complex element. + let length = Math.max( 0, res.length | 0 ); + const nReal = ( res.real && res.real.length ) | 0; + const nComp = ( res.comp && res.comp.length ) | 0; + if( nReal ) length = Math.min( length, nReal ); + if( nComp ) length = Math.min( length, nComp >> 1 ); + if( !nReal && !nComp ) length = 0; + HEAP32[( aMeta >> 2 ) + 3] = length; + if( nReal ) { + const p = _malloc( nReal * 8 ); HEAPF64.set( res.real, p >> 3 ); HEAPU32[aReal >> 2] = p; } - if( res.comp && res.comp.length ) { - const p = _malloc( res.comp.length * 8 ); + if( nComp ) { + const p = _malloc( nComp * 8 ); HEAPF64.set( res.comp, p >> 3 ); HEAPU32[aComp >> 2] = p; } @@ -159,13 +171,19 @@ extern "C" int wxWasmYieldUntil( int aToken ); // handler, and a superseded handler disarms itself. EM_JS( void, js_ngspice_install_events, (), { const installingModule = Module; + // E-16: capture the installing module's SCHEDULER too — the liveness gate + // and trap latch below must describe the exact instance this handler + // drives, not whatever scheduler the realm holds at dispatch time (under + // same-realm module replacement the realm-global would belong to the + // successor). + const installingScheduler = globalThis.__wxScheduler; const installed = globalThis.__ngspiceOnEvent; if( installed && installed.__pcbjamNgspiceOwnerModule === installingModule ) return; const handler = ( evt ) => { if( globalThis.__ngspiceOnEvent !== handler ) return; // superseded install — never drive a retired module - const sched = globalThis.__wxScheduler; + const sched = installingScheduler; if( !sched || !sched.canTouchNative || !sched.canTouchNative() ) { // E-8/M-2: a dead or terminal instance takes no native entry; the // drop is loud, never silent. @@ -173,6 +191,11 @@ EM_JS( void, js_ngspice_install_events, (), { + 'dead/terminal module' ); return; } + // E-16: a plain-JS throw between the malloc and the native entry + // leaks the line buffer — track it so the non-trap rethrow path can + // free it (never free on the trap path: freeing re-enters a trapped + // module). + let pendingText = 0; const call = ( kind, text, a, b ) => { let p = 0; if( text != null ) { @@ -185,7 +208,9 @@ EM_JS( void, js_ngspice_install_events, (), { p = _malloc( n ); stringToUTF8( text, p, n ); } + pendingText = p; installingModule._pcbjam_ngspice_event( kind, p, a | 0, b | 0 ); + pendingText = 0; // the native entry freed it }; try { if( evt.kind === 'char' || evt.kind === 'stat' ) { @@ -201,8 +226,11 @@ EM_JS( void, js_ngspice_install_events, (), { // A trap on this fresh entry poisons the instance: latch the // terminal gate so no later completion re-enters it. if( !sched._terminalizeNativeTrap - || !sched._terminalizeNativeTrap( 'ngspice event entry', e ) ) + || !sched._terminalizeNativeTrap( 'ngspice event entry', e ) ) { + if( pendingText ) + _free( pendingText ); throw e; + } } }; handler.__pcbjamNgspiceOwnerModule = installingModule; @@ -428,6 +456,33 @@ extern "C" EMSCRIPTEN_KEEPALIVE void pcbjam_ngspice_reset_callbacks( void* aUser s_user = nullptr; } +// E-7: the browser harness's final-refresh receipt — called by +// SIMULATOR_FRAME::onSimFinished after every final native refresh (one +// ifdef'd line there; the JS-side knowledge lives HERE, in the stub layer). +// Optional test evidence, no mainline behavior. +// clang-format off +EM_JS( void, js_ngspice_sim_run_applied, ( uint32_t aGeneration ), { + const hook = globalThis.__pcbjamNgspiceFinalRefreshApplied; + + if( typeof hook === 'function' ) + { + try + { + hook( aGeneration >>> 0 ); + } + catch( error ) + { + console.error( '[ngspice] final-refresh hook failed', error ); + } + } +} ); +// clang-format on + +extern "C" EMSCRIPTEN_KEEPALIVE void pcbjam_sim_run_applied( uint32_t aGeneration ) +{ + js_ngspice_sim_run_applied( aGeneration ); +} + // ------------------------------------------------------------------------- // The sharedspice API surface NGSPICE::init_dll binds to // ------------------------------------------------------------------------- @@ -506,11 +561,17 @@ pvector_info pcbjam_ngGet_Vec_Info( char* aVecName ) js_ngspice_get_vec_start( token, aVecName ? aVecName : "", meta, &real, &comp, &vname ); - if( wxWasmYieldUntil( token ) != 0 ) - return nullptr; - - if( !meta[0] ) + // E-11: a plain-JS throw mid-prepare (after some mallocs landed) resolves + // the inertResult without adopting the buffers into the arena — free + // whatever was written on EVERY failure path (free(nullptr) is a no-op, + // so this covers all partial orderings). + if( wxWasmYieldUntil( token ) != 0 || !meta[0] ) + { + std::free( vname ); + std::free( real ); + std::free( comp ); return nullptr; + } s_name = vname; s_real = real; diff --git a/web/standalone/src/wasm/boot.ts b/web/standalone/src/wasm/boot.ts index 391240f..5a4de6a 100644 --- a/web/standalone/src/wasm/boot.ts +++ b/web/standalone/src/wasm/boot.ts @@ -679,6 +679,13 @@ async function doBoot(opts: BootOptions): Promise { onAbort: (what: unknown) => { const msg = what === undefined ? "" : String(what); log(`[boot] abort: ${msg}`); + // Authoritative trap notification: latch the scheduler's terminal gate + // so no parked frame resumes into the aborted instance (E-8/E-14). + // Safe w.r.t. recovery — oom-watch recovers via a full page reload, + // never an in-realm module replacement. + (globalThis as { + __wxScheduler?: { terminalize?: (site: string, e?: unknown) => void }; + }).__wxScheduler?.terminalize?.("emscripten abort", msg); onAbort?.(msg); }, monitorRunDependencies: () => {}, diff --git a/web/standalone/src/wasm/libs/models-bridge.test.ts b/web/standalone/src/wasm/libs/models-bridge.test.ts index e4f90f5..e9a14ee 100644 --- a/web/standalone/src/wasm/libs/models-bridge.test.ts +++ b/web/standalone/src/wasm/libs/models-bridge.test.ts @@ -7,6 +7,7 @@ import { runDeferredModelPrescan, normalizeModelRef, scanModelRefs, + type BoardModelFile, } from "./models-bridge"; import type { Model3dSource } from "./models-source"; @@ -172,6 +173,104 @@ describe("collectBoardModelFiles", () => { expect(source.getModelBody).toHaveBeenCalledTimes(1); }); + it("feeds the caller's progress sink as models are accepted", async () => { + // E-21: the prefetch caller reads the sink synchronously at its timeout — + // the accepted models must be there the moment they are accepted, and the + // scan total as soon as it is known. + (globalThis as unknown as { window: unknown }).window ??= globalThis; + let resolveSecond!: (body: Uint8Array | null) => void; + const source: Model3dSource = { + getModelBody: vi.fn((ref: string) => { + if (ref.startsWith("SinkA")) { + return Promise.resolve(new TextEncoder().encode(`body:${ref}`)); + } + return new Promise((resolve) => { + resolveSecond = resolve; + }); + }), + hasModel: async () => true, + }; + installModel3dHandler(source, () => {}); + const progress = { totalRefs: 0, models: [] as BoardModelFile[] }; + const collection = collectBoardModelFiles( + '(model "SinkA.3dshapes/A.step")\n(model "SinkB.3dshapes/B.step")', + 1, + undefined, + progress, + ); + + await vi.waitFor(() => expect(progress.models).toHaveLength(1)); + expect(progress.totalRefs, "scan total known up front").toBe(2); + expect(progress.models[0]!.path).toBe("SinkA.3dshapes/A.step"); + + resolveSecond(new TextEncoder().encode("body:SinkB.3dshapes/B.step")); + const models = await collection; + expect(models, "the sink IS the result array").toBe(progress.models); + expect(models).toHaveLength(2); + }); + + it("gains no sink entries after abort (in-flight result stays inert)", async () => { + // E-4 barrier, restated through the sink: an aborted collection may not + // retain a body that resolves after the abort — not in its result, and + // not in the caller's progress sink either. + (globalThis as unknown as { window: unknown }).window ??= globalThis; + let resolveFirst!: (body: Uint8Array | null) => void; + const source: Model3dSource = { + getModelBody: vi.fn( + () => new Promise((resolve) => { + resolveFirst = resolve; + }), + ), + hasModel: async () => true, + }; + installModel3dHandler(source, () => {}); + const controller = new AbortController(); + const retired = new Error("exact OCC prefetch retired"); + const progress = { totalRefs: 0, models: [] as BoardModelFile[] }; + const collection = collectBoardModelFiles( + '(model "SinkAbortA.3dshapes/A.step")\n' + + '(model "SinkAbortB.3dshapes/B.step")', + 1, + controller.signal, + progress, + ); + + await vi.waitFor(() => expect(source.getModelBody).toHaveBeenCalledTimes(1)); + controller.abort(retired); + resolveFirst(new Uint8Array([1, 2, 3])); + + await expect(collection).rejects.toBe(retired); + expect(progress.totalRefs).toBe(2); + expect(progress.models, "no post-abort retention through the sink").toEqual([]); + }); + + it("remembers the serving fallback candidate across collects (no re-probes)", async () => { + // E-21 memo: a .wrl ref served by its .step fallback re-probed the missing + // .wrl on every export (IDB + network round-trips). The serving candidate + // is remembered per ref — positive results only, no body caching. + (globalThis as unknown as { window: unknown }).window ??= globalThis; + const getModelBody = vi.fn(async (ref: string) => + ref.endsWith(".step") ? new TextEncoder().encode(`body:${ref}`) : null); + const source: Model3dSource = { getModelBody, hasModel: async () => true }; + installModel3dHandler(source, () => {}); + const board = '(model "${KICAD10_3DMODEL_DIR}/MemoLib.3dshapes/M1.wrl")'; + + await collectBoardModelFiles(board); + expect(getModelBody.mock.calls.map((call) => call[0]), + "first collect probes the .wrl miss then the .step hit").toEqual([ + "MemoLib.3dshapes/M1.wrl", + "MemoLib.3dshapes/M1.step", + ]); + + getModelBody.mockClear(); + const models = await collectBoardModelFiles(board); + expect(getModelBody.mock.calls.map((call) => call[0]), + "second collect goes straight to the remembered candidate").toEqual([ + "MemoLib.3dshapes/M1.step", + ]); + expect(models[0]!.path).toBe("MemoLib.3dshapes/M1.step"); + }); + it("never touches the editor MEMFS (pure source/IDB/network path)", async () => { // repro for E-4: routing bodies through the editor heap added a stale // native-completion tail after a prefetch timeout — the collect path must diff --git a/web/standalone/src/wasm/libs/models-bridge.ts b/web/standalone/src/wasm/libs/models-bridge.ts index 16585e3..3286ea3 100644 --- a/web/standalone/src/wasm/libs/models-bridge.ts +++ b/web/standalone/src/wasm/libs/models-bridge.ts @@ -91,6 +91,15 @@ const materialized = new Map(); /** In-flight ensures, coalesced per ref (prescan and the C++ fallback race). */ const ensuring = new Map>(); +/** + * Which fallback candidate served each ref on the pure-source collect path + * (positive results only). Kills the repeated failed-candidate probes — a + * `.wrl` ref served by its `.step` fallback re-probed the missing `.wrl` on + * every export — without caching bodies (the source's IDB layer does that) and + * without touching the editor MEMFS. Cleared on source replacement. + */ +const servingCandidate = new Map(); + /** Wire the model source used by the provider dispatch + prescan. */ export function installModel3dHandler( source: Model3dSource, @@ -98,6 +107,7 @@ export function installModel3dHandler( ): void { installedSource = source; installedLog = log; + servingCandidate.clear(); } /** Fetch one model body and write it under MODELS_3D_ROOT. Resolves to the @@ -194,6 +204,18 @@ export interface BoardModelFile { bytes: Uint8Array; } +/** + * Caller-owned progress sink for collectBoardModelFiles: `models` receives + * each accepted body the moment it is accepted, `totalRefs` is set as soon as + * the board scan completes. A caller that abandons the collection (prefetch + * timeout) reads the partial set synchronously — awaiting the collector after + * abort would be unbounded (an in-flight source fetch is not abortable). + */ +export interface CollectProgress { + totalRefs: number; + models: BoardModelFile[]; +} + /** * Fetch every lib model a board references for an occ_service export. This is * deliberately a pure source/IDB/network path (E-4): the OCC worker has a @@ -209,8 +231,10 @@ export async function collectBoardModelFiles( boardText: string, concurrency = 6, signal?: AbortSignal, + progress?: CollectProgress, ): Promise { - if (!installedSource) return []; + const out: BoardModelFile[] = progress?.models ?? []; + if (!installedSource) return out; const source = installedSource; const isCurrent = () => source === installedSource; const throwIfAborted = (): void => { @@ -219,9 +243,9 @@ export async function collectBoardModelFiles( }; throwIfAborted(); const refs = scanModelRefs(boardText); - if (!refs.length) return []; + if (progress) progress.totalRefs = refs.length; + if (!refs.length) return out; - const out: BoardModelFile[] = []; const seen = new Set(); let idx = 0; const worker = async (): Promise => { @@ -229,7 +253,14 @@ export async function collectBoardModelFiles( throwIfAborted(); if (!isCurrent() || idx >= refs.length) return; const ref = refs[idx++]!; - for (const candidate of refCandidates(ref)) { + // The remembered serving candidate goes first (skips re-probing + // fallbacks that failed on an earlier export); the rest stay as backup + // in case it can no longer serve (IDB eviction). + const remembered = servingCandidate.get(ref); + const candidates = remembered + ? [remembered, ...refCandidates(ref).filter((c) => c !== remembered)] + : refCandidates(ref); + for (const candidate of candidates) { let body: Uint8Array | null = null; try { body = await source.getModelBody(candidate); @@ -241,6 +272,7 @@ export async function collectBoardModelFiles( if (!isCurrent()) return; if (!body) continue; + servingCandidate.set(ref, candidate); if (!seen.has(candidate)) { seen.add(candidate); // The OCC worker receives this buffer as a transferable. Keep its diff --git a/web/standalone/src/wasm/ngspice-service.test.ts b/web/standalone/src/wasm/ngspice-service.test.ts index 3fe2e0a..66de656 100644 --- a/web/standalone/src/wasm/ngspice-service.test.ts +++ b/web/standalone/src/wasm/ngspice-service.test.ts @@ -7,55 +7,18 @@ vi.mock("./wasm-assets", () => ({ import { installNgspiceService, + type NgspiceEvent, type NgspiceRequest, type NgspiceResponse, } from "./ngspice-service"; import { resolveWasmBase } from "./wasm-assets"; +import { FakeWorker, waitForWorker } from "./test-utils/fake-worker"; const mockedResolveWasmBase = vi.mocked(resolveWasmBase); const TEST_BOOT_TIMEOUT_MS = 1_000; const TEST_RESPONSE_TIMEOUT_MS = 5_000; -type MessageListener = (event: MessageEvent) => void; - -class FakeWorker { - static instances: FakeWorker[] = []; - - onmessage: MessageListener | null = null; - onerror: ((event: ErrorEvent) => void) | null = null; - onmessageerror: ((event: MessageEvent) => void) | null = null; - readonly postMessage = vi.fn(); - readonly terminate = vi.fn(); - private readonly messageListeners = new Set(); - - constructor() { - FakeWorker.instances.push(this); - } - - addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { - if (type === "message") this.messageListeners.add(listener as MessageListener); - } - - removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void { - if (type === "message") this.messageListeners.delete(listener as MessageListener); - } - - emitMessage(data: unknown): void { - const event = { data } as MessageEvent; - this.onmessage?.(event); - for (const listener of [...this.messageListeners]) listener(event); - } - - emitError(message: string): void { - this.onerror?.({ message } as ErrorEvent); - } - - emitMessageError(): void { - this.onmessageerror?.({} as MessageEvent); - } -} - const commandRequest = (cmd = "run"): NgspiceRequest => ({ kind: "command", cmd }); const service = () => { @@ -64,11 +27,6 @@ const service = () => { return installed; }; -async function waitForWorker(index: number): Promise { - await vi.waitFor(() => expect(FakeWorker.instances.length).toBeGreaterThan(index)); - return FakeWorker.instances[index]!; -} - async function readyRequest( workerIndex: number, requestBody: NgspiceRequest = commandRequest(), @@ -454,6 +412,142 @@ describe("ngspice service worker lifetime", () => { await expect(first.request).resolves.toEqual({ ret: 0 }); }); + it("acks a frame whose handler throws — one throw must not leak credit", async () => { + // E-19: the host takes transport ownership at onmessage; the sharedspice + // client deliberately rethrows non-trap errors, and each throw that + // escaped before the ack leaked one unit of the worker's 64-frame credit + // window until the stream died with a misattributed overload. + const first = await readyRequest(0); + globalThis.__ngspiceOnEvent = () => { + throw new Error("plot apply bug"); + }; + + expect(() => first.worker.emitMessage({ + evt: { kind: "char", lines: ["boom"] }, + eventSequence: 1, + eventBytes: 32, + }), "the handler throw keeps propagating (trap machinery must see it)") + .toThrow("plot apply bug"); + + const acks = () => first.worker.postMessage.mock.calls + .map((call) => (call[0] as { eventAck?: { sequence: number; bytes: number } }).eventAck) + .filter(Boolean); + expect(acks(), "the frame is acked despite the throwing handler") + .toEqual([{ sequence: 1, bytes: 32 }]); + + // The stream stays live: a later frame delivers and acks normally. + const events: string[] = []; + globalThis.__ngspiceOnEvent = (event) => events.push(event.lines?.[0] ?? event.kind); + first.worker.emitMessage({ + evt: { kind: "char", lines: ["after"] }, + eventSequence: 2, + eventBytes: 33, + }); + expect(events).toEqual(["after"]); + expect(acks()).toEqual([ + { sequence: 1, bytes: 32 }, + { sequence: 2, bytes: 33 }, + ]); + + first.worker.emitMessage({ id: first.id, res: { ret: 0 } }); + await expect(first.request).resolves.toEqual({ ret: 0 }); + }); + + it("acks the live frame exactly once when the queued-frame drain throws", async () => { + // E-19, drain path: a throw while draining the pre-handler queue aborts + // delivery, but the live frame's credit was owned at onmessage — its ack + // must still go out, and the queued frame (acked at enqueue) not twice. + const first = await readyRequest(0); + first.worker.emitMessage({ + evt: { kind: "char", lines: ["early"] }, + eventSequence: 1, + eventBytes: 33, + }); + globalThis.__ngspiceOnEvent = (event) => { + if (event.lines?.[0] === "early") throw new Error("drain bug"); + }; + + expect(() => first.worker.emitMessage({ + evt: { kind: "char", lines: ["live"] }, + eventSequence: 2, + eventBytes: 32, + })).toThrow("drain bug"); + + const acks = first.worker.postMessage.mock.calls + .map((call) => (call[0] as { eventAck?: { sequence: number; bytes: number } }).eventAck) + .filter(Boolean); + expect(acks, "exactly one ack per owned frame, none doubled").toEqual([ + { sequence: 1, bytes: 33 }, + { sequence: 2, bytes: 32 }, + ]); + + first.worker.emitMessage({ id: first.id, res: { ret: 0 } }); + await expect(first.request).resolves.toEqual({ ret: 0 }); + }); + + it("delivers the terminal notice's pending events, then retires, then exits", async () => { + // E-20: the worker's fatal frame carries the deferred batches it had + // already accepted (typically the diagnostics explaining the failure) — + // they must reach the handler, in order, without acks; the retirement's + // synthetic controlled-exit (E-10) follows them. + const first = await readyRequest(0); + const events: string[] = []; + globalThis.__ngspiceOnEvent = (event) => events.push(event.lines?.[0] ?? event.kind); + + first.worker.emitMessage({ + fatal: "ngspice event line exceeds 1048576 UTF-8 bytes", + pendingEvents: [ + { evt: { kind: "char", lines: ["tail diagnostics"] }, eventBytes: 42 }, + { evt: { kind: "bg", finished: true }, eventBytes: 30 }, + ], + }); + + await expect(first.request).resolves.toEqual({ + error: "ngspice_service crashed: event stream failure: " + + "ngspice event line exceeds 1048576 UTF-8 bytes", + }); + expect(events, "accepted frames first, synthetic exit last").toEqual([ + "tail diagnostics", + "bg", + "exit", + ]); + const acks = first.worker.postMessage.mock.calls + .map((call) => (call[0] as { eventAck?: unknown }).eventAck) + .filter(Boolean); + expect(acks, "terminal delivery is outside the credit protocol").toEqual([]); + }); + + it("synthesizes one controlled exit per retirement so the run mirror unlatches", async () => { + // E-10: a retired worker emits no bg/exit frame of its own; without the + // synthetic exit the sharedspice s_bgRunning mirror stays latched true + // and the simulator's Run action is disabled for the session. + const first = await readyRequest(0); + const events: NgspiceEvent[] = []; + globalThis.__ngspiceOnEvent = (event) => events.push(event); + + first.worker.emitError("wasm trap"); + await expect(first.request).resolves.toEqual({ + error: "ngspice_service crashed: wasm trap", + }); + expect(events).toEqual([ + { kind: "exit", status: 1, immediate: true, quit: false }, + ]); + + // Retirement is idempotent — a late second fault emits nothing more. + first.worker.emitError("late echo"); + expect(events).toHaveLength(1); + + // And a throwing handler must not break the retirement itself. + const second = await readyRequest(1); + globalThis.__ngspiceOnEvent = () => { + throw new Error("exit handler bug"); + }; + second.worker.emitError("second trap"); + await expect(second.request).resolves.toEqual({ + error: "ngspice_service crashed: second trap", + }); + }); + it("retires a worker whose bounded event stream reports a fatal line", async () => { const first = await readyRequest(0, commandRequest("oversize output")); first.worker.emitMessage({ diff --git a/web/standalone/src/wasm/ngspice-service.ts b/web/standalone/src/wasm/ngspice-service.ts index 147a1f8..57c6801 100644 --- a/web/standalone/src/wasm/ngspice-service.ts +++ b/web/standalone/src/wasm/ngspice-service.ts @@ -169,15 +169,24 @@ export function installNgspiceService( const frame = { generation: slot.generation, evt, sequence, bytes }; const handler = globalThis.__ngspiceOnEvent; if (handler) { - while (evtQueue.length) { - const queued = evtQueue.shift()!; - evtQueueBytes -= queued.bytes; - if (queued.generation !== slot.generation) continue; - // Queued frames were acked at enqueue (ownership was taken then). - handler(queued.evt); + // The host took transport ownership of this frame the moment it arrived + // in onmessage, so the ack must survive a throwing handler (the + // sharedspice client deliberately rethrows non-trap errors) — otherwise + // each throw leaks one unit of the worker's credit window until the + // stream dies with a misattributed overload. The throw itself keeps + // propagating: the client's trap-latch machinery needs to see it. + try { + while (evtQueue.length) { + const queued = evtQueue.shift()!; + evtQueueBytes -= queued.bytes; + if (queued.generation !== slot.generation) continue; + // Queued frames were acked at enqueue (ownership was taken then). + handler(queued.evt); + } + handler(evt); + } finally { + ackEvent(slot, frame); } - handler(evt); - ackEvent(slot, frame); } else { if (evtQueue.length >= MAX_QUEUED_EVENT_FRAMES || evtQueueBytes > MAX_QUEUED_EVENT_BYTES - bytes) { @@ -195,6 +204,31 @@ export function installNgspiceService( } }; + // The worker's terminal notice carries the deferred frames it had already + // accepted (its accepted-prefix contract — typically the last diagnostics + // explaining why the run died): deliver them best-effort, in order, WITHOUT + // acking — the fatal frame lives outside the credit protocol and the stream + // is gone. Best-effort: a throwing handler must not block later frames or + // the retirement that follows. + const deliverTerminalEvents = (entries: unknown): void => { + if (!Array.isArray(entries) || entries.length === 0) return; + const handler = globalThis.__ngspiceOnEvent; + if (!handler) { + log(`[ngspice] dropping ${entries.length} undelivered event frame(s) ` + + "from a failed stream (no handler installed)"); + return; + } + for (const entry of entries) { + const evt = (entry as { evt?: NgspiceEvent } | null)?.evt; + if (!evt) continue; + try { + handler(evt); + } catch (error) { + log(`[ngspice] terminal event delivery failed: ${String(error)}`); + } + } + }; + const failPending = (generation: number, why: string): void => { for (const [id, request] of pending) { if (request.generation !== generation) continue; @@ -237,6 +271,24 @@ export function installNgspiceService( const reject = slot.rejectBoot; slot.rejectBoot = undefined; reject?.(new Error(why)); + + // A retired worker emits no bg/exit frame of its own, so the sharedspice + // client's s_bgRunning mirror would stay latched true after a mid-run + // death — Run stays disabled and the promised fresh-worker restart is + // unreachable for the whole session. Synthesize the controlled-exit the + // crashed engine could not send. Dispatch straight to the installed + // handler, NOT through dispatchEvt: a fabricated frame must never touch + // the transport credit ledger. The client handler routes it through + // cbControlledExit (clears the mirror, reports, delivers SIM_IDLE) and + // self-drops on a dead/terminal instance. + const handler = globalThis.__ngspiceOnEvent; + if (handler) { + try { + handler({ kind: "exit", status: 1, immediate: true, quit: false }); + } catch (error) { + log(`[ngspice] synthetic exit dispatch failed: ${String(error)}`); + } + } }; const ensureWorker = (): Promise => { @@ -284,6 +336,9 @@ export function installNgspiceService( if (slot.failed || workerSlot !== slot) return; const data = e.data ?? {}; if (data.fatal) { + // Deliver the accepted-but-undelivered frames the terminal + // notice carries before retiring the generation. + deliverTerminalEvents(data.pendingEvents); failWorker(`event stream failure: ${String(data.fatal)}`); return; } diff --git a/web/standalone/src/wasm/ngspice-worker.js b/web/standalone/src/wasm/ngspice-worker.js index bf464c5f23fce2bf200e1c95a422840757d60799..251b9d2fb3105c11a088240bc35f83e092e914c5 100644 GIT binary patch delta 2792 zcmZ`*O>Z1U5G8;RGD1N?N$h}-+e zc(Xy)a^}Eg`2|RvxFGolL~!TCi7OxC$WMS*Ju_x8VYP?dneOUWuU@_CALF0gcfXyS zMiCA5p3<^!rAWxiPb=xXqt2f6yZbWURXR0MT-n_+H$D=1zPlXRN^7O}x+Jt4OG`eJ z$}S+3EJ{Bimx)q3sQ#zyx=?Ygk2X>{Cr}W{F=MEF8>! zJ^7GjaovhSl2Z>*>6#rUtwLT8K+e0{n>R@0teFYP`-&xlG)v?Ba9?UqUKO%O&a{;* zbKy~f0td$?fo33vAdUMX_S9ibI8~H6#Mh|7UBfolZ9^NKPbfp=zBPFc&>W`RLxQdrF(gy*sbaQjMxl3&rL z52>3NvIEtz(cS{*U;)3Jr&~MMFYOL*tZ%+G+(Kov9Mg`x?}v42p!o2cQ|l*~#H-uu z8yhGcHnC1tNgI+b=KkpM@1n}R>W?^GJk5o5t^CRCOFPbFULd)Ag4!TlY>EPSLW7Y> z$(1T*qO>!)BvW|^a?q;a_2|xH{Rb8{+|yYeiRMBj_-IN?RKF>Us+B3Z;(SU z5c{k2+S=jo&tHFXYD%eT6ja%%v>9u#H=0l)o!Zmg9*rsw5ULDP0xqs(qlW&~_4VP+ zo#9(pol0x5Gi4=lS2~r*u+~e9v_uXHJTeIKYGWbqsfFxhKZh*^+R*nE8XX~6W^pV_ zkFcecsk)D_KB(-U^I&1nd@-P)>SLu7mA(^9Nk%~Hx(YK1{QE`3k=md;-kR^ z)AA-Z`G-d$$PgNd<*+qK164@?G;|nz2?m8AZ9Ea*<0LwuG~*x0Fh^fXc4}lfDS)jz z(~IZNv^zpG)s;m;v#%Go`nA6Qea_`0X6u6ouFO%i&dTgBIyZZHY58eyC&7GX?=4;E z_m06lXdLGN2FEtXF2$d)X|}mU9XfhN(K0y?S1A~NGGXY;5nPEk92}aZ^G`(c%{Qps zq{DVJz(%ZB>7*m~q3}l4hu^+93}bF1!E`d<1FZ-Xs9nAB!Bq?s$f+Jdw|OQ}A=o@H zb1<7!qX9+%NsX+j_A+=}#wxPXWlb22x3&#xVF_{)lqNUZ1!2Y+-B1vs36u%PpgX+K z=w`%32?5AeWoaxcUGLS6j<&SHkp{CbduLYXY@V*r3a!q$JRQ_N&yeTWT?X%!oc%R8 zJEPv|Q~wzvYl!^9?CsvoN52{cQ-gWH&tq9InGKN`4m^5!BDi)Ea=X@tb5@*tGe73@ z?{G75YlcA|Mmp{yG27pSn@oY;60Pb8*AYK8=mV>mev!gPNX*A-up|;*+g*d|aS53_ zwgm$-FCD>1aWv*Xvakx+F&E~8u`>wJCCU;tX!4k1i>7z3-gujDEexQ&GC}vkh8Dz< z`8Y?pHOa)8Pz@(peQ43^tB0Bv9x7HuM_aA?DUMN>)Z$yhHp{dVsWh!9N7cbI5qnoE@ zpGN17t^Oi9_uSEHw@Pv_Kv8ZiuJk~nD)1fw zm1u-9qQa?7YyvXTOh*2cKRObZ&@5K3#qBAAmz@JJQFDqS_?mLIotzdizRO*uk^i8#-*sN=q69s ({ import { installOccService, type OccResponse } from "./occ-service"; import { collectBoardModelFiles } from "./libs/models-bridge"; import { resolveWasmBase } from "./wasm-assets"; +import { FakeWorker, waitForWorker } from "./test-utils/fake-worker"; const mockedCollectBoardModelFiles = vi.mocked(collectBoardModelFiles); const mockedResolveWasmBase = vi.mocked(resolveWasmBase); @@ -20,45 +21,6 @@ const TEST_MODEL_PREFETCH_TIMEOUT_MS = 500; const TEST_BOOT_TIMEOUT_MS = 1_000; const TEST_RESPONSE_TIMEOUT_MS = 5_000; -type MessageListener = (event: MessageEvent) => void; - -class FakeWorker { - static instances: FakeWorker[] = []; - - onmessage: MessageListener | null = null; - onerror: ((event: ErrorEvent) => void) | null = null; - onmessageerror: ((event: MessageEvent) => void) | null = null; - readonly postMessage = vi.fn(); - readonly terminate = vi.fn(); - private readonly messageListeners = new Set(); - - constructor() { - FakeWorker.instances.push(this); - } - - addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { - if (type === "message") this.messageListeners.add(listener as MessageListener); - } - - removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void { - if (type === "message") this.messageListeners.delete(listener as MessageListener); - } - - emitMessage(data: unknown): void { - const event = { data } as MessageEvent; - this.onmessage?.(event); - for (const listener of [...this.messageListeners]) listener(event); - } - - emitError(message: string): void { - this.onerror?.({ message } as ErrorEvent); - } - - emitMessageError(): void { - this.onmessageerror?.({} as MessageEvent); - } -} - const loadRequest = () => ({ kind: "loadModel" as const, bytes: new Uint8Array([1, 2, 3]), @@ -78,11 +40,6 @@ const service = () => { return installed; }; -async function waitForWorker(index: number): Promise { - await vi.waitFor(() => expect(FakeWorker.instances.length).toBeGreaterThan(index)); - return FakeWorker.instances[index]!; -} - async function readyRequest(workerIndex: number): Promise<{ worker: FakeWorker; request: Promise; @@ -255,9 +212,55 @@ describe("OCC service worker lifetime", () => { expect(worker.postMessage).toHaveBeenCalledTimes(1); worker.emitMessage({ id, res: { ok: true, report: "exported" } }); + // The timeout is no longer silent at the headline level: the export + // report carries the omission note. (The mock feeds no progress sink, + // hence the 0-of-0 counts here.) await expect(request).resolves.toEqual({ ok: true, - report: "exported", + report: "exported\nmodel prefetch timed out after " + + `${TEST_MODEL_PREFETCH_TIMEOUT_MS} ms — 0 of 0 model(s) omitted`, + fileName: undefined, + }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("ships the partial prefetch on timeout and reports the omission", async () => { + // E-21: a slow-but-alive prefetch used to be all-or-nothing — the 30s + // deadline discarded every model already collected and the export + // completed under a bare "Export complete." A timeout must ship the + // accepted partials and surface the omission in the report. + vi.useFakeTimers(); + mockedCollectBoardModelFiles.mockImplementationOnce( + (_board, _concurrency, _signal, progress) => { + if (progress) { + progress.totalRefs = 3; + progress.models.push( + { path: "PartialA.3dshapes/a.step", bytes: new Uint8Array([1]) }, + { path: "PartialB.3dshapes/b.step", bytes: new Uint8Array([2]) }, + ); + } + return new Promise(() => undefined); // hangs past the deadline + }, + ); + + const request = service().request(exportRequest()); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(TEST_MODEL_PREFETCH_TIMEOUT_MS); + const worker = FakeWorker.instances[0]!; + expect(worker).toBeDefined(); + worker.emitMessage({ ready: true }); + await vi.advanceTimersByTimeAsync(0); + const [{ id, req: dispatched }] = worker.postMessage.mock.calls[0] as [ + { id: number; req: { models: Array<{ path: string }> } }, + ]; + expect(dispatched.models.map((m) => m.path), "the accepted partials ship") + .toEqual(["PartialA.3dshapes/a.step", "PartialB.3dshapes/b.step"]); + + worker.emitMessage({ id, res: { ok: true, report: "Export complete." } }); + await expect(request).resolves.toEqual({ + ok: true, + report: "Export complete.\nmodel prefetch timed out after " + + `${TEST_MODEL_PREFETCH_TIMEOUT_MS} ms — 1 of 3 model(s) omitted`, fileName: undefined, }); expect(vi.getTimerCount()).toBe(0); diff --git a/web/standalone/src/wasm/occ-service.ts b/web/standalone/src/wasm/occ-service.ts index 2436496..ebca856 100644 --- a/web/standalone/src/wasm/occ-service.ts +++ b/web/standalone/src/wasm/occ-service.ts @@ -1,5 +1,5 @@ import { downloadBytes } from "@/lib/download"; -import { collectBoardModelFiles, type BoardModelFile } from "./libs/models-bridge"; +import { collectBoardModelFiles, type BoardModelFile, type CollectProgress } from "./libs/models-bridge"; // The worker-side wrapper as text (vite ?raw): one shared source of truth, // also injected by the e2e harness stub (tests/kicad/utils/occ-service.ts). import occWorkerSource from "./occ-worker.js?raw"; @@ -291,7 +291,7 @@ export function installOccService( const prefetchBoardModels = async ( board: Uint8Array, - ): Promise => { + ): Promise<{ models: BoardModelFile[]; note?: string }> => { type Outcome = | { kind: "ready"; models: BoardModelFile[] } | { kind: "failed"; error: unknown } @@ -300,12 +300,18 @@ export function installOccService( // collectBoardModelFiles keeps its own bounded network parallelism and does // no editor-native work. The controller owns this exact optional // collection: a timeout stops it from selecting more models and makes its - // already-started source results inert. + // already-started source results inert. The progress sink receives every + // accepted model as it lands — on timeout the partial set still ships + // (an aborted collection can never be awaited: an in-flight source fetch + // is not abortable), and the omission is surfaced in the export report + // instead of silently exporting without models. const controller = new AbortController(); + const progress: CollectProgress = { totalRefs: 0, models: [] }; const collected: Promise = collectBoardModelFiles( new TextDecoder().decode(board), 6, controller.signal, + progress, ).then( (models) => ({ kind: "ready", models }), (error) => ({ kind: "failed", error }), @@ -327,25 +333,29 @@ export function installOccService( const outcome = await Promise.race([collected, deadline]); if (timer !== undefined) clearTimeout(timer); - if (outcome.kind === "ready") return outcome.models; + if (outcome.kind === "ready") return { models: outcome.models }; if (!controller.signal.aborted) { controller.abort( new DOMException("OCC model prefetch retired", "AbortError"), ); } if (outcome.kind === "failed") { - log(`[occ] model prefetch failed (exporting without models): ${outcome.error}`); - } else { - log( - `[occ] model prefetch timed out after ${modelPrefetchTimeoutMs} ms ` + - "(exporting without models)", - ); + const note = + `model prefetch failed — exported without models: ${String(outcome.error)}`; + log(`[occ] ${note}`); + return { models: [], note }; } - return []; + const models = [...progress.models]; + const note = + `model prefetch timed out after ${modelPrefetchTimeoutMs} ms — ` + + `${progress.totalRefs - models.length} of ${progress.totalRefs} model(s) omitted`; + log(`[occ] ${note}`); + return { models, note }; }; const request = async (req: OccRequest): Promise => { let prepared: OccRequest; + let prefetchNote: string | undefined; if (req.kind === "export") { // Capture the caller-owned request fields before the first await and // build a private dispatch object. A late optional prefetch can then @@ -356,8 +366,10 @@ export function installOccService( // Ship the board's lib model bodies with the request: the worker's // EXPORTER_STEP resolves them from its own MEMFS (delivery gap doc: // docs/features/3d-models/0007). Best-effort — an export without - // models still succeeds, each miss reported by the exporter. - const models = await prefetchBoardModels(board); + // models still succeeds, each miss reported by the exporter — but a + // curtailed prefetch is surfaced in the export report (E-21). + const { models, note } = await prefetchBoardModels(board); + prefetchNote = note; if (models.length) log(`[occ] shipping ${models.length} board model(s) with the export`); prepared = { kind: "export", board, jobJson, fileName, models }; @@ -387,7 +399,12 @@ export function installOccService( downloadBytes(name, res.bytes); log(`[occ] export downloaded: ${name} (${res.bytes.length} bytes)`); } - return { ok: res.ok, report: res.report, fileName: res.fileName }; + // A curtailed prefetch reaches the user through the export report + // dialog, not only the console. + const report = prefetchNote + ? (res.report ? `${res.report}\n${prefetchNote}` : prefetchNote) + : res.report; + return { ok: res.ok, report, fileName: res.fileName }; } return res; diff --git a/web/standalone/src/wasm/scheduler-shim.test.ts b/web/standalone/src/wasm/scheduler-shim.test.ts index 7973110..ca9b4e8 100644 --- a/web/standalone/src/wasm/scheduler-shim.test.ts +++ b/web/standalone/src/wasm/scheduler-shim.test.ts @@ -61,6 +61,9 @@ function loadShim(opts: { busy: () => boolean }) { g.Module = { kicadOpenFileBusy: opts.busy, kicadCollabApplyItems: (x: unknown) => `applied:${String(x)}`, + // Headless stack ops so parked waits and the resume pump run under vitest. + stackSave: () => 0, + stackRestore: () => {}, }; // eslint-disable-next-line no-eval (0, eval)(readFileSync(SHIM_PATH, "utf8")); @@ -312,13 +315,18 @@ describe("E-8: runWaitCompletion admission gate for worker completions", () => { } }); - it("classifies cross-realm trap strings as terminal too", () => { + it("classifies a realm-crossed trap by its RuntimeError name", () => { + // An error object relayed across a realm loses its instanceof identity + // but keeps its name. (The old message-substring sniff is gone — see the + // false-positive gate below.) const S = loadShim({ busy: () => false }); const err = vi.spyOn(console, "error").mockImplementation(() => {}); try { const token = S.beginWait("occ"); + const crossed = new Error("unreachable"); + crossed.name = "RuntimeError"; S.runWaitCompletion("cross-realm trap", token, () => { - throw new Error("RuntimeError: unreachable"); + throw crossed; }); expect(S.terminal).toBe(true); expect(S.waitEarlyResolved(token)).toBe(0); @@ -327,6 +335,89 @@ describe("E-8: runWaitCompletion admission gate for worker completions", () => { } }); + it("a plain error QUOTING trap text does not terminalize (E-14)", async () => { + // The old classifier matched message substrings ('Aborted(', 'index out + // of bounds', …) — any plain JS error whose text merely QUOTED such + // wording permanently bricked a healthy instance. Structural signals + // only: RuntimeError instance or name. + const S = loadShim({ busy: () => false }); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const token = S.beginWait("ngspice"); + expect( + S.runWaitCompletion("relay bug", token, () => { + throw new Error( + "copy failed: memory access out of bounds in worker payload (Aborted(…))", + ); + }, 1), + ).toBe(false); + expect(S.terminal, "a message-only match must not brick the instance").toBe(false); + expect(S.canTouchNative()).toBe(true); + await expect(S.waitPromise(token), "the wait fails with inertResult").resolves.toBe(1); + } finally { + err.mockRestore(); + } + }); + + it("bare resolveWait after a terminal latch does not resume the parked waiter (E-15)", async () => { + const S = loadShim({ busy: () => false }); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const token = S.beginWait("3d"); + let settled = false; + void S.waitPromise(token).then(() => { + settled = true; + }); + + const trapToken = S.beginWait("occ"); + S.runWaitCompletion("trap", trapToken, () => { + throw new WebAssembly.RuntimeError("unreachable"); + }); + expect(S.terminal).toBe(true); + + // The ten bare finishers (fontenum/clipboard/3d/fp-lib/…) all route + // through resolveWait — on a terminal instance it must refuse WITHOUT + // consuming the entry (the frame stays visibly parked in dump()). + expect(S.resolveWait(token, 7), "bare resolve refused on terminal").toBe(false); + expect(S.pendingWaits("3d"), "the entry is not consumed").toBe(1); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(settled, "the parked frame must not resume into the trapped module") + .toBe(false); + } finally { + err.mockRestore(); + warn.mockRestore(); + } + }); + + it("a wake already queued when terminal latches is never delivered (E-15)", async () => { + const S = loadShim({ busy: () => false }); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const token = S.beginWait("sleep"); + let settled = false; + void S.waitPromise(token).then(() => { + settled = true; + }); + // Resolve on a HEALTHY instance — the wake is now queued behind a + // microtask — then latch terminal before the pump can run it. + expect(S.resolveWait(token, 1)).toBe(true); + const trapToken = S.beginWait("occ"); + S.runWaitCompletion("trap", trapToken, () => { + throw new WebAssembly.RuntimeError("unreachable"); + }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(settled, "the queued wake must not re-enter the trapped module") + .toBe(false); + } finally { + err.mockRestore(); + warn.mockRestore(); + } + }); + it("a plain JS bug fails the wait with inertResult instead of stranding it", async () => { const S = loadShim({ busy: () => false }); const err = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/web/standalone/src/wasm/test-utils/fake-worker.ts b/web/standalone/src/wasm/test-utils/fake-worker.ts new file mode 100644 index 0000000..c96318e --- /dev/null +++ b/web/standalone/src/wasm/test-utils/fake-worker.ts @@ -0,0 +1,57 @@ +import { expect, vi } from "vitest"; + +/** + * Shared Worker test double for the service-lifetime suites (one copy — + * previously duplicated verbatim in occ-service.test.ts and + * ngspice-service.test.ts, where a fix to its event semantics applied to one + * file left the other suite validating different fake-worker behavior). + * + * Honors J-4: no synthetic `dispatchEvent` — emit* invoke the exact functions + * the service assigned to the handler attributes. + * + * (Not named *.test.ts: the vitest include glob must not collect it.) + */ +export type MessageListener = (event: MessageEvent) => void; + +export class FakeWorker { + static instances: FakeWorker[] = []; + + onmessage: MessageListener | null = null; + onerror: ((event: ErrorEvent) => void) | null = null; + onmessageerror: ((event: MessageEvent) => void) | null = null; + readonly postMessage = vi.fn(); + readonly terminate = vi.fn(); + private readonly messageListeners = new Set(); + + constructor() { + FakeWorker.instances.push(this); + } + + addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + if (type === "message") this.messageListeners.add(listener as MessageListener); + } + + removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + if (type === "message") this.messageListeners.delete(listener as MessageListener); + } + + emitMessage(data: unknown): void { + const event = { data } as MessageEvent; + this.onmessage?.(event); + for (const listener of [...this.messageListeners]) listener(event); + } + + emitError(message: string): void { + this.onerror?.({ message } as ErrorEvent); + } + + emitMessageError(): void { + this.onmessageerror?.({} as MessageEvent); + } +} + +/** Await the service's Nth Worker construction. */ +export async function waitForWorker(index: number): Promise { + await vi.waitFor(() => expect(FakeWorker.instances.length).toBeGreaterThan(index)); + return FakeWorker.instances[index]!; +} diff --git a/wxwidgets b/wxwidgets index a7436d1..4e6cc5a 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit a7436d105271815592943aebfc91f42295a74b48 +Subproject commit 4e6cc5a441e46ac07b242caa3adacfd8e7c7ec50