diff --git a/.github/workflows/wasm-build.yml b/.github/workflows/wasm-build.yml index d1a84c4ed..531c5820c 100644 --- a/.github/workflows/wasm-build.yml +++ b/.github/workflows/wasm-build.yml @@ -387,6 +387,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 @@ -401,6 +409,18 @@ jobs: npm run corpus:lint npm run tools:contract + # 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 0bf6c9c34..8b318040a 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit 0bf6c9c34e08fc2e36dfe97acad55574cbfc8cd1 +Subproject commit 8b318040aa9ba06214d7aea7fe731551fb4862ff diff --git a/scripts/common/shims/jspi-scheduler.js b/scripts/common/shims/jspi-scheduler.js index 97e660bd1..173ad2422 100644 --- a/scripts/common/shims/jspi-scheduler.js +++ b/scripts/common/shims/jspi-scheduler.js @@ -200,6 +200,13 @@ earlyWaitResolves: 0, beginWait: function (kind) { + if (this.dead || this.terminal) { + // Refuse to mint a wait an unhealthy instance can never satisfy. + // Callers treat token 0 as "not started" (the C++ bridges bail); + // a stray waitPromise(0) settles immediately and warns. + this._note("beginWaitRefused", kind, 0); + return 0; + } var token = ++this.waitSeq; var entry = { kind: kind, resolved: false, resolve: null, promise: null }; entry.promise = new Promise(function (resolve) { entry.resolve = resolve; }); @@ -241,6 +248,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]; @@ -272,6 +289,85 @@ }, dead: false, + // --- E-8: admission gate for delayed worker/MEMFS completions ----------- + // `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) + || !!(e && e.name === "RuntimeError"); + if (!isTrap) return false; + this.terminalize(site, e); + return true; + }, + // The one admission boundary for delayed completions that both touch + // native state and wake a parked waiter (the four worker/MEMFS completion + // sites: OCC export, OCC model, ngspice request, ngspice vector). + // `prepare` runs IMMEDIATELY, never queued — it owns the parked waiter's + // output pointers, and queuing it behind anything can deadlock the very + // frame this completion wakes. Disposition (every drop is loud, never + // silent): + // stale/unknown token -> drop + warn (late frame from a retired + // worker generation) + // dead or terminal instance -> drop + warn, DO NOT resolve — resolving + // resumes the suspended frame INSIDE the + // damaged module + // prepare() traps -> latch terminal, DO NOT resolve + // prepare() throws plain JS -> resolve inertResult (fail the wait + // rather than strand its parked frame in + // a healthy instance) + runWaitCompletion: function (site, token, prepare, inertResult) { + var entry = this.waits.get(token); + if (!entry || entry.resolved) { + console.warn("[wx-scheduler] " + site + ": completion for stale wait " + + token + " dropped"); + this._note("staleCompletion", site, token); + return false; + } + if (!this.canTouchNative()) { + console.warn("[wx-scheduler] " + site + ": completion dropped (" + + (this.terminal ? "terminal" : "dead") + " instance)"); + this._note("inertCompletion", site, token); + return false; + } + var result; + try { + result = prepare(); + } catch (e) { + if (this._terminalizeNativeTrap(site, e)) { + this._note("completionTrap", site, token); + return false; + } + console.error("[wx-scheduler] " + site + ": completion failed: " + e); + this._note("completionError", site, token); + this.resolveWait(token, inertResult == null ? 0 : inertResult | 0); + return false; + } + this.resolveWait(token, result | 0); + return true; + }, shutdown: function (why) { this.dead = true; // S6 teardown contract: queued-but- @@ -471,7 +567,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 77c8f021a..22b8e9ab8 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 9e9965357..c3d184f63 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 62ed2eb3a..7d8d05f51 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 840206fe6..8e7837795 100644 --- a/tests/e2e/modal.spec.ts +++ b/tests/e2e/modal.spec.ts @@ -169,9 +169,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 @@ -253,7 +253,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 f8408e595..3555f3bc8 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 accf29ee2..c7aa89055 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 a29b1e228..14bb99520 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 5f3205145..e29fa02bd 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 891d94a7d..a657b9fab 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 000000000..c20ab0e38 --- /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 2e274bb0f..14d7ac99e 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,79 +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((sch: string) => { - (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 wait for the background run to -// finish (the bg 'finished' event lands after ngspice's thread joins). -async function runSimulation(page: import('@playwright/test').Page): Promise { - const evtsBefore = await page.evaluate( - () => (window as any).__ngspiceEvents.length as number); - - // 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); - - expect(await clickByTooltip(page, 'Run Simulation', { elementType: 'tool' }), - 'Run tool').toBe(true); - - await page.waitForFunction((n: number) => { - const evts = (window as any).__ngspiceEvents as Array<{ kind: string; finished?: boolean }>; - return evts.slice(n).some((e) => e.kind === 'bg' && e.finished === true); - }, evtsBefore, { timeout: 120000 }); -} - function distinctColors(png: PNG): number { const colors = new Set(); // 8x8 grid sampling, same spirit as the 3d-viewer render check. @@ -168,7 +87,9 @@ test.describe('eeschema simulator', () => { const charText = evts.flatMap((e) => e.lines ?? []).join('\n'); expect(charText, 'no missing-model errors').not.toMatch(/unable to find definition/i); - // The plot pulled real vector data through get_vec_info. + // The exact final-refresh receipt and drained-waits check above prove + // this log entry belongs to a vector which reached the plot, not + // merely a worker response still waiting to copy into native memory. const vecPulls = await page.evaluate(() => ((window as any).__ngspiceLog as Array<{ kind: string; length?: number }>) .filter((l) => l.kind === 'get_vec_info' && (l.length ?? 0) > 100).length); @@ -197,8 +118,10 @@ test.describe('eeschema simulator', () => { await loadRectifier(page); await openSimulator(page); - await runSimulation(page); - await runSimulation(page); + const firstGeneration = await runSimulation(page); + const secondGeneration = await runSimulation(page); + expect(secondGeneration, 'the second run has its own exact generation') + .toBeGreaterThan(firstGeneration); const finishCount = await page.evaluate(() => ((window as any).__ngspiceEvents as Array<{ kind: string; finished?: boolean }>) diff --git a/tests/kicad/load-pcb.spec.ts b/tests/kicad/load-pcb.spec.ts index 7ef5f4510..e9b03dca1 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/ngspice-probe.spec.ts b/tests/kicad/ngspice-probe.spec.ts index f804cf72a..ed7970427 100644 --- a/tests/kicad/ngspice-probe.spec.ts +++ b/tests/kicad/ngspice-probe.spec.ts @@ -79,6 +79,122 @@ test.describe('ngspice_service probe', () => { expect(last, 'v(out) end value').toBeLessThanOrEqual(1.0); }); + test('request receipts scan atomically and reject pre-checkpoint responses', async ({ page }) => { + await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' }); + expect((await svcRequest(page, { kind: 'init' })).ret).toBe(0); + + const evidence = await page.evaluate(async () => { + const runtime = globalThis as any; + const hooks = runtime.__ngspiceServiceTestHooks; + if (!hooks?.requestCheckpoint || !hooks?.waitForRequestAfter) + throw new Error('exact request receipt hooks are missing'); + + // Scan path: the response already exists before the waiter starts. + const scanCheckpoint = hooks.requestCheckpoint(); + await runtime.ngspiceService.request({ kind: 'cur_plot' }); + const scanned = await hooks.waitForRequestAfter(scanCheckpoint, { + kind: 'cur_plot', + }); + + // Subscribe path and issue-sequence rule: `old` owns a sequence + // before the checkpoint even though its response can arrive later. + // It must not satisfy the waiter; only the fresh request may do so. + const old = runtime.ngspiceService.request({ kind: 'running' }); + const freshCheckpoint = hooks.requestCheckpoint(); + let waiterSettled = false; + const waited = hooks.waitForRequestAfter(freshCheckpoint, { + kind: 'running', + }).then((entry: any) => { + waiterSettled = true; + return entry; + }); + await old; + await Promise.resolve(); + const ignoredOld = !waiterSettled; + const fresh = runtime.ngspiceService.request({ kind: 'running' }); + const [subscribed] = await Promise.all([waited, fresh]); + + return { + scanCheckpoint, + scanned, + freshCheckpoint, + subscribed, + ignoredOld, + state: hooks.snapshot(), + }; + }); + + expect(evidence.scanned.sequence).toBeGreaterThan(evidence.scanCheckpoint); + expect(evidence.scanned.kind).toBe('cur_plot'); + expect(evidence.ignoredOld, + 'a late response issued before the checkpoint cannot satisfy the waiter').toBe(true); + expect(evidence.subscribed.sequence).toBeGreaterThan(evidence.freshCheckpoint); + expect(evidence.subscribed.kind).toBe('running'); + expect(evidence.state.requestReceiptWaiters, 'all exact waiters settled').toBe(0); + }); + + test('boot and runtime worker decode faults settle exactly and recover', async ({ page }) => { + await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' }); + + await page.evaluate(() => { + (globalThis as any).__ngspiceServiceTestHooks.messageErrorDuringNextBoot(); + }); + const bootFailure = await svcRequest(page, { kind: 'init' }); + expect(bootFailure.error, 'the first boot request must settle').toContain( + 'message decode failed', + ); + let state = await page.evaluate( + () => (globalThis as any).__ngspiceServiceTestHooks.snapshot(), + ); + expect(state).toMatchObject({ + activeGeneration: null, + pending: 0, + retiredGenerations: [1], + bootFaultArmed: false, + }); + + const recoveredBoot = await svcRequest(page, { kind: 'init' }); + expect(recoveredBoot.error, 'generation 2 must boot normally').toBeUndefined(); + expect(recoveredBoot.ret, 'generation 2 ngSpice_Init').toBe(0); + + // Two calls share one live worker generation. Fault only after both + // real messages have been posted, proving the transport remains + // concurrent and fail-all settles every exact request. + const runtimeFailures = await page.evaluate(async () => { + const runtime = globalThis as any; + runtime.__ngspiceServiceTestHooks.messageErrorWhenPendingAtLeast(2); + return await Promise.all([ + runtime.ngspiceService.request({ kind: 'running' }), + runtime.ngspiceService.request({ kind: 'cur_plot' }), + ]); + }); + expect(runtimeFailures).toHaveLength(2); + for (const result of runtimeFailures) { + expect(result.error, 'every generation-2 request must settle').toContain( + 'message decode failed', + ); + } + + state = await page.evaluate( + () => (globalThis as any).__ngspiceServiceTestHooks.snapshot(), + ); + expect(state.maxPending, 'requests are posted concurrently').toBeGreaterThanOrEqual(2); + expect(state).toMatchObject({ + activeGeneration: null, + pending: 0, + retiredGenerations: [1, 2], + runtimeFaultArmed: false, + }); + + const recoveredRuntime = await svcRequest(page, { kind: 'init' }); + expect(recoveredRuntime.error, 'generation 3 must recover').toBeUndefined(); + expect(recoveredRuntime.ret, 'generation 3 ngSpice_Init').toBe(0); + state = await page.evaluate( + () => (globalThis as any).__ngspiceServiceTestHooks.snapshot(), + ); + expect(state).toMatchObject({ activeGeneration: 3, pending: 0 }); + }); + test('XSPICE code model resolves through the static registry', async ({ page }) => { await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' }); diff --git a/tests/kicad/occ-export-models.spec.ts b/tests/kicad/occ-export-models.spec.ts index c3202876d..9e5f365ed 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 7cd7214f8..f6e3352d8 100644 --- a/tests/kicad/occ-export.spec.ts +++ b/tests/kicad/occ-export.spec.ts @@ -1,21 +1,11 @@ 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 { waitForBoardLoaded } 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 { openBoardProgrammatically } from './utils/board-ready'; +import { findWxButton, clickWxButton, openStepExportDialog, dismissReportDialog } from './utils/wx-dialogs'; /** * STEP export through the occ_service worker (docs/features/occ-split/): @@ -46,61 +36,16 @@ async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/${proFilename}`, `${PROJECT_DIR_MEMFS}/${proFilename}`); - expect(await clickMenuBarItem(page, 'File'), 'File menu should be findable').toBe(true); - await waitForMenuItems(page); - // Items register progressively while the popup paints — wait for the one - // we click (clickMenuItem is single-shot; the >3-items gate isn't enough). - await waitForRenderedByLabel(page, 'Open...', { elementType: 'menuitem' }); - expect(await clickMenuItem(page, 'Open...'), 'Open… menu item should be findable').toBe(true); - - await page.waitForFunction(() => { - const registry = window.wxElementRegistry; - return !!registry && registry.findAll({ visible: true }) - .some((el) => el.typeName === 'wxFileDialog'); - }, null, { timeout: 15000 }); - // Wait for the filename text input to paint (the dialog object exists before its - // inner controls register; replaces a fixed 1000ms). - await waitUntil(page, () => { - const r = window.wxElementRegistry; - return !!r && r.findAll({ visible: true }).some((el) => el.typeName === 'wxTextCtrl' && el.name === 'text'); - }, 'file dialog filename input'); - - const filenameInput = await page.evaluate(() => { - const registry = window.wxElementRegistry; - if (!registry) return null; - const text = registry.findAll({ visible: true }) - .find((el) => el.typeName === 'wxTextCtrl' && el.name === 'text'); - return text ? { x: text.centerX, y: text.centerY } : null; - }); - expect(filenameInput, 'filename text input should be visible').not.toBeNull(); - if (!filenameInput) throw new Error('filename text input not found'); - - 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.keyboard.type(pcbFilename); - await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell - await page.keyboard.press('Enter'); - - const result = await waitForBoardLoaded(page, testLogger, 60000); + const result = await openBoardProgrammatically( + page, + `${PROJECT_DIR_MEMFS}/${pcbFilename}`, + DEMO.stem, + 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; -} - test.describe('OCC export via occ_service worker', () => { test.describe.configure({ mode: 'serial' }); test.setTimeout(240000); @@ -126,24 +71,8 @@ test.describe('OCC export via occ_service worker', () => { expect(occFetches, 'occ_service must NOT be fetched before the export').toHaveLength(0); - // File → Export → STEP/GLB/… - 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); - - // The (unchanged) DIALOG_EXPORT_STEP: wait for its Export button. - 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 }); + // File → Export → STEP/GLB/… and the unchanged export dialog. + await openStepExportDialog(page); await stableShot(page, 'occ-export-dialog.png'); expect(await clickWxButton(page, 'Export'), 'Export button click').toBe(true); @@ -167,12 +96,178 @@ 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'); }); + + test('worker decode fault settles concurrent native wait and the next export recovers', async ({ page, testLogger }) => { + await page.goto('/kicad/pcbnew.html'); + await waitForEditorReady(page); + await loadBoard(page, testLogger); + // The exact open Promise and title/paint helper have completed. Keep a + // byte-stable baseline before opening a nested submenu. + await settledShot(page.locator('#canvas'), expect); + await openStepExportDialog(page); + + const probeBoard = fs.readFileSync( + path.resolve(__dirname, '..', 'fixtures', 'demo', 'demo.kicad_pcb'), + 'utf8', + ); + + // Start one direct service request before the dialog's native request. + // Both wait on the same lazy worker boot, then both post without a host + // mutex. The harness injects a messageerror only when both are in the + // generation's pending map, and the real worker is then terminated. + await page.evaluate((boardText: string) => { + const runtime = globalThis as any; + runtime.__occServiceTestHooks.messageErrorWhenPendingAtLeast(2); + runtime.__occParallelResult = null; + void runtime.occService.request({ + kind: 'export', + board: new TextEncoder().encode(boardText), + jobJson: JSON.stringify({ format: 'step', export_components: false }), + fileName: 'parallel-probe.step', + }).then((res: unknown) => { runtime.__occParallelResult = res; }); + }, probeBoard); + + expect(await clickWxButton(page, 'Export'), 'first native Export button click').toBe(true); + + await page.waitForFunction( + () => (globalThis as any).__occParallelResult !== null, + null, + { timeout: 30000 }, + ); + const parallelResult = await page.evaluate( + () => (globalThis as any).__occParallelResult as { ok: boolean; report?: string }, + ); + expect(parallelResult.ok, 'the parallel request must settle on generation failure').toBe(false); + expect(parallelResult.report, 'the exact messageerror reason reaches the caller') + .toContain('message decode failed'); + + // The C++ Export() request was the second request in the same failed + // generation. Its exact wx wait must close and show the native failure + // dialog instead of leaving the export handler parked. + await page.waitForFunction(() => { + const registry = window.wxElementRegistry; + if (!registry) return false; + const dialogs = registry.findAll({ visible: true }) + .filter((el) => /Dialog/.test(el.typeName ?? '')); + return dialogs.length >= 2; + }, null, { timeout: 30000 }); + await expect.poll( + () => page.evaluate(() => { + const scheduler = (globalThis as any).__wxScheduler; + return scheduler?.pendingWaits?.('occ') ?? -1; + }), + { message: 'the native OCC wait must be completed by fail-all', timeout: 10000 }, + ).toBe(0); + + const failed = await page.evaluate(() => { + const runtime = globalThis as any; + const rendered = runtime.wxElementRegistry?.findAllRendered?.({}) ?? []; + return { + labels: rendered.map((el: any) => el.label ?? el.text ?? '').filter(Boolean), + service: runtime.__occServiceTestHooks.snapshot(), + }; + }); + expect(failed.service.maxPending, + 'two requests must coexist in one generation; worker requests are not serialized') + .toBeGreaterThanOrEqual(2); + expect(failed.service.requestsPosted, + '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').toHaveLength(1); + expect(failed.service.pending, 'fail-all must drain the failed generation').toBe(0); + 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)}`); + + // Resolve the parent action before dismissing the child, then reuse its + // exact DOM identity. This prevents a label/geometry re-query from + // 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(); + expect(retryExport!.x, 'the retry target has stable geometry').toBeGreaterThan(0); + expect(retryExport!.y, 'the retry target has stable geometry').toBeGreaterThan(0); + + expect(await clickWxButton(page, 'OK'), 'dismiss native export failure').toBe(true); + + // page.mouse.click() completes when the browser has delivered the OK + // input, not when the nested native modal has unwound. The parent + // export dialog is intentionally non-interactive until that exact + // child lease closes. Wait for the scheduler's observable modal + // count to return from {export + failure} to {export} before clicking + // through to the parent. + await expect.poll( + () => page.evaluate(() => { + const scheduler = (globalThis as any).__wxScheduler; + return scheduler?.pendingWaits?.('modal') ?? -1; + }), + { message: 'the failure child must retire before retrying its parent', timeout: 10000 }, + ).toBe(1); + + // The export dialog remains open. Its next request must create a fresh + // 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 page.mouse.click(retryExport.x, retryExport.y); + await expect.poll( + () => page.evaluate(() => (globalThis as any) + .__occServiceTestHooks.snapshot().requestsStarted), + { + message: 'the exact parent retry must enter the OCC provider once', + timeout: 10000, + }, + ).toBe(failed.service.requestsStarted + 1); + await expect.poll( + () => page.evaluate(() => (globalThis as any) + .__occServiceTestHooks.snapshot().activeGeneration), + { message: 'the retry must boot a replacement worker generation', timeout: 30000 }, + ).toBe(2); + await page.waitForFunction( + () => ((window as any).__occExports?.length ?? 0) === 1, + null, + { timeout: 180000 }, + ); + + const recovered = await page.evaluate(() => { + const runtime = globalThis as any; + return { + exports: runtime.__occExports, + service: runtime.__occServiceTestHooks.snapshot(), + schedulerDead: runtime.__wxScheduler?.dead === true, + occWaits: runtime.__wxScheduler?.pendingWaits?.('occ') ?? -1, + }; + }); + expect(recovered.exports).toHaveLength(1); + expect(recovered.exports[0].magic.startsWith('ISO-10303-21'), 'retry returns real STEP bytes') + .toBe(true); + expect(recovered.exports[0].size, 'retry returns a non-trivial STEP file') + .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(failed.service.requestsStarted + 1); + expect(recovered.service.requestsPosted, + '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') + .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); + + // 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 000000000..8b3948299 --- /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 c126b51b3..e5590bd67 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 33c2dbdb8..f8fd52889 100644 --- a/tests/kicad/utils/board-ready.ts +++ b/tests/kicad/utils/board-ready.ts @@ -1,4 +1,9 @@ import type { Page } from '@playwright/test'; +import { waitForCanvasStable } from '../../e2e/utils/element-tracker'; +import { assertNoNativeFailure, findNativeFailure } from './native-failure'; + +export type { RuntimeLogger } from './native-failure'; +import type { RuntimeLogger } from './native-failure'; /** * Wait for pcbnew to finish opening a board. @@ -31,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}`); } @@ -69,3 +71,60 @@ export async function waitForBoardLoaded( throw new Error(`Timed out waiting for board to load after ${timeoutMs}ms`); } + +function assertExpectedBoard(expectedBoard: string): void { + if (!expectedBoard.trim()) { + throw new Error('Expected board identity must not be empty'); + } +} + +async function waitForBoardIdentityAndPaint( + page: Page, + expectedBoard: string, + timeoutMs: number, +): Promise { + assertExpectedBoard(expectedBoard); + await page.waitForFunction( + (expected: string) => { + const titleMatches = document.title.toLocaleLowerCase() + .includes(expected.toLocaleLowerCase()); + const hasPcbFrame = (window.wxElementRegistry?.findAll({ visible: true }) ?? []) + .some((element) => element.name === 'PcbFrame'); + return titleMatches && hasPcbFrame; + }, + expectedBoard, + { timeout: timeoutMs }, + ); + await waitForCanvasStable(page, '#canvas', { timeout: timeoutMs }); +} + +/** + * Use the shell's exact owned-open Promise, then prove document identity and + * paint. No PcbFrame/no-dialog heuristic is involved. (Ported from the codex + * line; owner-free — the barrier-based waitForUiBoardReady was NOT taken.) + */ +export async function openBoardProgrammatically( + page: Page, + path: string, + expectedBoard: string, + logger?: RuntimeLogger, + timeoutMs = 60000, +): Promise { + assertExpectedBoard(expectedBoard); + const opened = await page.evaluate(async (boardPath: string) => { + const runtime = window as unknown as { + Module?: { kicadOpenFile?(path: string): Promise | boolean }; + }; + if (typeof runtime.Module?.kicadOpenFile !== 'function') { + throw new Error('Module.kicadOpenFile is not installed'); + } + return await runtime.Module.kicadOpenFile(boardPath); + }, path); + if (opened !== true) { + throw new Error(`Module.kicadOpenFile did not open ${path}: ${String(opened)}`); + } + assertNoNativeFailure(logger, `opening ${expectedBoard}`); + await waitForBoardIdentityAndPaint(page, expectedBoard, timeoutMs); + assertNoNativeFailure(logger, `painting ${expectedBoard}`); + return `opened and painted ${expectedBoard} from exact kicadOpenFile Promise`; +} diff --git a/tests/kicad/utils/native-failure.ts b/tests/kicad/utils/native-failure.ts new file mode 100644 index 000000000..7dc2e4598 --- /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 9b646d432..8f88f2bdf 100644 --- a/tests/kicad/utils/ngspice-service.ts +++ b/tests/kicad/utils/ngspice-service.ts @@ -21,7 +21,16 @@ import type { Page } from '@playwright/test'; * forwarded to globalThis.__ngspiceOnEvent (the editor client stub's * dispatcher, when integrated) — specs assert live streaming by comparing * event timestamps against run boundaries; - * - request/response summaries are appended to window.__ngspiceLog. + * - request/response summaries are appended to window.__ngspiceLog. Each + * carries the sequence assigned when the request was issued. The test hook + * offers a scan-then-subscribe receipt so a response cannot land in the + * gap between an array scan and listener installation; + * - Worker generations own their Blob URL, boot deadline, response deadlines, + * pending calls, and queued events. Retirement settles and cleans only that + * exact generation; + * - the native simulator publishes its run generation only after the final + * plot, operating-point, and canvas refresh calls. The harness stores an + * atomic scan-then-subscribe receipt for that applied generation. * * The worker fetches ngspice_service.js lazily on the FIRST request — specs * assert the lazy-load boundary by watching network requests. @@ -32,103 +41,598 @@ const NGSPICE_WORKER_SRC = fs.readFileSync( 'web', 'standalone', 'src', 'wasm', 'ngspice-worker.js'), 'utf8'); -export async function installNgspiceServiceStub(page: Page): Promise { - await page.addInitScript((workerSrc: string) => { +export interface NgspiceHarnessWatchdogs { + bootTimeoutMs?: number; + responseTimeoutMs?: number; +} + +export async function installNgspiceServiceStub( + page: Page, + watchdogs: NgspiceHarnessWatchdogs = {}, +): Promise { + const validDeadline = (value: number | undefined, fallback: number, name: string): number => { + const deadline = value ?? fallback; + if (!Number.isSafeInteger(deadline) || deadline < 1) + throw new Error(`${name} must be a positive safe integer`); + return deadline; + }; + const bootTimeoutMs = validDeadline(watchdogs.bootTimeoutMs, 2 * 60_000, 'bootTimeoutMs'); + const responseTimeoutMs = validDeadline( + watchdogs.responseTimeoutMs, + 30 * 60_000, + 'responseTimeoutMs', + ); + + await page.addInitScript((options: { + workerSrc: string; + bootTimeoutMs: number; + responseTimeoutMs: number; + }) => { if ((globalThis as any).ngspiceService) return; + const { workerSrc, bootTimeoutMs, responseTimeoutMs } = options; + const t0 = Date.now(); (window as any).__ngspiceEvents = []; (window as any).__ngspiceLog = []; - let workerP: Promise | null = null; - const pending = new Map void>(); + interface WorkerSlot { + generation: number; + worker?: Worker; + workerUrl?: string; + failed: boolean; + ready: Promise; + bootTimer?: ReturnType; + rejectBoot?: (reason?: unknown) => void; + removeBootListener?: () => void; + /** The exact lifecycle transition used by Worker.onmessageerror. */ + failDecode: () => void; + } + interface PendingRequest { + generation: number; + resolve: (res: any) => void; + timer: ReturnType; + } + interface RequestSummary { + sequence: number; + kind: string; + cmd?: string; + name?: string; + ret?: number; + error?: string; + length?: number; + t: number; + } + interface RequestCriteria { + kind?: string; + name?: string; + minimumLength?: number; + } + interface AppliedGenerationReceipt { + generation: number; + t: number; + } + + const RECEIPT_TIMEOUT_MS = 2 * 60_000; + const MAX_RECEIPT_TIMEOUT_MS = 5 * 60_000; + const MAX_RECEIPT_WAITERS = 128; + + let workerSlot: WorkerSlot | null = null; + let nextGeneration = 1; + const pending = new Map(); let nextId = 1; + let maxPending = 0; + let bootMessageErrorArmed = false; + let runtimeMessageErrorThreshold: number | null = null; + let dieOnNextBgRunArmed = false; + let corruptNextGetVecArmed = false; + const retiredGenerations: number[] = []; + let nextRequestSequence = 1; + const appliedGenerations: AppliedGenerationReceipt[] = []; + let disposed = false; + + const validateReceiptTimeout = (timeoutMs: number): string | undefined => { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 + || timeoutMs > MAX_RECEIPT_TIMEOUT_MS) { + return `receipt timeout must be an integer from 1 to ${MAX_RECEIPT_TIMEOUT_MS} ms`; + } + return undefined; + }; + + const requestMatches = ( + summary: RequestSummary, + after: number, + criteria: RequestCriteria, + ): boolean => summary.sequence > after + && summary.error === undefined + && (criteria.kind === undefined || summary.kind === criteria.kind) + && (criteria.name === undefined || summary.name === criteria.name) + && (criteria.minimumLength === undefined + || (summary.length ?? -1) >= criteria.minimumLength); + + 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 evtQueue: any[] = []; - const dispatchEvt = (evt: any) => { + 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); + requestReceipts.publish(summary); + }; + + const waitForRequestAfter = ( + after: number, + criteria: RequestCriteria, + timeoutMs = RECEIPT_TIMEOUT_MS, + ): Promise => requestReceipts.wait(after, criteria, timeoutMs); + + const waitForAppliedGenerationAfter = ( + after: number, + timeoutMs = RECEIPT_TIMEOUT_MS, + ): Promise => appliedReceipts.wait(after, undefined, timeoutMs); + + const previousAppliedHook = (globalThis as any).__pcbjamNgspiceFinalRefreshApplied; + const publishAppliedGeneration = (generation: number): void => { + if (disposed) return; + if (!Number.isSafeInteger(generation) || generation < 1) { + console.error(`[TEST-NGSPICE] ignored invalid applied generation ${generation}`); + return; + } + const previousGeneration = appliedGenerations.length + ? appliedGenerations[appliedGenerations.length - 1]!.generation + : 0; + if (generation <= previousGeneration) { + console.error(`[TEST-NGSPICE] ignored stale applied generation ${generation}`); + return; + } + const receipt = { generation, t: Date.now() - t0 }; + appliedGenerations.push(receipt); + appliedReceipts.publish(receipt); + if (typeof previousAppliedHook === 'function') previousAppliedHook(generation); + }; + (globalThis as any).__pcbjamNgspiceFinalRefreshApplied = publishAppliedGeneration; + + interface QueuedEventFrame { + generation: number; + evt: any; + sequence: number; + bytes: number; + } + const MAX_QUEUED_EVENT_FRAMES = 64; + const MAX_QUEUED_EVENT_BYTES = 8 * 1024 * 1024; + const evtQueue: QueuedEventFrame[] = []; + let evtQueueBytes = 0; + const ackEvent = (slot: WorkerSlot, frame: QueuedEventFrame): boolean => { + if (slot.failed || workerSlot !== slot || !slot.worker) return false; + try { + slot.worker.postMessage({ + eventAck: { sequence: frame.sequence, bytes: frame.bytes }, + }); + return true; + } catch (error) { + retireWorker( + slot, + `ngspice_service event acknowledgment failed: ${String(error)}`, + ); + return false; + } + }; + const dispatchEvt = ( + slot: WorkerSlot, + evt: any, + sequence: number, + bytes: number, + ) => { + if (slot.failed || workerSlot !== slot) return; + if (!Number.isSafeInteger(sequence) || sequence < 1 + || !Number.isSafeInteger(bytes) || bytes < 1 + || bytes > MAX_QUEUED_EVENT_BYTES) { + retireWorker(slot, 'ngspice_service sent invalid event-frame credit'); + return; + } + const frame = { generation: slot.generation, evt, sequence, bytes }; (window as any).__ngspiceEvents.push({ ...evt, t: Date.now() - t0 }); const handler = (globalThis as any).__ngspiceOnEvent; if (handler) { - while (evtQueue.length) handler(evtQueue.shift()); - handler(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); + } } else { - evtQueue.push(evt); + if (evtQueue.length >= MAX_QUEUED_EVENT_FRAMES + || evtQueueBytes > MAX_QUEUED_EVENT_BYTES - bytes) { + retireWorker(slot, 'ngspice_service event-frame queue exceeded credit'); + return; + } + evtQueue.push(frame); + evtQueueBytes += bytes; + // Enqueueing IS taking ownership (mirrors the production + // service): release the transport credit so a pre-handler + // stream cannot starve the worker's window. + ackEvent(slot, frame); + } + }; + + // 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; + pending.delete(id); + clearTimeout(request.timer); + request.resolve({ error: why }); } }; - const failAllPending = (why: string) => { - for (const [, resolve] of pending) resolve({ error: why }); - pending.clear(); + 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); + slot.bootTimer = undefined; + } + slot.removeBootListener?.(); + slot.removeBootListener = undefined; + failPending(slot.generation, why); + for (let i = evtQueue.length - 1; i >= 0; --i) { + if (evtQueue[i]!.generation === slot.generation) { + evtQueueBytes -= evtQueue[i]!.bytes; + evtQueue.splice(i, 1); + } + } + if (workerSlot === slot) workerSlot = null; + try { slot.worker?.terminate(); } catch { /* already gone */ } + if (slot.workerUrl) { + try { URL.revokeObjectURL(slot.workerUrl); } catch { /* cleanup only */ } + slot.workerUrl = undefined; + } + 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 => { - if (!workerP) { - workerP = (async () => { + const ensureWorker = (): Promise => { + if (disposed) return Promise.reject(new Error('ngspice service was disposed')); + if (!workerSlot) { + const slot = { + generation: nextGeneration++, + failed: false, + } as WorkerSlot; + workerSlot = slot; + + const bootDeadline = new Promise((_resolve, reject) => { + slot.rejectBoot = reject; + slot.bootTimer = setTimeout(() => { + if (slot.failed || workerSlot !== slot) return; + const why = `ngspice_service boot timed out after ${bootTimeoutMs} ms`; + console.log(`[TEST-NGSPICE] ${why} — resetting service`); + retireWorker(slot, why); + }, bootTimeoutMs); + }); + + const boot = (async () => { const glue = new URL('ngspice_service.js', window.location.href).href; console.log(`[TEST-NGSPICE] booting ngspice_service from ${glue}`); - const worker = new Worker(URL.createObjectURL(new Blob( + slot.workerUrl = URL.createObjectURL(new Blob( [`self.NGSPICE_GLUE_URL = ${JSON.stringify(glue)};\n`, workerSrc], - { type: 'text/javascript' }))); + { type: 'text/javascript' })); + const worker = new Worker(slot.workerUrl); + slot.worker = worker; + worker.onmessage = (e) => { + if (slot.failed || workerSlot !== slot) return; const data = e.data ?? {}; - if (data.evt) { dispatchEvt(data.evt); return; } + if (data.fatal) { + deliverTerminalEvents(data.pendingEvents); + failWorker(`event stream failure: ${String(data.fatal)}`); + return; + } + if (data.evt) { + dispatchEvt( + slot, + data.evt, + data.eventSequence, + data.eventBytes, + ); + return; + } if (typeof data.id !== 'number') return; - const resolve = pending.get(data.id); - if (resolve) { pending.delete(data.id); resolve(data.res); } + const request = pending.get(data.id); + if (request?.generation === slot.generation) { + pending.delete(data.id); + clearTimeout(request.timer); + request.resolve(data.res); + } }; - worker.onerror = (e) => { - console.log(`[TEST-NGSPICE] worker error: ${e.message} — resetting service`); - failAllPending(`ngspice_service crashed: ${e.message}`); - workerP = null; - try { worker.terminate(); } catch { /* already gone */ } + const failWorker = (detail: string) => { + const why = `ngspice_service crashed: ${detail}`; + console.log(`[TEST-NGSPICE] worker error: ${detail} — resetting service`); + retireWorker(slot, why); }; - // Legible boot: bound the handshake and surface worker - // death — the bare version hung to the spec timeout with - // zero evidence (occ-service.ts has the same guard). - await new Promise((resolve, reject) => { - const fail = (msg: string) => { - clearTimeout(timer); - reject(new Error(msg)); - }; - const timer = setTimeout( - () => fail('[TEST-NGSPICE] ngspice_service boot timed out after ' - + '60s (no ready/bootError from the worker)'), 60000); + worker.onerror = (e) => failWorker(e.message || 'worker error'); + slot.failDecode = () => failWorker('message decode failed'); + worker.onmessageerror = slot.failDecode; + if (bootMessageErrorArmed) { + bootMessageErrorArmed = false; + // Synthetic dispatch on Worker is engine-dependent. + // Call the same transition as the real event handler + // after rejectBoot is installed below. + queueMicrotask(() => slot.failDecode()); + } + await new Promise((resolve) => { const onFirst = (e: MessageEvent) => { if (e.data?.ready) { - worker.removeEventListener('message', onFirst); - clearTimeout(timer); + if (slot.bootTimer !== undefined) { + clearTimeout(slot.bootTimer); + slot.bootTimer = undefined; + } + slot.removeBootListener?.(); + slot.removeBootListener = undefined; + slot.rejectBoot = undefined; resolve(); } else if (e.data?.bootError) { - fail(`[TEST-NGSPICE] ngspice_service bootError: ${e.data.bootError}`); + const why = `ngspice_service boot failed: ${String(e.data.bootError)}`; + retireWorker(slot, why); } }; worker.addEventListener('message', onFirst); - worker.addEventListener('error', (e: any) => fail( - `[TEST-NGSPICE] ngspice_service worker error: ${e?.message ?? e} ` - + `(${e?.filename ?? '?'}:${e?.lineno ?? '?'})`)); - worker.addEventListener('messageerror', () => fail( - '[TEST-NGSPICE] ngspice_service worker messageerror (structured clone failed)')); + slot.removeBootListener = () => worker.removeEventListener('message', onFirst); }); + + if (slot.failed || workerSlot !== slot) { + throw new Error('ngspice_service worker retired during boot'); + } console.log('[TEST-NGSPICE] ngspice_service ready'); - return worker; - })().catch((e) => { workerP = null; throw e; }); + return slot; + })(); + slot.ready = Promise.race([boot, bootDeadline]).catch((e) => { + retireWorker(slot, `ngspice_service unavailable: ${String(e)}`); + throw e; + }); + } + return workerSlot.ready; + }; + + const post = (slot: WorkerSlot, req: any): Promise => { + const worker = slot.worker; + if (!worker || slot.failed || workerSlot !== slot) { + return Promise.resolve({ error: 'ngspice_service worker is unavailable' }); } - return workerP; + const id = nextId++; + return new Promise((resolve) => { + const timer = setTimeout(() => { + if (pending.get(id)?.generation !== slot.generation) return; + const why = `ngspice_service response timed out after ${responseTimeoutMs} ms`; + console.log(`[TEST-NGSPICE] ${why} — resetting service`); + retireWorker(slot, why); + }, responseTimeoutMs); + pending.set(id, { generation: slot.generation, resolve, timer }); + let generationPending = 0; + for (const request of pending.values()) { + if (request.generation === slot.generation) generationPending++; + } + maxPending = Math.max(maxPending, generationPending); + try { + worker.postMessage({ id, req }); + if (runtimeMessageErrorThreshold !== null + && generationPending >= runtimeMessageErrorThreshold) { + runtimeMessageErrorThreshold = null; + slot.failDecode(); + } + } catch (error) { + pending.delete(id); + clearTimeout(timer); + resolve({ error: `ngspice_service request failed: ${String(error)}` }); + } + }); }; const request = async (req: any) => { - let worker: Worker; + // Assign at issue time, before worker boot or posting. A late + // response from a request issued before a new simulation's + // 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 { - worker = await ensureWorker(); + slot = await ensureWorker(); } catch (e) { - return { error: `ngspice_service unavailable: ${e}` }; + const res = { error: `ngspice_service unavailable: ${e}` }; + publishRequestReceipt({ + sequence: requestSequence, + kind: String(req.kind), + cmd: req.cmd, + name: req.name, + error: res.error, + t: Date.now() - t0, + }); + return res; } - const id = nextId++; - const res: any = await new Promise((resolve) => { - pending.set(id, resolve); - worker.postMessage({ id, req }); - }); - (window as any).__ngspiceLog.push({ + 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, cmd: req.cmd, name: req.name, @@ -140,6 +644,75 @@ export async function installNgspiceServiceStub(page: Page): Promise { return res; }; + const dispose = (): void => { + if (disposed) return; + disposed = true; + if (workerSlot) retireWorker(workerSlot, 'ngspice service was disposed'); + 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; + } + }; + + (globalThis as any).__ngspiceServiceTestHooks = { + requestCheckpoint() { + return nextRequestSequence - 1; + }, + waitForRequestAfter, + appliedGenerationCheckpoint() { + return appliedGenerations.length + ? appliedGenerations[appliedGenerations.length - 1]!.generation + : 0; + }, + waitForAppliedGenerationAfter, + dispose, + messageErrorDuringNextBoot() { + bootMessageErrorArmed = true; + }, + messageErrorWhenPendingAtLeast(count: number) { + if (!Number.isSafeInteger(count) || count < 1) + 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, + pending: pending.size, + maxPending, + retiredGenerations: [...retiredGenerations], + bootFaultArmed: bootMessageErrorArmed, + runtimeFaultArmed: runtimeMessageErrorThreshold !== null, + lastRequestSequence: nextRequestSequence - 1, + requestReceiptWaiters: requestReceipts.size(), + appliedGenerations: appliedGenerations.map((entry) => entry.generation), + appliedGenerationWaiters: appliedReceipts.size(), + disposed, + }; + }, + }; + (globalThis as any).ngspiceService = { request }; - }, NGSPICE_WORKER_SRC); + window.addEventListener('pagehide', dispose, { once: true }); + }, { workerSrc: NGSPICE_WORKER_SRC, bootTimeoutMs, responseTimeoutMs }); } diff --git a/tests/kicad/utils/occ-service.ts b/tests/kicad/utils/occ-service.ts index bdc027ab9..4ddb0e97a 100644 --- a/tests/kicad/utils/occ-service.ts +++ b/tests/kicad/utils/occ-service.ts @@ -39,64 +39,207 @@ 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 = []; - let workerP: Promise | null = null; - const pending = new Map void>(); + interface WorkerSlot { + generation: number; + worker?: Worker; + failed: boolean; + ready: Promise; + bootTimer?: ReturnType; + rejectBoot?: (reason?: unknown) => void; + removeBootListener?: () => void; + /** The exact lifecycle transition used by Worker.onmessageerror. */ + failDecode: () => void; + } + + interface PendingRequest { + generation: number; + resolve: (res: any) => void; + } + + let nextGeneration = 1; + let workerSlot: WorkerSlot | null = null; + const pending = new Map(); let nextId = 1; + let maxPending = 0; + let requestsStarted = 0; + let requestsPosted = 0; + const workerGenerationsStarted: number[] = []; + 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 => { + let count = 0; + for (const request of pending.values()) { + if (request.generation === generation) count++; + } + return count; + }; + + const failPending = (generation: number, report: string): void => { + for (const [id, request] of pending) { + if (request.generation !== generation) continue; + pending.delete(id); + request.resolve({ ok: false, report }); + } + }; - const ensureWorker = (): Promise => { - if (!workerP) { - workerP = (async () => { + const retireWorker = (slot: WorkerSlot, report: string): void => { + 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 { + slot.worker?.terminate(); + } 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 { report } = armedFault; + armedFault = null; + 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 { + retireWorker(slot, report); + } + }; + + const ensureWorker = (): Promise => { + if (!workerSlot) { + const slot = { + generation: nextGeneration++, + failed: false, + } as WorkerSlot; + workerGenerationsStarted.push(slot.generation); + workerSlot = slot; + + 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; + + // 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); + }; + slot.failDecode = () => { + const report = 'occ_service transport failed: message decode failed'; + console.error(`[TEST-OCC] ${report}; resetting service`); + retireWorker(slot, report); + }; + worker.onmessageerror = slot.failDecode; worker.onmessage = (e) => { + if (slot.failed || workerSlot !== slot) return; const { id, res } = e.data ?? {}; if (typeof id !== 'number') return; - const resolve = pending.get(id); - if (resolve) { pending.delete(id); resolve(res); } + const request = pending.get(id); + if (request?.generation === slot.generation) { + pending.delete(id); + request.resolve(res); + } }; - // Legible boot: the old handshake could never reject on a - // worker DEATH (importScripts throw, pthread spawn wedge, - // OOM-kill) — the promise just hung until the spec's 180s - // timeout with zero evidence. Surface worker errors and - // bound the boot. - await new Promise((resolve, reject) => { - const fail = (msg: string) => { - clearTimeout(timer); - reject(new Error(msg)); - }; - const timer = setTimeout( - () => fail('[TEST-OCC] occ_service boot timed out after 60s ' - + '(no ready/bootError from the worker)'), 60000); + await new Promise((resolve) => { const onFirst = (e: MessageEvent) => { if (e.data?.ready) { - worker.removeEventListener('message', onFirst); - clearTimeout(timer); + if (slot.bootTimer !== undefined) { + clearTimeout(slot.bootTimer); + slot.bootTimer = undefined; + } + slot.removeBootListener?.(); + slot.removeBootListener = undefined; + slot.rejectBoot = undefined; resolve(); } else if (e.data?.bootError) { - fail(`[TEST-OCC] occ_service bootError: ${e.data.bootError}`); + retireWorker(slot, + `occ_service boot failed: ${String(e.data.bootError)}`); } }; worker.addEventListener('message', onFirst); - worker.addEventListener('error', (e: any) => fail( - `[TEST-OCC] occ_service worker error: ${e?.message ?? e} ` - + `(${e?.filename ?? '?'}:${e?.lineno ?? '?'})`)); - worker.addEventListener('messageerror', () => fail( - '[TEST-OCC] occ_service worker messageerror (structured clone failed)')); + 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 worker; - })().catch((e) => { workerP = null; throw e; }); + return slot; + })(); + + 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; + }); } - return workerP; + return workerSlot.ready; }; // Mirror of the app's collectBoardModelFiles, against the page's @@ -127,21 +270,36 @@ export async function installOccServiceStub(page: Page): Promise { }; const request = async (req: any) => { + // Count provider entry before model collection or worker boot. This + // distinguishes "the wx button reached OCC" from a worker that was + // already active for some earlier request. + requestsStarted++; if (req.kind === 'export') req.models = await collectModels(new TextDecoder().decode(req.board)); - let worker: Worker; + let slot: WorkerSlot; try { - worker = await ensureWorker(); + slot = await ensureWorker(); } catch (e) { return { ok: false, report: `occ_service unavailable: ${e}` }; } + const worker = slot.worker; + if (!worker || slot.failed || workerSlot !== slot) + return { ok: false, report: 'occ_service worker is unavailable' }; const id = nextId++; const transfer = req.kind === 'export' ? [req.board.buffer, ...(req.models ?? []).map((m: any) => m.bytes.buffer)] : [req.bytes.buffer]; const res: any = await new Promise((resolve) => { - pending.set(id, resolve); - worker.postMessage({ id, req }, transfer); + pending.set(id, { generation: slot.generation, resolve }); + maxPending = Math.max(maxPending, pendingInGeneration(slot.generation)); + try { + worker.postMessage({ id, req }, transfer); + requestsPosted++; + maybeTriggerArmedFault(slot); + } catch (error) { + pending.delete(id); + resolve({ ok: false, report: `occ_service request failed: ${String(error)}` }); + } }); if (req.kind === 'export') { if (res.ok && res.bytes?.length) { @@ -169,6 +327,39 @@ export async function installOccServiceStub(page: Page): Promise { return res; }; + (globalThis as any).__occServiceTestHooks = { + /** 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) { + if (!Number.isSafeInteger(count) || count < 1) + throw new Error('pending threshold must be a positive safe integer'); + armedFault = { + count, + report: 'occ_service transport failed: message decode failed', + }; + if (workerSlot) maybeTriggerArmedFault(workerSlot); + }, + snapshot() { + return { + activeGeneration: workerSlot?.generation ?? null, + pending: pending.size, + maxPending, + requestsStarted, + requestsPosted, + workerGenerationsStarted: [...workerGenerationsStarted], + retiredGenerations: [...retiredGenerations], + armed: armedFault !== null, + }; + }, + }; + (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 000000000..3230bcf54 --- /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 c41970832..8c2530e4a 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 000000000..efbe7b7ef --- /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 6d0423ca8..0133f0b24 100644 --- a/tests/package.json +++ b/tests/package.json @@ -29,7 +29,10 @@ "3d:check:parity": "tsx tools/screenshots/compare-dirs.ts --old 3d-regression/baseline --new 3d-regression/output/webgl --out 3d-regression/output/diff/parity --floors 3d-regression/floors.json --level webgl-vs-native --label 3d-parity", "3d:review": "tsx tools/screenshots/compare-dirs.ts --old 3d-regression/baseline --new 3d-regression/output/webgl --out 3d-regression/output/diff/parity-review --floors 3d-regression/floors.json --level webgl-vs-native --label 3d-parity --artifacts always", "3d:test:webgl": "playwright test --project=wx-chromium e2e/3d-webgl.spec.ts", - "tools:contract": "tsx tools/cli-contract.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: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 new file mode 100644 index 000000000..5ad7e416c --- /dev/null +++ b/tests/tools/findings-e-source-contract.ts @@ -0,0 +1,207 @@ +/** + * Source-contract tripwire for the findings group E fixes that live in C++ + * (EM_JS bridges and KiCad simulator code) and therefore cannot be + * behaviorally unit-tested without a full wasm build. Same style as the codex + * thread's contract tools: read the sources, assert the load-bearing tokens + * are present (and the reverted shapes absent), fail loudly with the finding + * ID. Run: npm run findings-e:contract + */ +import { strict as assert } from "node:assert"; +import { readFileSync } from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(here, "../.."); +const read = (rel: string) => readFileSync(path.join(repo, rel), "utf8"); + +const sharedspice = read("wasm/stubs/sharedspice_client.cpp"); +const exporterStub = read("wasm/stubs/exporter_step_stub.cpp"); +const oceStub = read("wasm/stubs/oce_plugin_stub.cpp"); +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"); +assert.ok((sharedspice.match(/__pcbjamNgspiceOwnerModule/g) ?? []).length >= 2, + "E-5: the handler must be stamped AND compared by owner module identity"); +assert.ok(sharedspice.includes("globalThis.__ngspiceOnEvent !== handler"), + "E-5: a superseded handler must disarm itself"); +assert.ok(!/if\(\s*globalThis\.__ngspiceOnEvent\s*\)/.test(sharedspice), + "E-5 REGRESSION: the install-once presence guard is back — presence is not identity"); +assert.ok(sharedspice.includes("canTouchNative"), + "E-5/E-8: event dispatch must check the scheduler liveness gate"); + +// --- E-8: all four completion sites route native work through the gate ------ +for (const [name, src, site] of [ + ["exporter_step_stub.cpp", exporterStub, "'OCC export completion'"], + ["oce_plugin_stub.cpp", oceStub, "'OCC model completion'"], + ["sharedspice_client.cpp", sharedspice, "'ngspice request completion'"], + ["sharedspice_client.cpp", sharedspice, "'ngspice vector completion'"], +] as const) { + assert.ok(src.includes(`runWaitCompletion( ${site}`), + `E-8: ${name} must run its ${site} through runWaitCompletion`); +} +for (const [name, src] of [ + ["exporter_step_stub.cpp", exporterStub], + ["oce_plugin_stub.cpp", oceStub], + ["sharedspice_client.cpp", sharedspice], +] as const) { + assert.ok(!/__wxScheduler\.resolveWait\(/.test(src), + `E-8 REGRESSION: ${name} resolves a wait directly, bypassing the admission gate`); + assert.ok(/if\(\s*token\s*<=\s*0\s*\)/.test(src), + `E-8: ${name} must bail when wxWasmBeginWait refuses the token`); +} +for (const symbol of ["runWaitCompletion", "_terminalizeNativeTrap", + "canTouchNative", "beginWaitRefused"]) { + assert.ok(shim.includes(symbol), + `E-8: jspi-scheduler.js must provide ${symbol}`); +} + +// --- E-7: per-session run generation, behavioral drops wasm-only ------------ +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}`); +} + +// --- E-9: destructor unregisters the sharedspice callbacks ------------------ +assert.ok(/#ifdef __EMSCRIPTEN__[\s\S]{0,400}pcbjam_ngspice_reset_callbacks\( this \)/ + .test(ngspiceCpp), + "E-9: ~NGSPICE must call pcbjam_ngspice_reset_callbacks(this) under __EMSCRIPTEN__"); +assert.ok(/pcbjam_ngspice_reset_callbacks\( void\* aUser \)[\s\S]{0,200}s_user != aUser/ + .test(sharedspice), + "E-9: the reset must be identity-checked so a stale destructor cannot clear a successor"); + +console.log("findings-e-source-contract: all green"); diff --git a/tests/tools/lib/worker-constants.ts b/tests/tools/lib/worker-constants.ts new file mode 100644 index 000000000..c8fd65c5e --- /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 3407d3f2a..263bbd0c7 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 01a120c75..a427eba32 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 new file mode 100644 index 000000000..563e02652 --- /dev/null +++ b/tests/tools/ngspice-worker-batch-unit.ts @@ -0,0 +1,313 @@ +/** + * Behavioral reducer for ngspice-worker.js output batching and transport + * credits. It executes the production worker source in a VM with a synchronous + * fake native module, where queued microtasks cannot hide retained bursts. + */ +import { strict as assert } from "node:assert"; +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, "../.."); +const workerSource = readFileSync( + path.join(repo, "web/standalone/src/wasm/ngspice-worker.js"), + "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; +}; + +type WorkerHarness = { + frames: Frame[]; + emit(kind: number, text: string, a: number, b: number): void; + message(data: unknown): Promise; +}; + +async function createWorkerHarness( + moduleOverrides: Record = {}, +): Promise { + const frames: Frame[] = []; + let messageHandler!: (event: { data: unknown }) => Promise; + const module: Record = { + init: () => 0, + circ: () => 0, + command: () => 0, + getVecInfo: () => ({ found: false }), + curPlot: () => "", + allPlots: () => [], + allVecs: () => [], + running: () => false, + cmInputPath: () => undefined, + ...moduleOverrides, + }; + const workerGlobal: Record = { + NGSPICE_GLUE_URL: "https://pcbjam.test/ngspice_service.js", + addEventListener: () => undefined, + }; + Object.defineProperty(workerGlobal, "onmessage", { + set(value) { messageHandler = value as typeof messageHandler; }, + }); + const context = vm.createContext({ + self: workerGlobal, + console, + Blob, + URL, + RangeError, + String, + Number, + Map, + JSON, + Promise, + TextEncoder, + queueMicrotask, + importScripts: () => undefined, + NgspiceService: () => Promise.resolve(module), + postMessage: (frame: Frame) => frames.push(structuredClone(frame)), + structuredClone, + }); + + vm.runInContext(workerSource, context, { filename: "ngspice-worker.js" }); + await Promise.resolve(); + assert.equal(typeof module.ngspiceEmit, "function"); + assert.equal(typeof messageHandler, "function"); + frames.length = 0; // discard ready + return { + frames, + emit: module.ngspiceEmit, + message: (data) => messageHandler({ data }), + }; +} + +async function acknowledgeAll(harness: WorkerHarness): Promise { + for (const frame of harness.frames) { + if (!frame.eventSequence) continue; + await harness.message({ + eventAck: { + sequence: frame.eventSequence, + bytes: frame.eventBytes, + }, + }); + } +} + +async function main(): Promise { +const bounded = await createWorkerHarness(); +// 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), + [BATCH_LINES, BATCH_LINES], +); +await Promise.resolve(); +assert.deepEqual( + bounded.frames.map((frame) => frame.evt?.lines?.length), + [BATCH_LINES, BATCH_LINES, 1], +); +assert.deepEqual( + bounded.frames.flatMap((frame) => frame.evt?.lines ?? []), + Array.from({ length: floodLines }, (_, i) => `line-${i}`), +); +await acknowledgeAll(bounded); +console.log("ok synchronous output flushes in bounded ordered line chunks"); + +bounded.frames.length = 0; +const wide = "x".repeat(400_000); +bounded.emit(0, `${wide}-0`, 0, 0); +bounded.emit(0, `${wide}-1`, 0, 0); +bounded.emit(0, `${wide}-2`, 0, 0); // crossing line flushes first two +assert.equal(bounded.frames.length, 1); +assert.deepEqual( + bounded.frames[0]!.evt!.lines!.map((line) => line.at(-1)), + ["0", "1"], +); +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! <= BATCH_BYTES); +await acknowledgeAll(bounded); +console.log("ok UTF-8 byte pressure flushes before retaining the crossing line"); + +const storm = await createWorkerHarness(); +const chunk = "z".repeat(900_000); +for (let i = 0; i < 100_000; ++i) { + try { + storm.emit(0, `${chunk}-${i}`, 0, 0); + } catch { + // Continue attempts deliberately: terminal state must remain inert and + // must never post another retained frame. + } +} +await Promise.resolve(); +const stormEvents = storm.frames.filter((frame) => frame.evt); +assert.ok(stormEvents.length <= WINDOW_FRAMES); +assert.ok( + stormEvents.reduce((sum, frame) => sum + frame.eventBytes!, 0) + <= WINDOW_BYTES, +); +assert.equal(storm.frames.filter((frame) => frame.fatal).length, 1); +assert.match(storm.frames.find((frame) => frame.fatal)!.fatal!, /deferred/); +console.log("ok 100,000 synchronous chunk attempts cannot exceed transport credit"); + +// A FULL credit window is backpressure, not a fault: frames beyond the window +// defer (bounded) and drain IN ORDER as acks free credit. The regression this +// pins: the first shipped shape terminally stopped the stream at 64 in-flight +// frames, killing a live simulation whenever the main thread lagged one +// window behind (observed as "event transport exceeded 64 frames" ending the +// eeschema second-run spec). +const paced = await createWorkerHarness(); +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, 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, pacedTotal, "deferred frames drained after acks"); +assert.deepEqual( + pacedEvents.map((f) => f.evt!.finished), + 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(BATCH_BYTES), 0, 0), + new RegExp(oversizeFatal), +); +assert.deepEqual(oversize.frames, [{ + fatal: oversizeFatal, + pendingEvents: [], +}]); +assert.throws( + () => oversize.emit(0, "late", 0, 0), + new RegExp(oversizeFatal), +); +await oversize.message({ id: 91, req: { kind: "running" } }); +assert.deepEqual(oversize.frames.at(-1), { + id: 91, + 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]!; +await assert.rejects( + ackMismatch.message({ + eventAck: { + sequence: credited.eventSequence, + bytes: credited.eventBytes! + 1, + }, + }), + /acknowledgment did not match an exact frame/, +); +assert.equal(ackMismatch.frames.filter((frame) => frame.fatal).length, 1); +console.log("ok acknowledgment must match the exact sequence and byte lease"); + +console.log("ngspice-worker-batch-unit: all green"); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/tools/service-stub-parity.ts b/tests/tools/service-stub-parity.ts new file mode 100644 index 000000000..0a029bbe8 --- /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 e512e5941..53fc178c9 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 4b645634b..334708de4 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 ba748240c..221d3a42c 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/exporter_step_stub.cpp b/wasm/stubs/exporter_step_stub.cpp index db84de1ea..c5ba5cb15 100644 --- a/wasm/stubs/exporter_step_stub.cpp +++ b/wasm/stubs/exporter_step_stub.cpp @@ -66,12 +66,18 @@ EM_JS( void, js_occExportStart, const jobJson = UTF8ToString( aJobJson ); const fileName = UTF8ToString( aFileName ); + // E-8: all native work (malloc + heap writes) runs inside the scheduler's + // completion gate — a dead or trapped instance drops the completion + // loudly instead of re-entering wasm, and a trap inside the prepare + // latches the instance terminal without resolving the wait. const finish = ( res ) => { - const s = JSON.stringify( res || { ok: false, report: 'occ_service: no response' } ); - const n = lengthBytesUTF8( s ) + 1; - const p = _malloc( n ); - stringToUTF8( s, p, n ); - globalThis.__wxScheduler.resolveWait( aToken, p ); + globalThis.__wxScheduler.runWaitCompletion( 'OCC export completion', aToken, () => { + const s = JSON.stringify( res || { ok: false, report: 'occ_service: no response' } ); + const n = lengthBytesUTF8( s ) + 1; + const p = _malloc( n ); + stringToUTF8( s, p, n ); + return p; + } ); }; let req; @@ -168,6 +174,18 @@ bool EXPORTER_STEP::Export() const wxString downloadName = wxFileName( m_outputFile ).GetFullName(); const int token = wxWasmBeginWait( "occ" ); + + // Token 0 = the scheduler refused the wait (dead or terminal instance): + // never start an RPC whose completion could not be admitted. + if( token <= 0 ) + { + if( m_reporter ) + m_reporter->Report( wxT( "occ_service: scheduler unavailable" ), RPT_SEVERITY_ERROR ); + + wxRemoveFile( wxString::FromUTF8( TMP_BOARD ) ); + return false; + } + js_occExportStart( token, TMP_BOARD, jobJson.c_str(), downloadName.utf8_string().c_str() ); // The malloc'd JSON pointer rides the wait as an int32. diff --git a/wasm/stubs/oce_plugin_stub.cpp b/wasm/stubs/oce_plugin_stub.cpp index f244e509c..8d36f8753 100644 --- a/wasm/stubs/oce_plugin_stub.cpp +++ b/wasm/stubs/oce_plugin_stub.cpp @@ -67,11 +67,18 @@ EM_JS( void, js_occLoadModelStart, ( int aToken, const char* aModelPath ), { const modelPath = UTF8ToString( aModelPath ); - const finish = ( cachePath ) => { - const n = lengthBytesUTF8( cachePath ) + 1; - const p = _malloc( n ); - stringToUTF8( cachePath, p, n ); - globalThis.__wxScheduler.resolveWait( aToken, p ); + // E-8: all native work (the MEMFS cache write + the malloc'd path) runs + // inside the scheduler's completion gate — a dead or trapped instance + // drops the completion loudly instead of re-entering wasm, and a trap + // inside the prepare latches the instance terminal without resolving. + const finish = ( writeCache ) => { + globalThis.__wxScheduler.runWaitCompletion( 'OCC model completion', aToken, () => { + const cachePath = writeCache(); + const n = lengthBytesUTF8( cachePath ) + 1; + const p = _malloc( n ); + stringToUTF8( cachePath, p, n ); + return p; + } ); }; let req; @@ -100,22 +107,26 @@ EM_JS( void, js_occLoadModelStart, ( int aToken, const char* aModelPath ), } req.then( ( res ) => { - let cachePath = ''; - - if( res && res.ok && res.bytes && res.bytes.length ) - { - cachePath = '/tmp/pcbjam_occ_model_cache.3dc'; - FS.writeFile( cachePath, res.bytes ); - } - else if( res && res.report ) - { - console.error( '[pcbjam-occ] loadModel failed:', res.report ); - } - - finish( cachePath ); + finish( () => { + let cachePath = ''; + + if( res && res.ok && res.bytes && res.bytes.length ) + { + cachePath = '/tmp/pcbjam_occ_model_cache.3dc'; + FS.writeFile( cachePath, res.bytes ); + } + else if( res && res.report ) + { + console.error( '[pcbjam-occ] loadModel failed:', res.report ); + } + + return cachePath; + } ); } ).catch( ( e ) => { + // The gate makes this fallback inert after a trap or shutdown — it + // cannot repeat native work in a damaged instance. console.error( '[pcbjam-occ] loadModel request failed:', e ); - finish( '' ); + finish( () => '' ); } ); } ) @@ -225,6 +236,11 @@ SCENEGRAPH* oce3d_Load( char const* aFileName ) return nullptr; const int token = wxWasmBeginWait( "occ" ); + + // Token 0 = the scheduler refused the wait (dead or terminal instance). + if( token <= 0 ) + return nullptr; + js_occLoadModelStart( token, aFileName ); // The malloc'd path pointer rides the wait as an int32. diff --git a/wasm/stubs/sharedspice_client.cpp b/wasm/stubs/sharedspice_client.cpp index 7835b8af6..b449373bb 100644 --- a/wasm/stubs/sharedspice_client.cpp +++ b/wasm/stubs/sharedspice_client.cpp @@ -60,12 +60,17 @@ using nlohmann::json; // microtask (the early-resolve contract, doc 22 §10 Phase E retry entry). // clang-format off EM_JS( void, js_ngspice_request_start, ( int aToken, const char* aReqJson ), { + // E-8: the malloc + heap writes run inside the scheduler's completion + // gate — a dead or trapped instance drops the completion loudly instead + // of re-entering wasm. const finish = ( res ) => { - const s = JSON.stringify( res ?? {} ); - const n = lengthBytesUTF8( s ) + 1; - const p = _malloc( n ); - stringToUTF8( s, p, n ); - globalThis.__wxScheduler.resolveWait( aToken, p ); + globalThis.__wxScheduler.runWaitCompletion( 'ngspice request completion', aToken, () => { + const s = JSON.stringify( res ?? {} ); + const n = lengthBytesUTF8( s ) + 1; + const p = _malloc( n ); + stringToUTF8( s, p, n ); + return p; + } ); }; let req; try { @@ -90,7 +95,6 @@ EM_JS( void, js_ngspice_request_start, ( int aToken, const char* aReqJson ), { EM_JS( void, js_ngspice_get_vec_start, ( int aToken, const char* aName, int* aMeta, double** aReal, double** aComp, char** aVName ), { - const finish = ( status ) => globalThis.__wxScheduler.resolveWait( aToken, status ); let req; try { const svc = globalThis.ngspiceService; @@ -100,35 +104,53 @@ EM_JS( void, js_ngspice_get_vec_start, } catch( e ) { req = Promise.resolve( { error: String( e ) } ); } + // E-8: every output-pointer write happens inside the scheduler's + // completion gate, so a dead or trapped instance is never written to. + // A plain JS failure inside the prepare resolves the inertResult (1 = + // transport error) so the parked caller fails instead of stranding. req.catch( ( e ) => ( { error: String( e ) } ) ).then( ( res ) => { - HEAP32[aMeta >> 2] = 0; - HEAPU32[aReal >> 2] = 0; - HEAPU32[aComp >> 2] = 0; - HEAPU32[aVName >> 2] = 0; - if( !res || res.error ) - return finish( 1 ); - if( !res.found ) - return finish( 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 ); - HEAPF64.set( res.real, p >> 3 ); - HEAPU32[aReal >> 2] = p; - } - if( res.comp && res.comp.length ) { - const p = _malloc( res.comp.length * 8 ); - HEAPF64.set( res.comp, p >> 3 ); - HEAPU32[aComp >> 2] = p; - } - const s = res.vname || ''; - const n = lengthBytesUTF8( s ) + 1; - const vp = _malloc( n ); - stringToUTF8( s, vp, n ); - HEAPU32[aVName >> 2] = vp; - HEAP32[aMeta >> 2] = 1; - finish( 0 ); + globalThis.__wxScheduler.runWaitCompletion( 'ngspice vector completion', aToken, () => { + HEAP32[aMeta >> 2] = 0; + HEAPU32[aReal >> 2] = 0; + HEAPU32[aComp >> 2] = 0; + HEAPU32[aVName >> 2] = 0; + if( !res || res.error ) + return 1; + if( !res.found ) + return 0; + HEAP32[( aMeta >> 2 ) + 1] = res.vtype | 0; + HEAP32[( aMeta >> 2 ) + 2] = res.flags | 0; + // 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( nComp ) { + const p = _malloc( nComp * 8 ); + HEAPF64.set( res.comp, p >> 3 ); + HEAPU32[aComp >> 2] = p; + } + const s = res.vname || ''; + const n = lengthBytesUTF8( s ) + 1; + const vp = _malloc( n ); + stringToUTF8( s, vp, n ); + HEAPU32[aVName >> 2] = vp; + HEAP32[aMeta >> 2] = 1; + return 0; + }, /* inertResult = */ 1 ); } ); } ); @@ -138,30 +160,81 @@ extern "C" int wxWasmYieldUntil( int aToken ); // Event dispatcher: provider `{ evt }` frames -> KiCad's registered callbacks // via the exported pcbjam_ngspice_event (fresh wasm entries; see header -// comment). Installed once, at first pcbjam_ngSpice_Init. +// comment). +// +// E-5: the handler is bound to the EXACT installing module, not to whatever +// `Module` lexically means when an event later arrives. Presence is not +// identity: the old install-once guard let a replacement module (trap +// recovery is module replacement — cross-ref G-8) keep the retired module's +// handler, whose closure drove the dead instance's heap. Re-installation is +// idempotent only for the same module; a different module replaces the +// handler, and a superseded handler disarms itself. EM_JS( void, js_ngspice_install_events, (), { - if( globalThis.__ngspiceOnEvent ) + 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; - globalThis.__ngspiceOnEvent = ( evt ) => { + const handler = ( evt ) => { + if( globalThis.__ngspiceOnEvent !== handler ) + return; // superseded install — never drive a retired module + 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. + console.warn( '[sharedspice_client] dropping ngspice event for a ' + + '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 ) { const n = lengthBytesUTF8( text ) + 1; + // Bare closure exports: this EM_JS body is compiled into the + // installing module's glue closure, so _malloc/stringToUTF8 + // ARE that exact module's (Module._malloc is not populated in + // this build). The identity guarantee is the handler capture + // plus the __ngspiceOnEvent self-disarm above. p = _malloc( n ); stringToUTF8( text, p, n ); } - Module._pcbjam_ngspice_event( kind, p, a | 0, b | 0 ); + pendingText = p; + installingModule._pcbjam_ngspice_event( kind, p, a | 0, b | 0 ); + pendingText = 0; // the native entry freed it }; - if( evt.kind === 'char' || evt.kind === 'stat' ) { - for( const line of evt.lines || [] ) - call( evt.kind === 'char' ? 0 : 1, line, 0, 0 ); - } else if( evt.kind === 'bg' ) { - call( 2, null, evt.finished ? 1 : 0, 0 ); - } else if( evt.kind === 'exit' ) { - call( 3, null, evt.status | 0, - ( evt.immediate ? 1 : 0 ) | ( evt.quit ? 2 : 0 ) ); + try { + if( evt.kind === 'char' || evt.kind === 'stat' ) { + for( const line of evt.lines || [] ) + call( evt.kind === 'char' ? 0 : 1, line, 0, 0 ); + } else if( evt.kind === 'bg' ) { + call( 2, null, evt.finished ? 1 : 0, 0 ); + } else if( evt.kind === 'exit' ) { + call( 3, null, evt.status | 0, + ( evt.immediate ? 1 : 0 ) | ( evt.quit ? 2 : 0 ) ); + } + } catch( e ) { + // 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 ) ) { + if( pendingText ) + _free( pendingText ); + throw e; + } } }; + handler.__pcbjamNgspiceOwnerModule = installingModule; + globalThis.__ngspiceOnEvent = handler; } ); // clang-format on @@ -184,6 +257,12 @@ std::atomic s_bgRunning{ false }; json rpc( const json& aReq ) { const int token = wxWasmBeginWait( "ngspice" ); + + // Token 0 = the scheduler refused the wait (dead or terminal instance): + // never start an RPC whose completion could not be admitted. + if( token <= 0 ) + return json{ { "error", "wx scheduler unavailable" } }; + js_ngspice_request_start( token, aReq.dump().c_str() ); // The malloc'd JSON pointer rides the wait as an int32. @@ -359,6 +438,51 @@ extern "C" EMSCRIPTEN_KEEPALIVE void pcbjam_ngspice_event( int aKind, char* aTex std::free( aText ); } +// E-9: a destroyed NGSPICE must unregister its callbacks — a late worker +// event otherwise reaches s_sendChar( text, 0, s_user ) with s_user pointing +// at the destroyed object (use-after-free after simulator close; the E-7 +// run-generation gate sits downstream in the wx event queue and cannot cover +// this entry). Identity-checked so a stale destructor never clears a +// successor instance's registration. +extern "C" EMSCRIPTEN_KEEPALIVE void pcbjam_ngspice_reset_callbacks( void* aUser ) +{ + if( s_user != aUser ) + return; + + s_sendChar = nullptr; + s_sendStat = nullptr; + s_controlledExit = nullptr; + s_bgThreadRunning = nullptr; + 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 // ------------------------------------------------------------------------- @@ -430,13 +554,24 @@ pvector_info pcbjam_ngGet_Vec_Info( char* aVecName ) char* vname = nullptr; const int token = wxWasmBeginWait( "ngspice" ); - js_ngspice_get_vec_start( token, aVecName ? aVecName : "", meta, &real, &comp, &vname ); - if( wxWasmYieldUntil( token ) != 0 ) + // Token 0 = the scheduler refused the wait (dead or terminal instance). + if( token <= 0 ) return nullptr; - if( !meta[0] ) + js_ngspice_get_vec_start( token, aVecName ? aVecName : "", meta, &real, &comp, &vname ); + + // 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 c6c372176..bc2c62eec 100644 --- a/web/standalone/src/wasm/boot.ts +++ b/web/standalone/src/wasm/boot.ts @@ -649,6 +649,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 3fc356ade..05cc71c10 100644 --- a/web/standalone/src/wasm/libs/models-bridge.test.ts +++ b/web/standalone/src/wasm/libs/models-bridge.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { collectBoardModelFiles, ensureModelInMemfs, installModel3dHandler, normalizeModelRef, scanModelRefs, + type BoardModelFile, } from "./models-bridge"; import type { Model3dSource } from "./models-source"; @@ -138,6 +139,162 @@ describe("collectBoardModelFiles", () => { installFakes(() => true); expect(await collectBoardModelFiles("(kicad_pcb (version 1))")).toEqual([]); }); + + it("makes an in-flight source result inert and starts no later ref after abort", async () => { + // repro for E-4: the prefetch abort must stop selection immediately and + // may not retain a body that resolves after the abort. + (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 collection = collectBoardModelFiles( + '(model "AbortA.3dshapes/A.step")\n' + + '(model "AbortB.3dshapes/B.step")', + 1, + controller.signal, + ); + + await vi.waitFor(() => expect(source.getModelBody).toHaveBeenCalledTimes(1)); + controller.abort(retired); + resolveFirst(new Uint8Array([1, 2, 3])); + + await expect(collection).rejects.toBe(retired); + 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 + // stay off FS entirely (the OCC worker stages bodies in its own MEMFS). + const fs = { + mkdirTree: vi.fn(), + writeFile: vi.fn(), + analyzePath: vi.fn(() => ({ exists: false })), + readFile: vi.fn(), + }; + (globalThis as unknown as { window: unknown }).window ??= globalThis; + (globalThis as unknown as { FS: unknown }).FS = fs; + const source: Model3dSource = { + getModelBody: async (ref) => new TextEncoder().encode(`body:${ref}`), + hasModel: async () => true, + }; + installModel3dHandler(source, () => {}); + const models = await collectBoardModelFiles( + '(model "PureLib.3dshapes/M1.step")', + ); + expect(models).toHaveLength(1); + expect(fs.mkdirTree).not.toHaveBeenCalled(); + expect(fs.writeFile).not.toHaveBeenCalled(); + expect(fs.readFile).not.toHaveBeenCalled(); + expect(fs.analyzePath).not.toHaveBeenCalled(); + }); }); describe("scanModelRefs", () => { diff --git a/web/standalone/src/wasm/libs/models-bridge.ts b/web/standalone/src/wasm/libs/models-bridge.ts index a0ad55ea2..a5491e545 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 @@ -195,47 +205,91 @@ export interface BoardModelFile { } /** - * Prefetch + read back every lib model a board references, for shipping with - * an occ_service export request — the worker is its own wasm module with its - * own MEMFS, so the editor-side files are invisible there. Reuses - * ensureModelInMemfs (IDB/R2-cached, coalesced, wrl→step format fallback); - * the returned paths carry the staged file's REAL extension, deduplicated - * (two refs can materialize to the same substituted body). Best-effort: a - * ref the source can't serve is skipped (the exporter reports it missing). - * Returns [] when 3D model delivery is not configured. + * 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 + * different MEMFS, so materializing and reading the bytes through the editor's + * native heap only adds a stale native-completion tail after an + * export-prefetch timeout. The returned paths carry the fetched file's real + * fallback extension and are deduplicated. Best-effort: missing models are + * skipped (the exporter reports them missing). An abort stops selection + * immediately and makes every already-started source result inert before it + * is retained. Returns [] when 3D model delivery is not configured. */ export async function collectBoardModelFiles( boardText: string, concurrency = 6, + signal?: AbortSignal, + progress?: CollectProgress, ): Promise { - const fs = toolFS(); - if (!installedSource || !fs) return []; + const out: BoardModelFile[] = progress?.models ?? []; + if (!installedSource) return out; + const source = installedSource; + const isCurrent = () => source === installedSource; + const throwIfAborted = (): void => { + if (!signal?.aborted) return; + throw signal.reason ?? new DOMException("Model collection aborted", "AbortError"); + }; + 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 => { - while (idx < refs.length) { + while (true) { + throwIfAborted(); + if (!isCurrent() || idx >= refs.length) return; const ref = refs[idx++]!; - try { - const abs = await ensureModelInMemfs(ref); - if (!abs || seen.has(abs)) continue; - seen.add(abs); - // FS.readFile copies out of the wasm heap — the buffer is safely - // transferable to the worker. - const bytes = fs.readFile(abs) as Uint8Array; - out.push({ path: abs.slice(MODELS_3D_ROOT.length + 1), bytes }); - } catch { - // best-effort: a missing body surfaces as the exporter's own - // "Could not add 3D model" report warning, never a failed export + // 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); + } catch { + // Best-effort: try the next format. The exporter reports a miss if + // no candidate exists. + } + throwIfAborted(); + 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 + // ownership independent from any source/cache view. + out.push({ path: candidate, bytes: new Uint8Array(body) }); + } + break; } } }; await Promise.all( - Array.from({ length: Math.min(concurrency, refs.length) }, () => worker()), + Array.from( + { length: Math.min(Math.max(1, Math.trunc(concurrency)), refs.length) }, + () => worker(), + ), ); + throwIfAborted(); installedLog(`[3d] export prefetch: ${out.length}/${refs.length} board model(s)`); return out; } diff --git a/web/standalone/src/wasm/ngspice-service.test.ts b/web/standalone/src/wasm/ngspice-service.test.ts new file mode 100644 index 000000000..66de656ad --- /dev/null +++ b/web/standalone/src/wasm/ngspice-service.test.ts @@ -0,0 +1,568 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./ngspice-worker.js?raw", () => ({ default: "// fake ngspice worker" })); +vi.mock("./wasm-assets", () => ({ + resolveWasmBase: vi.fn(async () => "/wasm"), +})); + +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; + +const commandRequest = (cmd = "run"): NgspiceRequest => ({ kind: "command", cmd }); + +const service = () => { + const installed = globalThis.ngspiceService; + if (!installed) throw new Error("ngspice service was not installed"); + return installed; +}; + +async function readyRequest( + workerIndex: number, + requestBody: NgspiceRequest = commandRequest(), +): Promise<{ worker: FakeWorker; request: Promise; id: number }> { + const request = service().request(requestBody); + const worker = await waitForWorker(workerIndex); + worker.emitMessage({ ready: true }); + await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalledTimes(1)); + const [{ id }] = worker.postMessage.mock.calls[0] as [{ id: number }]; + return { worker, request, id }; +} + +describe("ngspice service worker lifetime", () => { + beforeEach(() => { + FakeWorker.instances = []; + mockedResolveWasmBase.mockReset(); + mockedResolveWasmBase.mockResolvedValue("/wasm"); + vi.stubGlobal("window", { location: { href: "https://pcbjam.test/editor" } }); + vi.stubGlobal("Worker", FakeWorker); + let nextBlob = 1; + vi.spyOn(URL, "createObjectURL").mockImplementation( + () => `blob:ngspice-test-${nextBlob++}`, + ); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + delete globalThis.ngspiceService; + delete globalThis.__ngspiceOnEvent; + installNgspiceService(vi.fn(), { + bootTimeoutMs: TEST_BOOT_TIMEOUT_MS, + responseTimeoutMs: TEST_RESPONSE_TIMEOUT_MS, + }); + }); + + afterEach(() => { + delete globalThis.ngspiceService; + delete globalThis.__ngspiceOnEvent; + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("bounds a never-ready generation, retires it, and boots a fresh worker", async () => { + vi.useFakeTimers(); + + const timedOut = service().request(commandRequest("never ready")); + await vi.advanceTimersByTimeAsync(0); + const deadWorker = FakeWorker.instances[0]!; + expect(deadWorker).toBeDefined(); + + await vi.advanceTimersByTimeAsync(TEST_BOOT_TIMEOUT_MS); + await expect(timedOut).resolves.toEqual({ + error: expect.stringContaining( + `ngspice_service boot timed out after ${TEST_BOOT_TIMEOUT_MS} ms`, + ), + }); + expect(deadWorker.terminate).toHaveBeenCalledTimes(1); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:ngspice-test-1"); + expect(vi.getTimerCount()).toBe(0); + + // The old listener was removed and the slot is retired. A late ready frame + // cannot resurrect this generation. + deadWorker.emitMessage({ ready: true }); + + const recovered = service().request(commandRequest("fresh")); + await vi.advanceTimersByTimeAsync(0); + const freshWorker = FakeWorker.instances[1]!; + expect(freshWorker).toBeDefined(); + freshWorker.emitMessage({ ready: true }); + await vi.advanceTimersByTimeAsync(0); + const [{ id }] = freshWorker.postMessage.mock.calls[0] as [{ id: number }]; + freshWorker.emitMessage({ id, res: { ret: 0 } }); + + await expect(recovered).resolves.toEqual({ ret: 0 }); + expect(freshWorker.terminate).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("bounds delivery resolution and never creates a late retired worker", async () => { + vi.useFakeTimers(); + let releaseBase!: (base: string) => void; + mockedResolveWasmBase.mockImplementationOnce( + () => new Promise((resolve) => { + releaseBase = resolve; + }), + ); + + const timedOut = service().request(commandRequest("resolve forever")); + await vi.advanceTimersByTimeAsync(0); + expect(FakeWorker.instances).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(TEST_BOOT_TIMEOUT_MS); + await expect(timedOut).resolves.toEqual({ + error: expect.stringContaining( + `ngspice_service boot timed out after ${TEST_BOOT_TIMEOUT_MS} ms`, + ), + }); + expect(FakeWorker.instances).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + + // Delivery may still finish because the underlying fetch is not abortable + // here. Its retired generation must not create a Worker or replace the + // fresh slot which the next exact request owns. + releaseBase("/stale-wasm"); + await vi.advanceTimersByTimeAsync(0); + expect(FakeWorker.instances).toHaveLength(0); + + const recovered = service().request(commandRequest("fresh")); + await vi.advanceTimersByTimeAsync(0); + const freshWorker = FakeWorker.instances[0]!; + expect(freshWorker).toBeDefined(); + freshWorker.emitMessage({ ready: true }); + await vi.advanceTimersByTimeAsync(0); + const [{ id }] = freshWorker.postMessage.mock.calls[0] as [{ id: number }]; + freshWorker.emitMessage({ id, res: { ret: 0 } }); + + await expect(recovered).resolves.toEqual({ ret: 0 }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("retires a ready-but-silent generation and settles all concurrent ids", async () => { + vi.useFakeTimers(); + + const first = service().request(commandRequest("silent one")); + await vi.advanceTimersByTimeAsync(0); + const deadWorker = FakeWorker.instances[0]!; + deadWorker.emitMessage({ ready: true }); + await vi.advanceTimersByTimeAsync(0); + + const second = service().request(commandRequest("silent two")); + await vi.advanceTimersByTimeAsync(0); + expect(deadWorker.postMessage).toHaveBeenCalledTimes(2); + const [{ id: firstId }] = deadWorker.postMessage.mock.calls[0] as [ + { id: number }, + ]; + const [{ id: secondId }] = deadWorker.postMessage.mock.calls[1] as [ + { id: number }, + ]; + expect(firstId).not.toBe(secondId); + + await vi.advanceTimersByTimeAsync(TEST_RESPONSE_TIMEOUT_MS); + const timeout = + `ngspice_service response timed out after ${TEST_RESPONSE_TIMEOUT_MS} ms`; + await expect(first).resolves.toEqual({ error: timeout }); + await expect(second).resolves.toEqual({ error: timeout }); + expect(deadWorker.terminate).toHaveBeenCalledTimes(1); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:ngspice-test-1"); + expect(vi.getTimerCount()).toBe(0); + + const recovered = service().request(commandRequest("recovered")); + await vi.advanceTimersByTimeAsync(0); + const freshWorker = FakeWorker.instances[1]!; + freshWorker.emitMessage({ ready: true }); + await vi.advanceTimersByTimeAsync(0); + const [{ id: freshId }] = freshWorker.postMessage.mock.calls[0] as [ + { id: number }, + ]; + let recoveredSettled = false; + const observed = recovered.then((response) => { + recoveredSettled = true; + return response; + }); + + // Even a late old-generation callback carrying the current numeric id is + // ignored. The replacement remains live until its own Worker answers. + deadWorker.emitMessage({ id: freshId, res: { ret: 99 } }); + await Promise.resolve(); + expect(recoveredSettled).toBe(false); + expect(freshWorker.terminate).not.toHaveBeenCalled(); + + freshWorker.emitMessage({ id: freshId, res: { ret: 0 } }); + await expect(observed).resolves.toEqual({ ret: 0 }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("settles a first request when the worker crashes before ready and retries", async () => { + const firstRequest = service().request(commandRequest("first")); + const firstWorker = await waitForWorker(0); + + firstWorker.emitError("boot trap"); + + await expect(firstRequest).resolves.toEqual({ + error: expect.stringContaining("ngspice_service crashed: boot trap"), + }); + expect(firstWorker.terminate).toHaveBeenCalledTimes(1); + + const retry = await readyRequest(1, commandRequest("retry")); + retry.worker.emitMessage({ id: retry.id, res: { ret: 0 } }); + await expect(retry.request).resolves.toEqual({ ret: 0 }); + }); + + it("fails every exact pending request on a runtime crash and ignores stale callbacks", async () => { + const first = await readyRequest(0, commandRequest("one")); + const alsoPending = service().request(commandRequest("two")); + await vi.waitFor(() => expect(first.worker.postMessage).toHaveBeenCalledTimes(2)); + + first.worker.emitError("wasm trap"); + + await expect(first.request).resolves.toEqual({ + error: "ngspice_service crashed: wasm trap", + }); + await expect(alsoPending).resolves.toEqual({ + error: "ngspice_service crashed: wasm trap", + }); + expect(first.worker.terminate).toHaveBeenCalledTimes(1); + + const second = await readyRequest(1, commandRequest("recovered")); + let secondSettled = false; + const observedSecond = second.request.then((response) => { + secondSettled = true; + return response; + }); + + first.worker.emitMessage({ id: second.id, res: { ret: 99 } }); + first.worker.emitError("late old error"); + await Promise.resolve(); + expect(secondSettled).toBe(false); + expect(second.worker.terminate).not.toHaveBeenCalled(); + + second.worker.emitMessage({ id: second.id, res: { ret: 0 } }); + await expect(observedSecond).resolves.toEqual({ ret: 0 }); + }); + + it("keeps requests concurrent and correlates out-of-order responses by id", async () => { + const first = await readyRequest(0, commandRequest("slow")); + const secondRequest = service().request(commandRequest("fast")); + await vi.waitFor(() => expect(first.worker.postMessage).toHaveBeenCalledTimes(2)); + const [{ id: secondId }] = first.worker.postMessage.mock.calls[1] as [ + { id: number }, + ]; + + first.worker.emitMessage({ id: secondId, res: { ret: 2 } }); + await expect(secondRequest).resolves.toEqual({ ret: 2 }); + + let firstSettled = false; + const observedFirst = first.request.then((response) => { + firstSettled = true; + return response; + }); + await Promise.resolve(); + expect(firstSettled).toBe(false); + + first.worker.emitMessage({ id: first.id, res: { ret: 1 } }); + await expect(observedFirst).resolves.toEqual({ ret: 1 }); + }); + + it("turns bootError into a response and keeps the next generation retryable", async () => { + const failedRequest = service().request(commandRequest()); + const failedWorker = await waitForWorker(0); + + failedWorker.emitMessage({ bootError: "initialization failed" }); + + await expect(failedRequest).resolves.toEqual({ + error: expect.stringContaining( + "ngspice_service boot failed: initialization failed", + ), + }); + expect(failedWorker.terminate).toHaveBeenCalledTimes(1); + + const retry = await readyRequest(1); + retry.worker.emitMessage({ id: retry.id, res: { ret: 0 } }); + await expect(retry.request).resolves.toEqual({ ret: 0 }); + }); + + it("fails all boot and runtime waiters on decode errors, then recovers", async () => { + const bootRequest = service().request(commandRequest("boot-one")); + const alsoBooting = service().request(commandRequest("boot-two")); + const bootWorker = await waitForWorker(0); + bootWorker.emitMessageError(); + await expect(bootRequest).resolves.toEqual({ + error: expect.stringContaining("ngspice_service crashed: message decode failed"), + }); + await expect(alsoBooting).resolves.toEqual({ + error: expect.stringContaining("ngspice_service crashed: message decode failed"), + }); + + const runtime = await readyRequest(1, commandRequest("runtime")); + const alsoPending = service().request(commandRequest("also-runtime")); + await vi.waitFor(() => expect(runtime.worker.postMessage).toHaveBeenCalledTimes(2)); + runtime.worker.emitMessageError(); + await expect(runtime.request).resolves.toEqual({ + error: "ngspice_service crashed: message decode failed", + }); + await expect(alsoPending).resolves.toEqual({ + error: "ngspice_service crashed: message decode failed", + }); + + const recovered = await readyRequest(2, commandRequest("recovered")); + recovered.worker.emitMessage({ id: recovered.id, res: { ret: 0 } }); + await expect(recovered.request).resolves.toEqual({ ret: 0 }); + }); + + it("settles a synchronous postMessage failure without leaking its pending id", async () => { + const failedRequest = service().request(commandRequest("bad post")); + const worker = await waitForWorker(0); + worker.postMessage.mockImplementationOnce(() => { + throw new DOMException("cannot clone", "DataCloneError"); + }); + worker.emitMessage({ ready: true }); + + await expect(failedRequest).resolves.toEqual({ + error: expect.stringContaining("ngspice_service request failed: DataCloneError"), + }); + + const retry = service().request(commandRequest("good post")); + await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalledTimes(2)); + const [{ id }] = worker.postMessage.mock.calls[1] as [{ id: number }]; + worker.emitMessage({ id, res: { ret: 0 } }); + await expect(retry).resolves.toEqual({ ret: 0 }); + + // A late reply for the failed request cannot consume the recovered request. + const [{ id: failedId }] = worker.postMessage.mock.calls[0] as [{ id: number }]; + expect(id).not.toBe(failedId); + }); + + it("drops queued events and late event callbacks from a retired generation", async () => { + const first = await readyRequest(0); + first.worker.emitMessage({ + evt: { kind: "char", lines: ["old"] }, + eventSequence: 1, + eventBytes: 31, + }); + first.worker.emitError("worker died"); + await expect(first.request).resolves.toEqual({ + error: "ngspice_service crashed: worker died", + }); + + const events: string[] = []; + globalThis.__ngspiceOnEvent = (event) => events.push(event.lines?.[0] ?? event.kind); + + const second = await readyRequest(1); + first.worker.emitMessage({ + evt: { kind: "char", lines: ["late old"] }, + eventSequence: 2, + eventBytes: 36, + }); + second.worker.emitMessage({ + evt: { kind: "char", lines: ["new"] }, + eventSequence: 1, + eventBytes: 31, + }); + second.worker.emitMessage({ id: second.id, res: { ret: 0 } }); + + await expect(second.request).resolves.toEqual({ ret: 0 }); + expect(events).toEqual(["new"]); + }); + + it("acks a pre-handler queued frame at enqueue so the transport window never starves", async () => { + // Pin for the E-6 refinement: placing a frame in the bounded mirror queue + // IS taking ownership — without the enqueue-time ack, a stream that starts + // before the C++ handler installs pins the worker's 64-frame credit + // window open forever (observed as the ngspice-probe bg_halt starvation). + const first = await readyRequest(0); + expect(globalThis.__ngspiceOnEvent).toBeUndefined(); + + first.worker.emitMessage({ + evt: { kind: "char", lines: ["early"] }, + eventSequence: 1, + eventBytes: 33, + }); + await vi.waitFor(() => + expect(first.worker.postMessage).toHaveBeenCalledWith({ + eventAck: { sequence: 1, bytes: 33 }, + })); + + // Once the handler installs, the queued frame is dispatched WITHOUT a + // second ack; only the new frame earns one. + const events: string[] = []; + globalThis.__ngspiceOnEvent = (event) => events.push(event.lines?.[0] ?? event.kind); + first.worker.emitMessage({ + evt: { kind: "char", lines: ["late"] }, + eventSequence: 2, + eventBytes: 32, + }); + await vi.waitFor(() => expect(events).toEqual(["early", "late"])); + 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 frame, none repeated on drain").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("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({ + fatal: "ngspice event line exceeds 1048576 UTF-8 bytes", + }); + + await expect(first.request).resolves.toEqual({ + error: "ngspice_service crashed: event stream failure: " + + "ngspice event line exceeds 1048576 UTF-8 bytes", + }); + expect(first.worker.terminate).toHaveBeenCalledTimes(1); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:ngspice-test-1"); + + const recovered = await readyRequest(1, commandRequest("fresh generation")); + recovered.worker.emitMessage({ id: recovered.id, res: { ret: 0 } }); + await expect(recovered.request).resolves.toEqual({ ret: 0 }); + }); +}); diff --git a/web/standalone/src/wasm/ngspice-service.ts b/web/standalone/src/wasm/ngspice-service.ts index 2c8b2b1d3..57c68015d 100644 --- a/web/standalone/src/wasm/ngspice-service.ts +++ b/web/standalone/src/wasm/ngspice-service.ts @@ -83,107 +83,365 @@ export function ngspiceWorkerBlobParts(glueHref: string): string[] { ]; } -export function installNgspiceService(log: (msg: string) => void): void { +export interface NgspiceServiceWatchdogs { + /** Maximum time from the first request until a new generation announces `ready`. */ + bootTimeoutMs?: number; + /** Maximum time for any one request in a ready generation to answer. */ + responseTimeoutMs?: number; +} + +// These are last-resort failure bounds, not normal scheduling deadlines. +// SPICE startup and foreground simulations can be expensive on slow devices, +// so production defaults deliberately leave a large margin. +export const NGSPICE_BOOT_TIMEOUT_MS = 2 * 60_000; +export const NGSPICE_RESPONSE_TIMEOUT_MS = 30 * 60_000; + +export function installNgspiceService( + log: (msg: string) => void, + watchdogs: NgspiceServiceWatchdogs = {}, +): void { if (globalThis.ngspiceService) return; + const bootTimeoutMs = + watchdogs.bootTimeoutMs ?? NGSPICE_BOOT_TIMEOUT_MS; + const responseTimeoutMs = + watchdogs.responseTimeoutMs ?? NGSPICE_RESPONSE_TIMEOUT_MS; + + interface WorkerSlot { + generation: number; + worker?: Worker; + workerUrl?: string; + failed: boolean; + ready: Promise; + bootTimer?: ReturnType; + rejectBoot?: (reason?: unknown) => void; + removeBootListener?: () => void; + } + + interface PendingRequest { + generation: number; + resolve: (res: NgspiceResponse) => void; + timer: ReturnType; + } + let nextId = 1; - const pending = new Map void>(); - let workerP: Promise | null = null; + let nextGeneration = 1; + const pending = new Map(); + let workerSlot: WorkerSlot | null = null; // Events can arrive before the client stub installs __ngspiceOnEvent // (the handler comes with the first editor-side ngSpice_Init). - const evtQueue: NgspiceEvent[] = []; - const dispatchEvt = (evt: NgspiceEvent) => { + interface QueuedEventFrame { + generation: number; + evt: NgspiceEvent; + sequence: number; + bytes: number; + } + const MAX_QUEUED_EVENT_FRAMES = 64; + const MAX_QUEUED_EVENT_BYTES = 8 * 1024 * 1024; + const evtQueue: QueuedEventFrame[] = []; + let evtQueueBytes = 0; + const ackEvent = (slot: WorkerSlot, frame: QueuedEventFrame): boolean => { + if (slot.failed || workerSlot !== slot || !slot.worker) return false; + try { + slot.worker.postMessage({ + eventAck: { sequence: frame.sequence, bytes: frame.bytes }, + }); + return true; + } catch (error) { + retireWorker(slot, `ngspice_service event acknowledgment failed: ${String(error)}`); + return false; + } + }; + const dispatchEvt = ( + slot: WorkerSlot, + evt: NgspiceEvent, + sequence: number, + bytes: number, + ) => { + if (slot.failed || workerSlot !== slot) return; + if (!Number.isSafeInteger(sequence) || sequence < 1 + || !Number.isSafeInteger(bytes) || bytes < 1 + || bytes > MAX_QUEUED_EVENT_BYTES) { + retireWorker(slot, "ngspice_service sent invalid event-frame credit"); + return; + } + const frame = { generation: slot.generation, evt, sequence, bytes }; const handler = globalThis.__ngspiceOnEvent; if (handler) { - while (evtQueue.length) handler(evtQueue.shift()!); - handler(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); + } } else { - evtQueue.push(evt); + if (evtQueue.length >= MAX_QUEUED_EVENT_FRAMES + || evtQueueBytes > MAX_QUEUED_EVENT_BYTES - bytes) { + retireWorker(slot, "ngspice_service event-frame queue exceeded credit"); + return; + } + evtQueue.push(frame); + evtQueueBytes += bytes; + // Enqueueing IS taking ownership: the frame now lives in this bounded + // mirror queue, so its transport credit is released — otherwise a + // stream that starts before the C++ handler installs starves the + // worker's window forever. The queue caps above stay the pre-handler + // bound (M-6: count + bytes). + ackEvent(slot, frame); } }; - const failAllPending = (why: string) => { - for (const [, resolve] of pending) resolve({ error: why }); - pending.clear(); + // 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; + pending.delete(id); + clearTimeout(request.timer); + request.resolve({ error: why }); + } }; - const ensureWorker = (): Promise => { - if (!workerP) { - workerP = (async () => { + const retireWorker = (slot: WorkerSlot, why: string): void => { + if (slot.failed) return; + slot.failed = true; + if (slot.bootTimer !== undefined) { + clearTimeout(slot.bootTimer); + slot.bootTimer = undefined; + } + slot.removeBootListener?.(); + slot.removeBootListener = undefined; + failPending(slot.generation, why); + for (let i = evtQueue.length - 1; i >= 0; --i) { + if (evtQueue[i]!.generation === slot.generation) { + evtQueueBytes -= evtQueue[i]!.bytes; + evtQueue.splice(i, 1); + } + } + if (workerSlot === slot) workerSlot = null; + try { + slot.worker?.terminate(); + } catch { + /* already gone */ + } + if (slot.workerUrl) { + try { + URL.revokeObjectURL(slot.workerUrl); + } catch { + /* URL cleanup must not prevent exact wait settlement */ + } + slot.workerUrl = undefined; + } + 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 => { + if (!workerSlot) { + const slot = { + generation: nextGeneration++, + failed: false, + } as WorkerSlot; + // Publish the generation before its async boot reaches the first await. + // This also lets every continuation test exact slot ownership directly. + workerSlot = slot; + + // The editor is parked for this entire operation, including delivery + // discovery. Start the generation deadline before resolveWasmBase(): a + // hung manifest/CDN lookup must settle the exact wait just like a Worker + // which never announces ready. + const bootDeadline = new Promise((_resolve, reject) => { + slot.rejectBoot = reject; + slot.bootTimer = setTimeout(() => { + if (slot.failed || workerSlot !== slot) return; + const why = + `ngspice_service boot timed out after ${bootTimeoutMs} ms`; + log(`[ngspice] ${why} — resetting service`); + retireWorker(slot, why); + }, bootTimeoutMs); + }); + + const boot = (async () => { const base = await resolveWasmBase("ngspice_service"); + if (slot.failed || workerSlot !== slot) { + throw new Error( + "ngspice_service worker retired during delivery resolution", + ); + } const glue = new URL(`${base}/ngspice_service.js`, window.location.href).href; log(`[ngspice] booting ngspice_service from ${base}`); - const worker = new Worker( - URL.createObjectURL( - new Blob(ngspiceWorkerBlobParts(glue), { type: "text/javascript" }), - ), + slot.workerUrl = URL.createObjectURL( + new Blob(ngspiceWorkerBlobParts(glue), { type: "text/javascript" }), ); + const worker = new Worker(slot.workerUrl); + slot.worker = worker; worker.onmessage = (e) => { + 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; + } if (data.evt) { - dispatchEvt(data.evt as NgspiceEvent); + dispatchEvt( + slot, + data.evt as NgspiceEvent, + data.eventSequence, + data.eventBytes, + ); return; } if (typeof data.id !== "number") return; - const resolve = pending.get(data.id); - if (resolve) { + const request = pending.get(data.id); + if (request?.generation === slot.generation) { pending.delete(data.id); - resolve(data.res as NgspiceResponse); + clearTimeout(request.timer); + request.resolve(data.res as NgspiceResponse); } }; // A dead worker (hard ngspice fault) must not strand the editor // suspended in an EM_ASYNC_JS bridge: fail everything in flight and // make the next request boot a fresh worker. - worker.onerror = (e) => { - log(`[ngspice] worker error: ${e.message} — resetting service`); - failAllPending(`ngspice_service crashed: ${e.message}`); - workerP = null; - try { - worker.terminate(); - } catch { - /* already gone */ - } + const failWorker = (detail: string) => { + const why = `ngspice_service crashed: ${detail}`; + log(`[ngspice] worker error: ${detail} — resetting service`); + retireWorker(slot, why); }; + worker.onerror = (e) => failWorker(e.message || "worker error"); + worker.onmessageerror = () => failWorker("message decode failed"); - await new Promise((resolve, reject) => { + await new Promise((resolve) => { const onFirst = (e: MessageEvent) => { if (e.data?.ready) { - worker.removeEventListener("message", onFirst); + if (slot.bootTimer !== undefined) { + clearTimeout(slot.bootTimer); + slot.bootTimer = undefined; + } + slot.removeBootListener?.(); + slot.removeBootListener = undefined; + slot.rejectBoot = undefined; resolve(); } else if (e.data?.bootError) { - reject(new Error(e.data.bootError)); + const why = `ngspice_service boot failed: ${String(e.data.bootError)}`; + retireWorker(slot, why); } }; worker.addEventListener("message", onFirst); + slot.removeBootListener = () => + worker.removeEventListener("message", onFirst); }); + if (slot.failed || workerSlot !== slot) { + throw new Error("ngspice_service worker retired during boot"); + } log("[ngspice] ngspice_service ready"); - return worker; - })().catch((e) => { - workerP = null; // a failed boot must stay retryable + return slot; + })(); + + slot.ready = Promise.race([boot, bootDeadline]).catch((e) => { + // A late failure from a retired generation must not clear a replacement + // which a re-entrant caller has already started. + retireWorker(slot, `ngspice_service unavailable: ${String(e)}`); throw e; }); } - return workerP; + return workerSlot.ready; + }; + + const post = (slot: WorkerSlot, req: NgspiceRequest): Promise => { + const worker = slot.worker; + if (!worker || slot.failed || workerSlot !== slot) { + return Promise.resolve({ error: "ngspice_service worker is unavailable" }); + } + const id = nextId++; + return new Promise((resolve) => { + const timer = setTimeout(() => { + if (pending.get(id)?.generation !== slot.generation) return; + const why = + `ngspice_service response timed out after ${responseTimeoutMs} ms`; + log(`[ngspice] ${why} — resetting service`); + retireWorker(slot, why); + }, responseTimeoutMs); + pending.set(id, { generation: slot.generation, resolve, timer }); + try { + worker.postMessage({ id, req }); + } catch (error) { + pending.delete(id); + clearTimeout(timer); + resolve({ error: `ngspice_service request failed: ${String(error)}` }); + } + }); }; const request = async (req: NgspiceRequest): Promise => { - let worker: Worker; + let slot: WorkerSlot; try { - worker = await ensureWorker(); + slot = await ensureWorker(); } catch (e) { return { error: `ngspice_service unavailable: ${e}` }; } - - const id = nextId++; - return new Promise((resolve) => { - pending.set(id, resolve); - worker.postMessage({ id, req }); - }); + return post(slot, req); }; globalThis.ngspiceService = { request }; diff --git a/web/standalone/src/wasm/ngspice-worker.js b/web/standalone/src/wasm/ngspice-worker.js index 2ef14700c..251b9d2fb 100644 Binary files a/web/standalone/src/wasm/ngspice-worker.js and b/web/standalone/src/wasm/ngspice-worker.js differ diff --git a/web/standalone/src/wasm/occ-service.test.ts b/web/standalone/src/wasm/occ-service.test.ts new file mode 100644 index 000000000..493446278 --- /dev/null +++ b/web/standalone/src/wasm/occ-service.test.ts @@ -0,0 +1,416 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/download", () => ({ downloadBytes: vi.fn() })); +vi.mock("./libs/models-bridge", () => ({ + collectBoardModelFiles: vi.fn(async () => []), +})); +vi.mock("./occ-worker.js?raw", () => ({ default: "// fake OCC worker" })); +vi.mock("./wasm-assets", () => ({ + resolveWasmBase: vi.fn(async () => "/wasm"), +})); + +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); + +const TEST_MODEL_PREFETCH_TIMEOUT_MS = 500; +const TEST_BOOT_TIMEOUT_MS = 1_000; +const TEST_RESPONSE_TIMEOUT_MS = 5_000; + +const loadRequest = () => ({ + kind: "loadModel" as const, + bytes: new Uint8Array([1, 2, 3]), + ext: "step", +}); + +const exportRequest = () => ({ + kind: "export" as const, + board: new TextEncoder().encode("(kicad_pcb)"), + jobJson: '{"type":"step"}', + fileName: "board.step", +}); + +const service = () => { + const installed = globalThis.occService; + if (!installed) throw new Error("OCC service was not installed"); + return installed; +}; + +async function readyRequest(workerIndex: number): Promise<{ + worker: FakeWorker; + request: Promise; + id: number; +}> { + const request = service().request(loadRequest()); + const worker = await waitForWorker(workerIndex); + worker.emitMessage({ ready: true }); + await vi.waitFor(() => expect(worker.postMessage).toHaveBeenCalledTimes(1)); + const [{ id }] = worker.postMessage.mock.calls[0] as [{ id: number }]; + return { worker, request, id }; +} + +describe("OCC service worker lifetime", () => { + beforeEach(() => { + FakeWorker.instances = []; + mockedCollectBoardModelFiles.mockReset(); + mockedCollectBoardModelFiles.mockResolvedValue([]); + mockedResolveWasmBase.mockReset(); + mockedResolveWasmBase.mockResolvedValue("/wasm"); + vi.stubGlobal("window", { location: { href: "https://pcbjam.test/editor" } }); + vi.stubGlobal("Worker", FakeWorker); + let nextBlob = 1; + vi.spyOn(URL, "createObjectURL").mockImplementation( + () => `blob:occ-test-${nextBlob++}`, + ); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + delete globalThis.occService; + installOccService(vi.fn(), { + modelPrefetchTimeoutMs: TEST_MODEL_PREFETCH_TIMEOUT_MS, + bootTimeoutMs: TEST_BOOT_TIMEOUT_MS, + responseTimeoutMs: TEST_RESPONSE_TIMEOUT_MS, + }); + }); + + afterEach(() => { + delete globalThis.occService; + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("bounds a never-ready generation, retires it, and boots a fresh worker", async () => { + vi.useFakeTimers(); + + const timedOut = service().request(loadRequest()); + await vi.advanceTimersByTimeAsync(0); + const deadWorker = FakeWorker.instances[0]!; + expect(deadWorker).toBeDefined(); + + await vi.advanceTimersByTimeAsync(TEST_BOOT_TIMEOUT_MS); + await expect(timedOut).resolves.toMatchObject({ + ok: false, + report: expect.stringContaining( + `occ_service boot timed out after ${TEST_BOOT_TIMEOUT_MS} ms`, + ), + }); + expect(deadWorker.terminate).toHaveBeenCalledTimes(1); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:occ-test-1"); + expect(vi.getTimerCount()).toBe(0); + + deadWorker.emitMessage({ ready: true }); + + const recovered = service().request(loadRequest()); + await vi.advanceTimersByTimeAsync(0); + const freshWorker = FakeWorker.instances[1]!; + expect(freshWorker).toBeDefined(); + freshWorker.emitMessage({ ready: true }); + await vi.advanceTimersByTimeAsync(0); + const [{ id }] = freshWorker.postMessage.mock.calls[0] as [{ id: number }]; + freshWorker.emitMessage({ id, res: { ok: true, report: "fresh" } }); + + await expect(recovered).resolves.toEqual({ ok: true, report: "fresh" }); + expect(freshWorker.terminate).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("bounds delivery resolution and never creates a late retired worker", async () => { + vi.useFakeTimers(); + let releaseBase!: (base: string) => void; + mockedResolveWasmBase.mockImplementationOnce( + () => new Promise((resolve) => { + releaseBase = resolve; + }), + ); + + const timedOut = service().request(loadRequest()); + await vi.advanceTimersByTimeAsync(0); + expect(FakeWorker.instances).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(TEST_BOOT_TIMEOUT_MS); + await expect(timedOut).resolves.toMatchObject({ + ok: false, + report: expect.stringContaining( + `occ_service boot timed out after ${TEST_BOOT_TIMEOUT_MS} ms`, + ), + }); + expect(FakeWorker.instances).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + + // Delivery may still finish because the underlying fetch is not abortable + // here. Its retired generation must not create a Worker or replace the + // fresh slot which the next exact request owns. + releaseBase("/stale-wasm"); + await vi.advanceTimersByTimeAsync(0); + expect(FakeWorker.instances).toHaveLength(0); + + const recovered = service().request(loadRequest()); + await vi.advanceTimersByTimeAsync(0); + const freshWorker = FakeWorker.instances[0]!; + expect(freshWorker).toBeDefined(); + freshWorker.emitMessage({ ready: true }); + await vi.advanceTimersByTimeAsync(0); + const [{ id }] = freshWorker.postMessage.mock.calls[0] as [{ id: number }]; + freshWorker.emitMessage({ id, res: { ok: true, report: "fresh" } }); + + await expect(recovered).resolves.toEqual({ ok: true, report: "fresh" }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("exports without a hung model prefetch and ignores its late result", async () => { + vi.useFakeTimers(); + let releaseModels!: ( + models: Array<{ path: string; bytes: Uint8Array }>, + ) => void; + let prefetchSignal: AbortSignal | undefined; + mockedCollectBoardModelFiles.mockImplementationOnce( + (_board, _concurrency, signal) => { + prefetchSignal = signal; + return new Promise((resolve) => { + releaseModels = resolve; + }); + }, + ); + const input = exportRequest(); + const request = service().request(input); + + // Request fields are captured before optional asynchronous preparation. + input.fileName = "mutated-after-dispatch.step"; + await vi.advanceTimersByTimeAsync(0); + expect(FakeWorker.instances).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(TEST_MODEL_PREFETCH_TIMEOUT_MS); + expect(prefetchSignal?.aborted).toBe(true); + const worker = FakeWorker.instances[0]!; + expect(worker).toBeDefined(); + worker.emitMessage({ ready: true }); + await vi.advanceTimersByTimeAsync(0); + expect(worker.postMessage).toHaveBeenCalledTimes(1); + const [{ id, req: dispatched }] = worker.postMessage.mock.calls[0] as [ + { + id: number; + req: { + kind: "export"; + fileName: string; + models: Array<{ path: string; bytes: Uint8Array }>; + }; + }, + ]; + expect(dispatched).not.toBe(input); + expect(dispatched.fileName).toBe("board.step"); + expect(dispatched.models).toEqual([]); + expect(input).not.toHaveProperty("models"); + + releaseModels([ + { path: "Late.3dshapes/model.step", bytes: new Uint8Array([9]) }, + ]); + await vi.advanceTimersByTimeAsync(0); + expect(dispatched.models).toEqual([]); + 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\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); + }); + + it("retires a ready-but-silent generation and settles all concurrent ids", async () => { + vi.useFakeTimers(); + + const first = service().request(loadRequest()); + await vi.advanceTimersByTimeAsync(0); + const deadWorker = FakeWorker.instances[0]!; + deadWorker.emitMessage({ ready: true }); + await vi.advanceTimersByTimeAsync(0); + + const second = service().request(loadRequest()); + await vi.advanceTimersByTimeAsync(0); + expect(deadWorker.postMessage).toHaveBeenCalledTimes(2); + const [{ id: firstId }] = deadWorker.postMessage.mock.calls[0] as [ + { id: number }, + ]; + const [{ id: secondId }] = deadWorker.postMessage.mock.calls[1] as [ + { id: number }, + ]; + expect(firstId).not.toBe(secondId); + + await vi.advanceTimersByTimeAsync(TEST_RESPONSE_TIMEOUT_MS); + const timeout = + `occ_service response timed out after ${TEST_RESPONSE_TIMEOUT_MS} ms`; + await expect(first).resolves.toEqual({ ok: false, report: timeout }); + await expect(second).resolves.toEqual({ ok: false, report: timeout }); + expect(deadWorker.terminate).toHaveBeenCalledTimes(1); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:occ-test-1"); + expect(vi.getTimerCount()).toBe(0); + + const recovered = service().request(loadRequest()); + await vi.advanceTimersByTimeAsync(0); + const freshWorker = FakeWorker.instances[1]!; + freshWorker.emitMessage({ ready: true }); + await vi.advanceTimersByTimeAsync(0); + const [{ id: freshId }] = freshWorker.postMessage.mock.calls[0] as [ + { id: number }, + ]; + let recoveredSettled = false; + const observed = recovered.then((response) => { + recoveredSettled = true; + return response; + }); + + deadWorker.emitMessage({ + id: freshId, + res: { ok: true, report: "stale" }, + }); + await Promise.resolve(); + expect(recoveredSettled).toBe(false); + expect(freshWorker.terminate).not.toHaveBeenCalled(); + + freshWorker.emitMessage({ + id: freshId, + res: { ok: true, report: "fresh" }, + }); + await expect(observed).resolves.toEqual({ ok: true, report: "fresh" }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("settles every pending request on a runtime crash and restarts", async () => { + const first = await readyRequest(0); + const alsoPending = service().request(loadRequest()); + await vi.waitFor(() => expect(first.worker.postMessage).toHaveBeenCalledTimes(2)); + + first.worker.emitError("wasm trap"); + + await expect(first.request).resolves.toEqual({ + ok: false, + report: "occ_service crashed: wasm trap", + }); + await expect(alsoPending).resolves.toEqual({ + ok: false, + report: "occ_service crashed: wasm trap", + }); + expect(first.worker.terminate).toHaveBeenCalledTimes(1); + + const second = await readyRequest(1); + expect(second.worker).not.toBe(first.worker); + + let secondSettled = false; + const observedSecond = second.request.then((response) => { + secondSettled = true; + return response; + }); + + // A callback retained by the retired generation cannot consume the current + // generation's request, even if it carries that request's numeric id. + first.worker.emitMessage({ + id: second.id, + res: { ok: true, report: "stale result" }, + }); + await Promise.resolve(); + expect(secondSettled).toBe(false); + + second.worker.emitMessage({ + id: second.id, + res: { ok: true, report: "fresh result" }, + }); + await expect(observedSecond).resolves.toEqual({ + ok: true, + report: "fresh result", + }); + }); + + it("turns a boot error into a response and keeps the next boot retryable", async () => { + const failedRequest = service().request(loadRequest()); + const failedWorker = await waitForWorker(0); + + failedWorker.emitMessage({ bootError: "initialization failed" }); + + await expect(failedRequest).resolves.toMatchObject({ + ok: false, + report: expect.stringContaining("occ_service boot failed: initialization failed"), + }); + expect(failedWorker.terminate).toHaveBeenCalledTimes(1); + + const retry = await readyRequest(1); + retry.worker.emitMessage({ + id: retry.id, + res: { ok: true, report: "recovered" }, + }); + await expect(retry.request).resolves.toEqual({ ok: true, report: "recovered" }); + }); + + it("fails every request in a decode-faulted generation, then recovers", async () => { + const failed = await readyRequest(0); + const alsoPending = service().request(loadRequest()); + await vi.waitFor(() => expect(failed.worker.postMessage).toHaveBeenCalledTimes(2)); + failed.worker.emitMessageError(); + + await expect(failed.request).resolves.toEqual({ + ok: false, + report: "occ_service transport failed: message decode failed", + }); + await expect(alsoPending).resolves.toEqual({ + ok: false, + report: "occ_service transport failed: message decode failed", + }); + expect(failed.worker.terminate).toHaveBeenCalledTimes(1); + + const retry = await readyRequest(1); + retry.worker.emitMessage({ + id: retry.id, + res: { ok: true, report: "decoded" }, + }); + await expect(retry.request).resolves.toEqual({ ok: true, report: "decoded" }); + }); +}); diff --git a/web/standalone/src/wasm/occ-service.ts b/web/standalone/src/wasm/occ-service.ts index 319e3cc62..ebca85683 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"; @@ -67,101 +67,326 @@ export function occWorkerBlobParts(glueHref: string): string[] { ]; } -export function installOccService(log: (msg: string) => void): void { +export interface OccServiceWatchdogs { + /** Maximum time to wait for optional board-model prefetch before exporting without it. */ + modelPrefetchTimeoutMs?: number; + /** Maximum time from the first request until a new generation announces `ready`. */ + bootTimeoutMs?: number; + /** Maximum time for any one request in a ready generation to answer. */ + responseTimeoutMs?: number; +} + +// These are last-resort failure bounds, not normal scheduling deadlines. +// OCC startup, model parsing, and board export can all be expensive on slow +// devices, so production defaults deliberately leave a large margin. +export const OCC_BOOT_TIMEOUT_MS = 2 * 60_000; +export const OCC_RESPONSE_TIMEOUT_MS = 30 * 60_000; +export const OCC_MODEL_PREFETCH_TIMEOUT_MS = 30_000; + +export function installOccService( + log: (msg: string) => void, + watchdogs: OccServiceWatchdogs = {}, +): void { if (globalThis.occService) return; + const modelPrefetchTimeoutMs = + watchdogs.modelPrefetchTimeoutMs ?? OCC_MODEL_PREFETCH_TIMEOUT_MS; + const bootTimeoutMs = watchdogs.bootTimeoutMs ?? OCC_BOOT_TIMEOUT_MS; + const responseTimeoutMs = + watchdogs.responseTimeoutMs ?? OCC_RESPONSE_TIMEOUT_MS; + + interface WorkerSlot { + generation: number; + worker?: Worker; + workerUrl?: string; + failed: boolean; + ready: Promise; + bootTimer?: ReturnType; + rejectBoot?: (reason?: unknown) => void; + removeBootListener?: () => void; + } + + interface PendingRequest { + generation: number; + resolve: (res: OccResponse) => void; + timer: ReturnType; + } + let nextId = 1; - const pending = new Map void>(); - let workerP: Promise | null = null; + let nextGeneration = 1; + const pending = new Map(); + let workerSlot: WorkerSlot | null = null; + + const failPending = (generation: number, report: string): void => { + for (const [id, request] of pending) { + if (request.generation !== generation) continue; + pending.delete(id); + clearTimeout(request.timer); + request.resolve({ ok: false, report }); + } + }; + + const retireWorker = (slot: WorkerSlot, report: string): void => { + if (slot.failed) return; + slot.failed = true; + 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 { + slot.worker?.terminate(); + } catch { + /* already gone */ + } + if (slot.workerUrl) { + try { + URL.revokeObjectURL(slot.workerUrl); + } catch { + /* URL cleanup must not prevent exact wait settlement */ + } + slot.workerUrl = undefined; + } + const reject = slot.rejectBoot; + slot.rejectBoot = undefined; + reject?.(new Error(report)); + }; + + const ensureWorker = (): Promise => { + if (!workerSlot) { + const slot = { + generation: nextGeneration++, + failed: false, + } as WorkerSlot; + // Publish the generation before its async boot reaches the first await. + // This also lets every continuation test exact slot ownership directly. + workerSlot = slot; + + // The editor is parked for this entire operation, including delivery + // discovery. Start the generation deadline before resolveWasmBase(): a + // hung manifest/CDN lookup must settle the exact wait just like a Worker + // which never announces ready. + const bootDeadline = new Promise((_resolve, reject) => { + slot.rejectBoot = reject; + slot.bootTimer = setTimeout(() => { + if (slot.failed || workerSlot !== slot) return; + const report = + `occ_service boot timed out after ${bootTimeoutMs} ms`; + log(`[occ] ${report} — resetting service`); + retireWorker(slot, report); + }, bootTimeoutMs); + }); - const ensureWorker = (): Promise => { - if (!workerP) { - workerP = (async () => { + const boot = (async () => { // occ_service is a Bundle (a published delivery artifact), not a Tool — // resolveWasmBase accepts either and looks the bundle up directly. const base = await resolveWasmBase("occ_service"); + if (slot.failed || workerSlot !== slot) { + throw new Error("occ_service worker retired during delivery resolution"); + } const glue = new URL(`${base}/occ_service.js`, window.location.href).href; log(`[occ] booting occ_service from ${base}`); - const worker = new Worker( - URL.createObjectURL( - new Blob(occWorkerBlobParts(glue), { type: "text/javascript" }), - ), + slot.workerUrl = URL.createObjectURL( + new Blob(occWorkerBlobParts(glue), { type: "text/javascript" }), ); + const worker = new Worker(slot.workerUrl); + slot.worker = worker; + + // A hard OCC/Wasm fault must complete every exact editor wait which + // depends on this worker. The next request gets a fresh generation; + // callbacks from this retired worker cannot resolve its requests. + worker.onerror = (e) => { + const report = `occ_service crashed: ${e.message || "worker error"}`; + log(`[occ] worker error: ${e.message || "worker error"} — resetting service`); + retireWorker(slot, report); + }; + worker.onmessageerror = () => { + const report = "occ_service transport failed: message decode failed"; + log("[occ] worker message decode failed — resetting service"); + retireWorker(slot, report); + }; worker.onmessage = (e) => { + if (slot.failed || workerSlot !== slot) return; const { id, res } = e.data ?? {}; if (typeof id !== "number") return; - const resolve = pending.get(id); - if (resolve) { + const request = pending.get(id); + if (request?.generation === slot.generation) { pending.delete(id); - resolve(res as OccResponse); + clearTimeout(request.timer); + request.resolve(res as OccResponse); } }; - await new Promise((resolve, reject) => { + await new Promise((resolve) => { const onFirst = (e: MessageEvent) => { if (e.data?.ready) { - worker.removeEventListener("message", onFirst); + if (slot.bootTimer !== undefined) { + clearTimeout(slot.bootTimer); + slot.bootTimer = undefined; + } + slot.removeBootListener?.(); + slot.removeBootListener = undefined; + slot.rejectBoot = undefined; resolve(); } else if (e.data?.bootError) { - reject(new Error(e.data.bootError)); + const report = `occ_service boot failed: ${String(e.data.bootError)}`; + retireWorker(slot, report); } }; worker.addEventListener("message", onFirst); - worker.onerror = (e) => reject(new Error(`occ_service worker: ${e.message}`)); + slot.removeBootListener = () => + worker.removeEventListener("message", onFirst); }); + if (slot.failed || workerSlot !== slot) { + throw new Error("occ_service worker retired during boot"); + } log("[occ] occ_service ready"); - return worker; - })().catch((e) => { - workerP = null; // a failed boot must stay retryable + return slot; + })(); + + slot.ready = Promise.race([boot, bootDeadline]).catch((e) => { + // Do not let a late failure from an old generation clear a replacement + // which a re-entrant caller has already started. + retireWorker(slot, `occ_service unavailable: ${String(e)}`); throw e; }); } - return workerP; + return workerSlot.ready; }; - const post = (worker: Worker, req: OccRequest): Promise => { + const post = (slot: WorkerSlot, req: OccRequest): Promise => { + const worker = slot.worker; + if (!worker || slot.failed || workerSlot !== slot) { + return Promise.resolve({ ok: false, report: "occ_service worker is unavailable" }); + } const id = nextId++; const transfer: Transferable[] = req.kind === "export" ? [req.board.buffer, ...(req.models ?? []).map((m) => m.bytes.buffer)] : [req.bytes.buffer]; return new Promise((resolve) => { - pending.set(id, resolve); - worker.postMessage({ id, req }, transfer); + const timer = setTimeout(() => { + if (pending.get(id)?.generation !== slot.generation) return; + const report = + `occ_service response timed out after ${responseTimeoutMs} ms`; + log(`[occ] ${report} — resetting service`); + retireWorker(slot, report); + }, responseTimeoutMs); + pending.set(id, { generation: slot.generation, resolve, timer }); + try { + worker.postMessage({ id, req }, transfer); + } catch (error) { + pending.delete(id); + clearTimeout(timer); + resolve({ ok: false, report: `occ_service request failed: ${String(error)}` }); + } }); }; + const prefetchBoardModels = async ( + board: Uint8Array, + ): Promise<{ models: BoardModelFile[]; note?: string }> => { + type Outcome = + | { kind: "ready"; models: BoardModelFile[] } + | { kind: "failed"; error: unknown } + | { kind: "timeout" }; + + // 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. 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 }), + ); + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout( + () => { + // Settle the timeout outcome before abort rejection can enqueue its + // Promise reaction, so logs and public behavior stay deterministic. + resolve({ kind: "timeout" }); + controller.abort( + new DOMException("OCC model prefetch timed out", "TimeoutError"), + ); + }, + modelPrefetchTimeoutMs, + ); + }); + const outcome = await Promise.race([collected, deadline]); + if (timer !== undefined) clearTimeout(timer); + + 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") { + const note = + `model prefetch failed — exported without models: ${String(outcome.error)}`; + log(`[occ] ${note}`); + return { models: [], note }; + } + 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 + // neither mutate the caller's object nor change an already-sent payload. + const board = req.board; + const jobJson = req.jobJson; + const fileName = req.fileName; // 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. - try { - req.models = await collectBoardModelFiles( - new TextDecoder().decode(req.board), - ); - if (req.models.length) - log(`[occ] shipping ${req.models.length} board model(s) with the export`); - } catch (e) { - log(`[occ] model prefetch failed (exporting without models): ${e}`); - req.models = []; - } + // 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 }; + } else { + prepared = { kind: "loadModel", bytes: req.bytes, ext: req.ext }; } - let worker: Worker; + let slot: WorkerSlot; try { - worker = await ensureWorker(); + slot = await ensureWorker(); } catch (e) { return { ok: false, report: `occ_service unavailable: ${e}` }; } - const res = await post(worker, req); + const res = await post(slot, prepared); - if (req.kind === "export") { + if (prepared.kind === "export") { // Deliver the export straight to the user; the editor gets status only // (the bytes never enter pcbnew's heap). if (res.ok && res.bytes?.length) { @@ -169,12 +394,17 @@ export function installOccService(log: (msg: string) => void): void { // default filename field is empty in the browser); Chromium mangles a // bare dotfile download to "step.txt", so give it a real stem while // keeping the format extension the user picked. - const raw = req.fileName || res.fileName || ""; + const raw = prepared.fileName || res.fileName || ""; const name = !raw || raw.startsWith(".") ? `export${raw || ".step"}` : raw; 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 f90de7c5e..ca9b4e86f 100644 --- a/web/standalone/src/wasm/scheduler-shim.test.ts +++ b/web/standalone/src/wasm/scheduler-shim.test.ts @@ -24,6 +24,14 @@ type SchedulerShape = { mutatorQueue: unknown[]; mutatorsDelivered: number; dead: boolean; + terminal: boolean; + canTouchNative(): boolean; + runWaitCompletion( + site: string, + token: number, + prepare: () => number, + inertResult?: number, + ): boolean; shutdown(reason: string): void; enqueueAfter(fn: number, arg: number, ms: number): void; _openBusy(): boolean; @@ -53,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")); @@ -229,3 +240,209 @@ describe("N5: scheduler shim under flood", () => { await expect(S.waitPromise(99999), "unknown token resolves 0").resolves.toBe(0); }); }); + +describe("E-8: runWaitCompletion admission gate for worker completions", () => { + it("runs prepare immediately and resolves the wait with its result", async () => { + const S = loadShim({ busy: () => false }); + const token = S.beginWait("occ"); + let ran = false; + expect( + S.runWaitCompletion("test completion", token, () => { + ran = true; + return 42; + }), + ).toBe(true); + expect(ran, "prepare runs immediately, never queued").toBe(true); + await expect(S.waitPromise(token)).resolves.toBe(42); + }); + + it("drops a completion for a stale or already-resolved token, loudly", () => { + const S = loadShim({ busy: () => false }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const prepare = vi.fn(() => 1); + expect(S.runWaitCompletion("late frame", 99999, prepare)).toBe(false); + const token = S.beginWait("occ"); + S.resolveWait(token, 7); + expect(S.runWaitCompletion("late frame", token, prepare)).toBe(false); + expect(prepare, "stale completions never touch native").not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledTimes(2); + } finally { + warn.mockRestore(); + } + }); + + it("a dead instance admits no native work and does not resolve the wait", () => { + const S = loadShim({ busy: () => false }); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const token = S.beginWait("ngspice"); + S.shutdown("test"); + expect(S.canTouchNative()).toBe(false); + const prepare = vi.fn(() => 1); + expect(S.runWaitCompletion("post-shutdown", token, prepare)).toBe(false); + expect(prepare).not.toHaveBeenCalled(); + expect(S.waitEarlyResolved(token), "wait deliberately not resolved").toBe(0); + } finally { + warn.mockRestore(); + } + }); + + it("a trap in prepare latches terminal and never resolves the wait", () => { + const S = loadShim({ busy: () => false }); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const token = S.beginWait("occ"); + expect( + S.runWaitCompletion("trapping completion", token, () => { + throw new WebAssembly.RuntimeError("memory access out of bounds"); + }), + ).toBe(false); + expect(S.terminal).toBe(true); + expect(S.canTouchNative()).toBe(false); + // Resolving would resume the parked frame INSIDE the trapped module. + expect(S.waitEarlyResolved(token)).toBe(0); + // Every later completion is inert… + const prepare = vi.fn(() => 1); + const token2Before = S.beginWait("occ"); + expect(token2Before, "beginWait refuses on a terminal instance").toBe(0); + expect(S.runWaitCompletion("after trap", token, prepare)).toBe(false); + expect(prepare).not.toHaveBeenCalled(); + } finally { + err.mockRestore(); + warn.mockRestore(); + } + }); + + 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 crossed; + }); + expect(S.terminal).toBe(true); + expect(S.waitEarlyResolved(token)).toBe(0); + } finally { + err.mockRestore(); + } + }); + + 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(() => {}); + try { + const token = S.beginWait("ngspice"); + expect( + S.runWaitCompletion( + "buggy completion", + token, + () => { + throw new TypeError("res.lines is not iterable"); + }, + 1, + ), + ).toBe(false); + expect(S.terminal, "a JS bug is not a trap").toBe(false); + await expect(S.waitPromise(token), "wait fails instead of stranding").resolves.toBe(1); + } finally { + err.mockRestore(); + } + }); + + it("beginWait refuses (token 0) on a dead instance", () => { + const S = loadShim({ busy: () => false }); + S.shutdown("test"); + expect(S.beginWait("occ")).toBe(0); + }); +}); 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 000000000..c96318e09 --- /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 304ee266b..614adf4c1 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit 304ee266b4437a8b9c8bd49704f1b0a9006c54dc +Subproject commit 614adf4c1b3d4206625568f28b546a21d0f3bfc0