diff --git a/kicad b/kicad index 9ab93b8..1c67b78 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit 9ab93b838e86bedab0ca649a36a3104f30d01e34 +Subproject commit 1c67b78fa43052e8ad730fc6c91a219ce1f3a2af diff --git a/scripts/common/shims/jspi-scheduler.js b/scripts/common/shims/jspi-scheduler.js index e72bba0..99b843c 100644 --- a/scripts/common/shims/jspi-scheduler.js +++ b/scripts/common/shims/jspi-scheduler.js @@ -202,6 +202,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; }); @@ -274,6 +281,76 @@ }, dead: false, + // --- E-8: admission gate for delayed worker/MEMFS completions ----------- + // `terminal` means the wasm instance TRAPPED (WebAssembly.RuntimeError or + // its cross-realm string equivalent): the heap may be mid-mutation, so no + // further native work (malloc / heap stores / FS writes) may run and no + // parked frame may be resumed into it. Distinct from `dead` (orderly + // shutdown). One-way. + terminal: false, + canTouchNative: function () { return !this.dead && !this.terminal; }, + _terminalizeNativeTrap: function (site, e) { + var isTrap = (typeof WebAssembly !== "undefined" + && WebAssembly.RuntimeError + && e instanceof WebAssembly.RuntimeError) + || /unreachable|memory access out of bounds|index out of bounds|null function or function signature mismatch|Aborted\(/i + .test(String((e && e.message) || e)); + if (!isTrap) return false; + if (!this.terminal) { + this.terminal = true; + this._note("terminal", site, 0); + console.error("[wx-scheduler] native trap in " + site + + " — instance is terminal; all further native completions are inert: " + + e); + } + 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- diff --git a/tests/kicad/eeschema-sim.spec.ts b/tests/kicad/eeschema-sim.spec.ts index 2e274bb..7d170ca 100644 --- a/tests/kicad/eeschema-sim.spec.ts +++ b/tests/kicad/eeschema-sim.spec.ts @@ -36,8 +36,8 @@ async function loadRectifier(page: import('@playwright/test').Page): Promise { - (window as any).Module.kicadOpenFile(sch); + await page.evaluate(async (sch: string) => { + await (window as any).Module.kicadOpenFile(sch); }, `${MEMFS_DIR}/rectifier.kicad_sch`); await expect @@ -67,12 +67,9 @@ async function openSimulator(page: import('@playwright/test').Page): Promise { - const evtsBefore = await page.evaluate( - () => (window as any).__ngspiceEvents.length as number); - +// Run the loaded workbook's analysis and await the exact native run generation +// only after its final plot, operating-point, and canvas refresh calls return. +async function runSimulation(page: import('@playwright/test').Page): Promise { // The simulator window div appears while the frame ctor is still // suspended in the init RPC; the toolbar registers its tools only after // init completes and the frame first paints. The Run tool's @@ -90,13 +87,53 @@ async function runSimulation(page: import('@playwright/test').Page): Promise { + 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); - 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 }); + const appliedReceipt = await page.evaluate(async (after: number) => { + const hooks = (globalThis as any).__ngspiceServiceTestHooks; + return await hooks.waitForAppliedGenerationAfter(after, 120000); + }, generationCheckpoint); + expect(appliedReceipt.generation, 'the clicked run published a newer applied generation') + .toBeGreaterThan(generationCheckpoint); + + // The native receipt fires after the final refreshes. Additionally + // require the scheduler to hold no parked ngspice wait — a stale + // suspended frame here means the finish path leaked a wait. (The codex + // line awaited the execution owner's barrier; that machinery does not + // exist on the JSPI line, and wait drainage is its observable + // equivalent.) + await expect.poll( + () => page.evaluate(() => { + const scheduler = (globalThis as any).__wxScheduler; + return scheduler?.pendingWaits?.('ngspice') ?? -1; + }), + { message: 'no ngspice wait may stay parked after the applied receipt', timeout: 30000 }, + ).toBe(0); + + // Vector traffic is result validation only. It is deliberately not used as + // completion evidence because periodic OnSimRefresh(false) pulls can look + // identical to the final pull at the worker boundary. + const vectorReceipt = await page.evaluate(() => + ((window as any).__ngspiceLog as Array<{ + sequence: number; kind: string; error?: string; length?: number; + }>).find((entry) => entry.kind === 'get_vec_info' + && entry.error === undefined + && (entry.length ?? -1) >= 101) ?? null, + ); + expect(vectorReceipt, 'the applied run returned a non-trivial successful vector') + .not.toBeNull(); + + return appliedReceipt.generation; } function distinctColors(png: PNG): number { @@ -168,7 +205,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 +236,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/ngspice-probe.spec.ts b/tests/kicad/ngspice-probe.spec.ts index f804cf7..ed79704 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.spec.ts b/tests/kicad/occ-export.spec.ts index 7cd7214..dc1ecdb 100644 --- a/tests/kicad/occ-export.spec.ts +++ b/tests/kicad/occ-export.spec.ts @@ -1,8 +1,10 @@ import type { Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; import { test, expect } from './fixtures'; import { clickMenuBarItem, clickMenuItem, waitForEditorReady, waitForRenderedByLabel, waitUntil, stableShot, settledShot } from '../e2e/utils/element-tracker'; import { injectFromSubmodule } from './utils/fs-inject'; -import { waitForBoardLoaded } from './utils/board-ready'; +import { openBoardProgrammatically } from './utils/board-ready'; /** Wait for a rendered popup menu to have its items (replaces a fixed post-menu-click sleep). */ async function waitForMenuItems(page: Page): Promise { @@ -46,61 +48,65 @@ 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) => { +type WxButtonTarget = { x: number; y: number; domId: number | null }; + +/** Resolve one visible wx button to its stable DOM identity and fallback point. */ +async function findWxButton(page: Page, label: string): Promise { + return page.evaluate((wanted: string) => { const registry = window.wxElementRegistry; if (!registry) return null; const el = registry.findAll({ visible: true }) .find((e) => (e.label === wanted || e.label === `&${wanted}`) && (e.typeName ?? '').includes('Button')); - return el ? { x: el.centerX, y: el.centerY } : null; + return el + ? { x: el.centerX, y: el.centerY, domId: el.domId && el.domId > 0 ? el.domId : null } + : null; }, label); - if (!pos) return false; - await page.mouse.click(pos.x, pos.y); +} + +async function clickWxButtonTarget(page: Page, target: WxButtonTarget): Promise { + if (target.domId) { + await page.locator(`[data-wx-dom-id="${target.domId}"]`).click(); + return; + } + + await page.mouse.click(target.x, target.y); +} + +/** Click a visible wx button by label; returns whether it was found. */ +async function clickWxButton(page: Page, label: string): Promise { + const target = await findWxButton(page, label); + if (!target) return false; + await clickWxButtonTarget(page, target); return true; } +async function openStepExportDialog(page: Page): Promise { + expect(await clickMenuBarItem(page, 'File'), 'File menu').toBe(true); + await waitForMenuItems(page); + await waitForRenderedByLabel(page, 'Export', { elementType: 'menuitem' }); + expect(await clickMenuItem(page, 'Export'), 'Export submenu').toBe(true); + await waitForRenderedByLabel(page, 'STEP/GLB/BREP/XAO/PLY/STL...', { elementType: 'menuitem' }); + expect(await clickMenuItem(page, 'STEP/GLB/BREP/XAO/PLY/STL...'), + 'STEP export menu item').toBe(true); + await page.waitForFunction(() => { + const registry = window.wxElementRegistry; + return !!registry && registry.findAll({ visible: true }) + .some((el) => (el.label === 'Export' || el.label === '&Export') + && (el.typeName ?? '').includes('Button')); + }, null, { timeout: 20000 }); +} + test.describe('OCC export via occ_service worker', () => { test.describe.configure({ mode: 'serial' }); test.setTimeout(240000); @@ -126,24 +132,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); @@ -175,4 +165,165 @@ test.describe('OCC export via occ_service worker', () => { 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.requestsStarted, + 'the direct probe and one native export must be the only provider entries').toBe(2); + expect(failed.service.requestsPosted, + 'both failed-generation requests must reach the real worker transport').toBe(2); + expect(failed.service.workerGenerationsStarted, + 'the two parallel requests must share one worker generation').toEqual([1]); + expect(failed.service.pending, 'fail-all must drain the failed generation').toBe(0); + expect(failed.service.retiredGenerations, 'generation 1 must be retired').toEqual([1]); + expect(failed.service.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?.domId, + 'the retry must target a stable DOM-backed wx button').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. + if (!retryExport) throw new Error('parent Export button disappeared before retry'); + await clickWxButtonTarget(page, retryExport); + 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(3); + expect(recovered.service.requestsPosted, + 'the parent retry must post exactly once to the replacement worker').toBe(3); + expect(recovered.service.workerGenerationsStarted, + 'the retry must create exactly one replacement generation').toEqual([1, 2]); + expect(recovered.service.pending, 'replacement generation must quiesce').toBe(0); + expect(recovered.schedulerDead, 'the worker failure must not terminalize the editor').toBe(false); + expect(recovered.occWaits, 'the replacement native OCC wait must quiesce').toBe(0); + + await page.waitForTimeout(1000); // eslint-disable-line -- documented interaction dwell + await clickWxButton(page, 'OK'); + }); }); diff --git a/wasm/stubs/exporter_step_stub.cpp b/wasm/stubs/exporter_step_stub.cpp index db84de1..c5ba5cb 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 f244e50..8d36f87 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 = ''; + 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 ); - } + 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 ); + 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 7835b8a..91568e1 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,41 @@ 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; + 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; + return 0; + }, /* inertResult = */ 1 ); } ); } ); @@ -138,30 +148,60 @@ 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; + 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 = globalThis.__wxScheduler; + 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; + } const call = ( kind, text, a, b ) => { let p = 0; if( text != null ) { const n = lengthBytesUTF8( text ) + 1; - p = _malloc( n ); + p = installingModule._malloc( n ); stringToUTF8( text, p, n ); } - Module._pcbjam_ngspice_event( kind, p, a | 0, b | 0 ); + installingModule._pcbjam_ngspice_event( kind, p, a | 0, b | 0 ); }; - 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 ) ) + throw e; } }; + handler.__pcbjamNgspiceOwnerModule = installingModule; + globalThis.__ngspiceOnEvent = handler; } ); // clang-format on @@ -184,6 +224,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 +405,24 @@ 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; +} + // ------------------------------------------------------------------------- // The sharedspice API surface NGSPICE::init_dll binds to // ------------------------------------------------------------------------- @@ -430,6 +494,11 @@ pvector_info pcbjam_ngGet_Vec_Info( char* aVecName ) char* vname = nullptr; const int token = wxWasmBeginWait( "ngspice" ); + + // Token 0 = the scheduler refused the wait (dead or terminal instance). + if( token <= 0 ) + return nullptr; + js_ngspice_get_vec_start( token, aVecName ? aVecName : "", meta, &real, &comp, &vname ); if( wxWasmYieldUntil( token ) != 0 ) diff --git a/web/standalone/src/wasm/scheduler-shim.test.ts b/web/standalone/src/wasm/scheduler-shim.test.ts index f90de7c..7973110 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; @@ -229,3 +237,121 @@ 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 cross-realm trap strings as terminal too", () => { + const S = loadShim({ busy: () => false }); + const err = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const token = S.beginWait("occ"); + S.runWaitCompletion("cross-realm trap", token, () => { + throw new Error("RuntimeError: unreachable"); + }); + expect(S.terminal).toBe(true); + expect(S.waitEarlyResolved(token)).toBe(0); + } finally { + err.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); + }); +});