Compare commits

...
Author SHA1 Message Date
Istvan Matejcsok
766e2f409f tests: give the 17 bare dwell markers their documented why (determinism lint)
Some checks failed
wasm-build.yml / tests: give the 17 bare dwell markers their documented why (determinism lint) (push) Failing after 0s
CI full build + e2e (Ubicloud) / build (push) Has been cancelled
CI full build + e2e (Ubicloud) / Build all tools + KiCad e2e (Ubicloud) (push) Has been cancelled
The findings-E branch tightened lint:determinism to require
"documented interaction dwell: <why>" on every dwell marker; these six
specs landed on staging after that branch was cut, so the two only met
at merge time and the first staging CI run went red. Comment-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 09:36:26 +02:00
Istvan Matejcsok
c421d724b0 findings(E-10..E-22): fix the defects a code review found in the E-1..E-9 work
A review of the group-E fixes found 13 further defects; ten were introduced by
those fixes, two pre-existed and were merely relocated, one is deferred.

Services / transport
  E-10  retireWorker synthesized no bg/exit frame, so sharedspice's s_bgRunning
        mirror stayed latched true after a mid-run worker death: Run stayed
        disabled and the promised fresh-worker restart was unreachable for the
        whole session. Retirement now dispatches a synthetic controlled-exit
        straight to the installed handler (never through dispatchEvt — a
        fabricated frame must not touch the credit ledger). Driving the repro
        exposed two further defects, both fixed here: a replacement worker
        trapped on pre-init engine reads, and the rerun's cm_input_path/circ hit
        that uninitialized engine before KiCad's validate() re-init (the native
        flow assumes a crashed engine survives in-process — true for the dll,
        false for a dead worker). Reads now answer their empty shapes pre-init,
        writes lazy-init, and init is idempotent per worker engine.
  E-19  dispatchEvt acked only AFTER handler(evt) returned, and the sharedspice
        client deliberately rethrows non-trap errors — so each throw leaked one
        unit of the 64-frame credit window until the stream died with a
        misattributed "transport exceeded". The ack moves to a finally in both
        service copies; the throw still propagates (the trap machinery needs it).
  E-20  the oversize-line path promises to transfer the accepted prefix, but
        with the window full that flush only DEFERS, and stopEventStream wiped
        the deferred queue — losing the diagnostics that explain the failure.
        The terminal notice now carries them as pendingEvents; both hosts
        deliver them in order, unacked (the fatal frame is outside the credit
        protocol).
  E-21  the 30s prefetch deadline discarded every model already collected and
        reported nothing. A caller-owned progress sink ships the partials and
        the omission reaches the export report. (Awaiting the aborted collection
        was rejected: an in-flight source fetch is not abortable — E-4's
        original disease.) Plus a serving-candidate memo, so a .wrl ref served
        by its .step fallback stops re-probing the miss on every export.

Scheduler
  E-14  _terminalizeNativeTrap classified by message substring, so any plain JS
        error QUOTING 'Aborted(' or 'out of bounds' permanently bricked a
        healthy instance. Now structural only: instanceof RuntimeError plus a
        duck-typed name check (verified in this build's glue that abort() throws
        a genuine RuntimeError both pre- and post-runtime-init). Module.onAbort
        now latches the gate — the authoritative notification, previously
        ignored.
  E-15  the shim half: _pumpResume gates on terminal (catching wakes already
        queued at latch time) and resolveWait refuses on terminal WITHOUT
        consuming the entry, so a frame stays visibly parked rather than
        resuming inside a trapped module.
  E-16  the E-5 handler read the realm-global scheduler at dispatch instead of
        its installing module's; also frees the per-line buffer on the non-trap
        rethrow path.
  E-11  get_vec trusted the worker's res.length over the transferred arrays.
        Observed death shape: a 4 GiB std::vector threw an unhandled
        std::length_error that exited the editor's main loop. Now clamped, with
        the buffers freed on every failure path.

Guardrails (replacing two deferred refactors: e2e→production-code injection and
collapsing the four copies of the worker-lifecycle machinery)
  E-18  the source contract asserted comment-string counts — rewording failed
        CI while moving a guard outside its #ifdef passed. It now parses the
        #ifdef regions and asserts on code.
        service-stub-parity.ts pins what the four lifecycle copies must share:
        credit-window equality parsed from source, the finally-ack, boot
        deadlines, terminal-notice consumption. The transport numbers are now
        single-sourced from the worker.
        CI actually runs the gates: the web/standalone vitest suites (which had
        NEVER run in CI), the reducer, the source contract and the parity tool —
        with a NON_PLAYWRIGHT_GATES check so deleting a step re-fails the lint.
  E-22  the e2e occ stub's 60s boot watchdog, deleted in a66e109, is restored in
        the ngspice-stub shape with a wedgeNextBoot() repro hook.

Every behavioral fix has red-then-green evidence (the reds were captured first).
E-17 (a stale RUNNING cross-stamping the next run's generation under E-6's
transport deferral) is DEFERRED with its analysis recorded — a real fix needs
run identity on the bg frames.

Test hygiene: the dwell lint now requires the mandated ": <why>" and all 47 bare
markers carry their reason; three export-report dwells became modal-lease polls;
exact-ledger assertions became relative deltas; the dead data-wx-dom-id branch,
an unused fault hook and unused receipt plumbing are gone; abort scans, wx
dialog drivers, the sim harness and the vitest FakeWorker are each one copy now.

Bumps kicad and wxwidgets to their findings-group-e tips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 18:19:16 +02:00
Istvan Matejcsok
0f21a33216 findings(E-5,E-6,E-7,E-8,E-9): close the test-coverage gaps + kicad ifdef bump
Coverage audit found three fixes without a test that could catch their
reversion:

- E-6 ack-on-enqueue: new unit case "acks a pre-handler queued frame at
  enqueue so the transport window never starves" (ngspice-service.test.ts;
  false-green audited — fails with the ack removed, exactly one ack per
  frame, none repeated on drain).
- E-5 / E-8 wiring / E-9 / E-7 shape: new source-contract tripwire
  tests/tools/findings-e-source-contract.ts (npm run findings-e:contract) —
  the codex-thread contract style for C++/EM_JS code that cannot be
  behaviorally unit-tested without a wasm build. Asserts: the module
  identity stamp + self-disarm and ABSENCE of the install-once presence
  guard (E-5); all four completion sites use runWaitCompletion, no stub
  resolves a wait directly, token<=0 bails, the shim exports the gate
  surface (E-8); the run-generation mechanism present with its behavioral
  drops confined to the wasm build (E-7); the identity-checked destructor
  unregistration (E-9).

Also bumps kicad to the E-7 ifdef-confinement commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 18:18:28 +02:00
Istvan Matejcsok
06a46546cc findings(E-5,E-6): validation-round fixes — live e2e falsified two ported shapes
E-5: the module-identity bridge called installingModule._malloc, but this
build exposes _malloc only as a bare glue-closure export (Module._malloc is
absent) — every char/stat event entry threw TypeError, which also starved
the E-6 credit window (thrown dispatches never acked) and wedged the queued
bg-finished frame behind them. The bridge now uses the bare closure exports
(identity is still exact: the EM_JS body IS the installing module's closure;
the __ngspiceOnEvent self-disarm covers supersession).

E-6 (codex reference design corrected — its validation matrix never ran):
a FULL credit window was terminal (stopEventStream at 64 in-flight frames).
Under live e2e that killed a real simulation: bg-thread emissions proxy one
per task, so each line ships as its own frame and a normal transient outruns
a busy main thread. A full window now DEFERS into a bounded FIFO (512 events
/ 4 MiB) drained in order as acks free credit; only true overload or an
invalid ack is terminal. Retention stays bounded (8 MiB in flight + 4 MiB
deferred + 1 MiB open batch). And the service/harness mirror queue now acks
at ENQUEUE — placing a frame in the bounded pre-handler queue is taking
ownership; without that, a stream starting before the C++ handler installs
(the ngspice-probe page) starves the worker window forever.

Test updates: worker-batch reducer — new "a full credit window defers and
drains in order, never terminal" case pinning the regression; the storm case
now proves the deferred caps are the terminal edge. board-ready.ts gains the
owner-free openBoardProgrammatically (codex helper the ported occ-export
spec needs; the barrier-based waitForUiBoardReady was NOT taken).
occ-export.spec: domId is optional on this line's registry (coordinate
fallback is the supported path).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 18:18:21 +02:00
Istvan Matejcsok
c14e76651c findings(E-5,E-8,E-9): module-identity ngspice events + runWaitCompletion admission gate
E-8 (re-implemented for JSPI — the codex gate is entangled with the dropped
execution owner; under JSPI a fresh non-suspending JS→wasm entry while
another activation is suspended is structurally safe on its own stack, so
the admission boundary for worker completions is liveness + trap state, not
execution ownership):
- jspi-scheduler.js grows `terminal` (trapped instance; distinct from `dead`),
  canTouchNative(), _terminalizeNativeTrap() (WebAssembly.RuntimeError +
  cross-realm string classification), and runWaitCompletion(site, token,
  prepare, inertResult): prepare runs immediately and owns ALL native work;
  stale tokens and dead/terminal instances drop loudly without resolving
  (resolving would resume the parked frame inside the damaged module); a
  trap latches terminal; a plain JS bug resolves inertResult so the wait
  fails instead of stranding. beginWait refuses (token 0) when dead/terminal.
- all four delayed completion sites route their native work through the
  gate: 'OCC export completion' (exporter_step_stub), 'OCC model completion'
  (oce_plugin_stub — the MEMFS cache write moves inside the gate too),
  'ngspice request completion' and 'ngspice vector completion'
  (sharedspice_client — every HEAP32/HEAPF64/malloc write inside prepare,
  inertResult 1 = transport error). Every wxWasmBeginWait caller in the
  stubs bails on token <= 0.
- deliberately NOT ported from codex: ownerModule, enqueueNativeCompletion,
  executionBarrier, the byte-credit native-entry FIFO — completions are
  one-shot per wait token and stream volume is bounded at the E-6 transport
  credit window. Cross-refs logged for group M (M-2/M-6/M-8).

E-5 (re-implemented; codex shape kept, owner APIs replaced with the E-8
gate): js_ngspice_install_events binds the handler to the EXACT installing
module (handler.__pcbjamNgspiceOwnerModule stamp; presence is not identity),
re-installation replaces a foreign module's handler, a superseded handler
disarms itself, native entry goes through installingModule._malloc/
._pcbjam_ngspice_event (never lexical Module), each dispatch checks
canTouchNative() (loud drop on a dead/terminal module), and a trap on the
per-line entry latches the terminal gate.

Tests: scheduler-shim.test.ts +7 (gate happy/stale/dead/terminal/cross-realm/
js-bug/beginWait-refusal). e2e specs updated from the codex line: occ-export
decode-fault recovery (real onmessageerror transition via failDecode, J-4),
ngspice-probe direct-service coverage, eeschema-sim rewritten onto the E-7
applied-generation receipt (codex's executionBarrier await replaced with a
pendingWaits('ngspice') drain poll — the JSPI-line equivalent).

Also bumps the kicad submodule to the E-7/E-9 commit (dd5751038f7).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 18:18:21 +02:00
Istvan Matejcsok
0b8e7186d4 findings(E-1,E-2,E-3,E-6): ngspice service generations + credit-bounded event transport
Adapted from codex/asyncify-execution-owner-core 3753320. Service side mirrors
the occ-service shape (E-1 watchdogs, E-2 fail-all + boot-death fix — onerror
now rejects the in-flight boot waiter instead of stranding it, E-3
onmessageerror terminal, Blob URL revoked, per-generation evtQueue cleared on
retirement).

E-6 transport bounds (worker hunks re-applied inside the emscripten-6
em-pthread else-branch — the codex file predates that split, so this is a
re-application, not a cherry-pick):
- batch cut at 512 lines / 1 MiB exact JSON-UTF-8 bytes, measured before a
  line is retained; a single line > 1 MiB flushes the accepted prefix then
  stops the event stream terminally (never retained);
- posting gated by a 64-frame / 8 MiB unacked credit window; each frame
  carries { eventSequence, eventBytes } and is released only by an exact
  { sequence, bytes } ack; any mismatched ack is terminal;
- the service mirrors the same 64-frame / 8 MiB bound on its pre-handler
  queue, acks after handing a frame to __ngspiceOnEvent, and retires the
  generation on invalid credit; { fatal } frames retire the worker.

Tests: ngspice-service.test.ts (11, ported) — watchdogs, crash/bootError/
decode-fault settlement + recovery, out-of-order ids, sync postMessage throw,
stale-generation event drops, fatal-frame retirement. tests/tools/
ngspice-worker-batch-unit.ts (node:vm over the production worker source;
`npm run ngspice:worker-batch`) — bounded ordered chunks, byte-pressure
flush, 100k-chunk credit storm, over-limit line, exact ack lease. e2e harness
twin updated to speak the ack protocol (adds __ngspiceServiceTestHooks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 18:18:02 +02:00
Istvan Matejcsok
3de00bb15b findings(E-1,E-2,E-3,E-4): occ-service generations/watchdogs/messageerror + abortable model prefetch
Adapted from codex/asyncify-execution-owner-core 3753320 (scheduler-free on
that branch already; one comment line re-worded for the JSPI line):

- E-1: generation-slotted WorkerSlot with a 2-min boot watchdog (armed before
  resolveWasmBase, so a hung delivery lookup expires too) and a 30-min
  per-request response watchdog; retireWorker() is the single idempotent
  funnel (fail that generation's pendings, terminate, revoke the worker Blob
  URL, clear timers/listeners).
- E-2: worker.onerror is wired for the worker's whole life and settles every
  in-flight STEP/export request; a synchronous postMessage throw settles its
  request without leaking the pending id; late frames from a retired
  generation are inert.
- E-3: worker.onmessageerror retires the generation like error does.
- E-4: collectBoardModelFiles is a pure source/IDB/network path (no editor
  MEMFS round-trip) taking an AbortSignal checked at every loop head;
  prefetchBoardModels races it against a 30 s deadline — timeout is non-fatal
  (export proceeds without models) and late results are inert.

Tests: occ-service.test.ts (7, ported) — boot/response watchdog expiry,
crash-settles-all, bootError retry, decode-fault retirement (invokes the real
onmessageerror transition, per J-4), hung-prefetch export; models-bridge.test.ts
+3 — abort inertness, zero FS access on the collect path. e2e harness twin
updated to the same generation shape (adds __occServiceTestHooks/failDecode).
False-green audited: 19 cases fail with the fixes reverted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-31 18:18:02 +02:00
Gergő Törcsvári
6fb07ea882
site(blog): devblog w32-35 — async rewrite, one-request boot, security sweep, viewers
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JM7DQKFwGLPpvabE1hU1MW
2026-08-31 16:55:13 +02:00
Gergő Törcsvári
a4fab53590 sibling restage: focus-triggered repull — a dropped touched no longer strands the mirror until reload
Follow-up to the sync-delete fix: with the doc corruption gone, the remaining
in-session staleness class is the passive watch itself. It re-pulls only on
`touched` control frames, and the gateway debounces those 2s leading-edge
with NO trailing emit — a dropped frame (real across gateway/DO hops, nearly
impossible on localhost) leaves the MEMFS mirror pre-delete until an
unrelated later edit. Field symptom: "delete + sync doesn't delete, but
reloading the pcbnew tab and syncing again does".

The sync gesture always brings the pcbnew tab to the front first, so on
window focus / visibilitychange every live sibling watch now sends a manual
SyncStep1 (new gateway-only `YjsProvider.repull`), rate-limited to one per
2s; any news restages through the normal debounce.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MLPKYptXFrxHj5Gu7rhToz
2026-08-31 15:13:43 +02:00
Gergő Törcsvári
141f7ba5bd
build: freetype download falls back to sha-pinned mirrors (savannah 502)
download.savannah.gnu.org 502'd through all retries and took out the
2026-08-31 staging CI run. Try savannah, then its mirror pool alias, then
SourceForge — every candidate must hash to FREETYPE_SHA256, so a mirror can
only supply the byte-identical tarball.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MLPKYptXFrxHj5Gu7rhToz
2026-08-31 12:58:51 +02:00
66 changed files with 5223 additions and 586 deletions

View file

@ -393,6 +393,14 @@ jobs:
corepack enable
pnpm install --frozen-lockfile
# The web/standalone vitest suites carry the red/green evidence for the
# findings-E service-layer fixes (worker lifecycle, transport credits,
# admission gate, models prefetch). Node-env, no wasm build needed.
- name: web standalone unit tests (vitest)
if: inputs.run_tests
working-directory: web
run: pnpm --filter @pcbjam/standalone test
# kicad_tools gates (tasks-runner 0001 R2): the corpus lint (fixtures +
# shared-codec round-trips — the wrapInBoardEnvelope-class E3 gate,
# kicad-validity 0001 §5) and the CLI contract the backend job runner
@ -415,6 +423,18 @@ jobs:
npm ci
npm test
# Findings-E gates: the ngspice transport reducer (production worker
# source in a node:vm), the C++/shim source contract, and the
# stub/production parity tripwire. lint-ci-coverage asserts these exact
# invocations stay wired (NON_PLAYWRIGHT_GATES).
- name: findings-E transport reducer + source/parity contracts
if: inputs.run_tests
working-directory: tests
run: |
npm run ngspice:worker-batch
npm run findings-e:contract
npm run findings-e:parity
# Browser binaries keyed on the lockfile (which pins the playwright version).
# On a hit `playwright install` skips the downloads; --with-deps still
# apt-installs its small OS dep set either way.

2
kicad

@ -1 +1 @@
Subproject commit 9ab93b838e86bedab0ca649a36a3104f30d01e34
Subproject commit 56678639eea1b3c0358f384fa47ef7fab78bd09b

View file

@ -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; });
@ -243,6 +250,16 @@
resolveWait: function (token, result) {
var entry = this.waits.get(token);
if (!entry || entry.resolved) return false;
if (this.terminal) {
// Resolving would resume the parked frame INSIDE the trapped module
// (the runWaitCompletion invariant, which the bare finishers used to
// bypass). Refuse WITHOUT consuming the entry — the frame stays
// visibly parked in dump() and the ring says why.
this._note("resolveRefused", entry.kind, token);
console.warn("[wx-scheduler] resolveWait(" + token + ", " + entry.kind
+ ") refused: instance is terminal");
return false;
}
entry.resolved = true;
this.waitsResolved++;
var stack = this.waitStacks[entry.kind];
@ -274,6 +291,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-
@ -473,7 +569,11 @@
},
_pumpResume: function () {
if (this.dead) return;
// `terminal` too: a queued wake must never re-enter a trapped module —
// resuming swaps SP into (and runs wasm on) a heap that may be
// mid-mutation. Freezing the pump on a terminal instance is by design:
// the fatal overlay owns the page from here.
if (this.dead || this.terminal) return;
if (this._windowLive) {
// Self-heal: an activation that suspended RAW (bypassing the shim)
// or completed untracked never ends its window here; without this

View file

@ -41,8 +41,25 @@ if [ ! -d "${FREETYPE_DIR}" ]; then
mkdir -p "${DEPS_ROOT}"
cd "${DEPS_ROOT}"
FREETYPE_URL="https://download.savannah.gnu.org/releases/freetype/freetype-${FREETYPE_VERSION}.tar.xz"
download_file "${FREETYPE_URL}" "freetype-${FREETYPE_VERSION}.tar.xz" "${FREETYPE_SHA256}"
# savannah 502s regularly (took out the 2026-08-31 staging CI run); every
# mirror must serve the byte-identical tarball — FREETYPE_SHA256 gates it.
FREETYPE_URLS=(
"https://download.savannah.gnu.org/releases/freetype/freetype-${FREETYPE_VERSION}.tar.xz"
"https://download-mirror.savannah.gnu.org/releases/freetype/freetype-${FREETYPE_VERSION}.tar.xz"
"https://downloads.sourceforge.net/freetype/freetype-${FREETYPE_VERSION}.tar.xz"
)
downloaded=0
for FREETYPE_URL in "${FREETYPE_URLS[@]}"; do
if download_file "${FREETYPE_URL}" "freetype-${FREETYPE_VERSION}.tar.xz" "${FREETYPE_SHA256}"; then
downloaded=1
break
fi
log_warn "FreeType download failed from ${FREETYPE_URL} — trying next mirror"
done
if [ "${downloaded}" != "1" ]; then
log_error "All FreeType mirrors failed"
exit 1
fi
tar -xJf "freetype-${FREETYPE_VERSION}.tar.xz"
rm "freetype-${FREETYPE_VERSION}.tar.xz"
fi

View file

@ -0,0 +1,100 @@
---
title: "DevBlog 2026 weeks 32-35 - The big async rewrite, and everything after"
description: "Four weeks in one post: swapping the editor's engine mid-flight, one-request boot, a security sweep, and a pile of fixes"
author: Gergő Törcsvári
pubDate: 2026-08-31
---
Yes, we skipped four weekly posts. No, we did not stop working. Quite the opposite. The first half of this stretch went into the deepest rewrite this project has seen, the kind where you don't surface to write blog posts. The second half was us cashing in on it. So this one is a long post, covering weeks 32 to 35.
# The big one: we replaced how the editor sleeps
This deserves (and will get) its own full blog post, but here is the short version.
KiCad is synchronous C++ code. The browser is an asynchronous world. Everything interesting and everything terrible about running KiCad in a browser comes from that mismatch. Until now we bridged it with Asyncify, a tool that rewrites the compiled WASM so C++ code can "sleep" while the browser does async work.
It worked, but it was also behind basically every mystery crash we have ever hunted, including the week-31 crash hunt from the last post.
So we stopped patching around it and rewrote the machinery in two stages:
- **Week 32** we rebuilt our async layer from the ground up: every place the C++ side can park got audited and moved onto a proper scheduler with explicit mailboxes, instead of ad-hoc sleeps scattered through the port. The legacy runtime got deleted, not just deprecated.
- **Week 33** we switched the underlying mechanism from Asyncify to **JSPI** (JavaScript Promise Integration), the browser-native way to suspend WebAssembly. This let us delete the entire Asyncify post-processing pipeline from our build.
With the JSPI change;
- We revived 26 tests that had been skipped over the months with "flaky under asyncify" notes, and they pass.
- One collaboration bug that genuinely diverged in a few percent of runs (the quarantined one from the week-31 post) simply does not reproduce under JSPI. We stress-ran it ~100 times and gave up trying.
- The bundle size went down.
- The load-speed, memory usage, fps, basically every performance metric get better.
- We lost Safari and IOS compatibility, but Apple promised that they will enable it in the next Safari release in September, 2026.
We will write a bigger post about this, in the next weeks.
# One request, two websockets
With the engine swap behind us, week 34 went into the load path. Opening a board used to be a small storm of requests; we had already cut the websocket count in July, but the boot sequence itself was still chatty and slow.
Now the editor boots from **one** request. The server resolves the whole project (files, library stack, permissions) in a single round-trip, and everything the editor needs streams in from there.
Realtime runs over exactly two websockets: one gateway for the project (which lazily relays the per-document rooms behind the scenes) and one shared library-sync socket for your whole team.
Along the way:
- The boot endpoint's library resolution went from ~28 seconds on a heavy staging project to 3 database queries.
- Project files are cached in the browser (IndexedDB), and library layers are served as immutable CDN objects, so a warm reload barely touches the network.
- The schematic editor now warms collab rooms only for the sheet hierarchy you actually opened. On our repo-as-a-project torture test that is 5 rooms instead of 26.
- As a follow-up, the server can now sync documents passively from storage without waking the realtime room at all (rooms only run when someone is actually editing).
- First cold-load for an empty browser is still a bit slow, because of the ~400 official libs.
# We pointed an LLM at our own code
In week 32 we built a security scanner: an LLM reads the codebase file by file, with provenance tracking and a quota-aware runner, and files findings. We ran it, widened it, then ran a third pass with a stronger model that also scans third-party dependencies.
Then we spent the tail of week 35 actually fixing what it found, in one big sweep: API route authorization, auth/identity/tenancy edge cases, machine-token handling, storage key hygiene, upload paths, input validation on the collab bridge, and pinning every dependency tarball fetch to a SHA256. Most findings were hardening rather than exploitable holes, but a handful were real, and every fixed group landed with its own regression tests.
We also worked through a backlog of externally reported findings from earlier reviews.
The fun part: several long-standing "confirmed" concurrency bugs turned out to be already dead, killed by the JSPI migration before we ever got to them.
# Viewers get a real viewer
Read-only mode used to be the editor with the dangerous buttons removed. It is becoming its own product:
- A **layer selector** and a **selection inspector** panel, with click-to-inspect on the canvas, you can explore a board without being able to break it.
- A **sheet navigator** panel for schematics, so multi-sheet designs are actually browsable.
- Viewers no longer boot the whole library catalog they can't edit anyway, which makes anonymous opens of public projects noticeably lighter (and fixed a bug where they came up with blank library tables).
![the read-only viewer: a selected track in the inspector, and the layer selector](./w35-assets/viewer-panels.png)
![clicking through the Arduino Leonardo schematic with the sheet navigator](./w35-assets/sheet-navigator.gif)
# Libraries, continued
The library system kept getting attention all month:
- **Live library editing**: Cmd+S finally works in the symbol and footprint editors, an overlay shows which library you are editing, and when a teammate edits a shared library, your open editor gets a toast and picks up the change.
- **Overrides** are now first-class: they are indexed for sync, and you can compare an override with its original. (With a cross-fader and a blink toggle, like comparing gerber revisions.)
- Remote library edits **invalidate** your open copy instead of yanking it out from under you mid-edit.
- 3D models are now served from our registry on the staging and production editor builds, with the official 3D packs ingested in chunks.
![comparing an override with its original — the Blink toggle drives the cross-fader](./w35-assets/compare-crossfader.gif)
# Project sync and imports
- When someone saves a file, everyone else's open editors are notified through the project room and quietly re-stage the changed siblings. So no more stale `.kicad_sch` next to your freshly saved board.
- Zip upload is now a **durable import job**: you get an immediate response, a progress banner on the project page, and the import survives worker restarts instead of dying with the request. (But it get a bit slower.)
- Bulk uploads used to trip over Cloudflare's six-connection limit; resaves now run batched, in one staging pass.
# The fix parade
Four weeks of "and also":
- **3D viewer**: reopening it no longer gives a blank render, switching to raytracing and back renders again, and toolbar clicks are no longer hijacked by invisible controls from the frame below it.
- **eeschema copy/paste** works properly again (a UTF-8 clipboard bug in the wx port), and comes with regression specs.
- You can **type into grid cell editors** in dialogs again, the CvPcb footprint filter box is where it looks like it is (and accepts clicks), and Cmd+S no longer goes dead after using a filter box.
- Two nasty subsheet bugs: entering a not-yet-synced subsheet could wipe it, and one reload scenario made a sheet its own ancestor. Both fixed, both with red-green tests.
- Dialogs that don't remember a position open centered again, and rounded rectangles with negative radii no longer crash the renderer (don't ask, just don't).
- The shipped WASM now goes through an extra optimizer pass, and CI measures real rendered frames per second on every push, so we will notice if we make it slower.
# Not closely connected
Screenshot test baselines moved out of git into content-addressed R2 storage, with a small review app on top: every CI run's renders are uploaded, and updating a baseline is now "review the diffs, click promote" instead of committing PNGs. Our repo got lighter and our diffs got readable. You can check the code in [PCBJam/morelli](https://github.com/PCBJam/morelli), when we will have time, we will try to make this pcbjam agnostic.
We will attend on [Kicon Europe](https://kicon.kicad.org/europe2026/) so if you want to meet us, you can!

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

View file

@ -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 });

View file

@ -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);

View file

@ -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 });

View file

@ -173,9 +173,9 @@ test.describe('Modal dialog border + drag (pcbjam #22)', () => {
const startX = tbox!.x + tbox!.width / 2;
const startY = tbox!.y + tbox!.height / 2;
await page.mouse.move(startX, startY);
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell (pointer settle before grabbing the title bar)
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: pointer settle before grabbing the title bar
await page.mouse.down();
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell (press commit before the drag begins)
await page.waitForTimeout(150); // eslint-disable-line -- documented interaction dwell: press commit before the drag begins
// Drag in many small steps, sampling the modal canvas immediately after each
// move. Each move calls setWindowRect, which clears the canvas; the dialog's
@ -257,7 +257,7 @@ test.describe('Modal dialog border + drag (pcbjam #22)', () => {
const startY = hbox!.y + hbox!.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.waitForTimeout(120); // eslint-disable-line -- documented interaction dwell (press commit before the resize drag begins)
await page.waitForTimeout(120); // eslint-disable-line -- documented interaction dwell: press commit before the resize drag begins
let minOpaque = 1;
let lowFrames = 0;

View file

@ -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.

View file

@ -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<boolean> {
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);

View file

@ -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');

View file

@ -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 ?? []);

View file

@ -126,7 +126,7 @@ test.describe('3D viewer close and reopen', () => {
// ...then let the wx-side op (wx_window_move → wxWindow::Move) fully land before
// close saves the frame position from the wx side (documented interaction dwell —
// the DOM moves before the wx op completes; see 3d-viewer.spec.ts titlebar test).
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(500); // eslint-disable-line -- documented interaction dwell: the DOM observable fires before wx_window_move lands wx-side; close must save the settled wx position
const posBefore = await windowPos(page, winId);
expect(posBefore, 'the 3D viewer window should have a position').not.toBeNull();

View file

@ -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();

View file

@ -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();
});
});

View file

@ -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<void> {
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<string> {
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<void> {
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<number>();
// 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 }>)

View file

@ -348,18 +348,18 @@ async function getPos(page: Page, id: string) {
async function moveWithM(page: Page, at: { x: number; y: number }, nudges: number) {
await page.mouse.move(at.x, at.y);
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: pointer hover must settle so the tool picks the item under the cursor
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: click-select commit has no DOM observable
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-tool activation has no DOM observable
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: each nudge repaint must land before the next key
}
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: move commit repaint has no DOM observable
}
test.describe("P-3 `m` move twice per page", () => {
@ -386,12 +386,12 @@ test.describe("P-3 `m` move twice per page", () => {
const mid = { x: Math.round((p0.x + p1.x) / 2), y: p0.y };
for (const p of [p0, p1]) {
await page.mouse.move(p.x, p.y);
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(350); // eslint-disable-line -- documented interaction dwell: pointer hover must settle so the tool picks the item under the cursor
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: click-select commit has no DOM observable
}
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: the second selection must commit before Escape clears the tool
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await expect.poll(async () => (await snapshotItems(page)).filter((i) => !idsBefore.has(i.id)).length,

View file

@ -109,9 +109,9 @@ async function loadEcc83(page: Page, testLogger: { consoleLogs: string[]; errors
});
if (!input) throw new Error('filename input not found');
await page.mouse.click(input.x, input.y);
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: focus caret must land in the wx text ctrl before typing
await page.keyboard.type(`${STEM}.kicad_pcb`);
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: typed filename must flush to the wx text ctrl before Enter confirms
await page.keyboard.press('Enter');
const result = await waitForBoardLoaded(page, testLogger, 60000);
console.log(`[TEST] ecc83 board-ready result: ${result}`);

View file

@ -116,9 +116,9 @@ async function loadOldBoard(page: Page, testLogger: { consoleLogs: string[]; err
});
if (!input) throw new Error('filename input not found');
await page.mouse.click(input.x, input.y);
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(200); // eslint-disable-line -- documented interaction dwell: focus caret must land in the wx text ctrl before typing
await page.keyboard.type(`${STEM}.kicad_pcb`);
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: typed filename must flush to the wx text ctrl before Enter confirms
await page.keyboard.press('Enter');
const result = await waitForBoardLoaded(page, testLogger, 60000);
console.log(`[TEST] old board-ready result: ${result}`);

View file

@ -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

View file

@ -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' });

View file

@ -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<void> {
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<void> {
// 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<boolean> {
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([]);
});

View file

@ -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<void> {
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<boolean> {
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');
});
});

View file

@ -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);
});
});

View file

@ -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' });

View file

@ -168,14 +168,14 @@ for (const kind of ["selection", "xsel"] as const) {
expect(await removeItem(page, WIRE1)).toBe(true);
// Documented interaction dwell: the overlay repaint has no observable signal
// — settledShot below compares stable frames, this only lets it queue.
await page.waitForTimeout(1500); // eslint-disable-line -- documented interaction dwell
await page.waitForTimeout(1500); // eslint-disable-line -- documented interaction dwell: the overlay repaint after delete has no observable; settledShot below only compares stable frames
const afterDelete = await settledShot(canvas);
// Ground truth for "what the overlay should look like now": force the
// overlay to rebuild from the SAME peer snapshot — WIRE1 no longer
// resolves, so nothing is drawn for it.
await setRemote(page, { peers: [peer] });
await page.waitForTimeout(1500); // eslint-disable-line -- documented interaction dwell (overlay rebuild, no observable)
await page.waitForTimeout(1500); // eslint-disable-line -- documented interaction dwell: the overlay rebuild from the pushed peer snapshot has no observable
const afterRepush = await settledShot(canvas);
expect(

View file

@ -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<void> {
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<string> {
assertExpectedBoard(expectedBoard);
const opened = await page.evaluate(async (boardPath: string) => {
const runtime = window as unknown as {
Module?: { kicadOpenFile?(path: string): Promise<boolean> | 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`;
}

View file

@ -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}`);
}

View file

@ -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<void> {
await page.addInitScript((workerSrc: string) => {
export interface NgspiceHarnessWatchdogs {
bootTimeoutMs?: number;
responseTimeoutMs?: number;
}
export async function installNgspiceServiceStub(
page: Page,
watchdogs: NgspiceHarnessWatchdogs = {},
): Promise<void> {
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<Worker> | null = null;
const pending = new Map<number, (res: any) => void>();
let nextId = 1;
interface WorkerSlot {
generation: number;
worker?: Worker;
workerUrl?: string;
failed: boolean;
ready: Promise<WorkerSlot>;
bootTimer?: ReturnType<typeof setTimeout>;
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<typeof setTimeout>;
}
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 evtQueue: any[] = [];
const dispatchEvt = (evt: any) => {
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<number, PendingRequest>();
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<TReceipt, TCriteria> {
after: number;
criteria: TCriteria;
resolve: (receipt: TReceipt) => void;
reject: (reason?: unknown) => void;
timer: ReturnType<typeof setTimeout>;
}
/**
* 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 = <TReceipt, TCriteria>(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<ReceiptWaiter<TReceipt, TCriteria>>();
const rejectWaiter = (
waiter: ReceiptWaiter<TReceipt, TCriteria>,
reason: Error,
): void => {
if (!waiters.delete(waiter)) return;
clearTimeout(waiter.timer);
waiter.reject(reason);
};
return {
wait(after: number, criteria: TCriteria, timeoutMs: number): Promise<TReceipt> {
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<TReceipt>((resolve, reject) => {
const waiter: ReceiptWaiter<TReceipt, TCriteria> = {
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<RequestSummary, RequestCriteria>({
validate: (after, criteria) => {
if (!Number.isSafeInteger(after) || after < 0)
return 'request checkpoint must be a non-negative integer';
if (!criteria || typeof criteria !== 'object')
return 'request receipt criteria must be an object';
if (criteria.minimumLength !== undefined
&& (!Number.isSafeInteger(criteria.minimumLength)
|| criteria.minimumLength < 0)) {
return 'minimumLength must be a non-negative integer';
}
return undefined;
},
snapshotCriteria: (criteria) => ({ ...criteria }),
scanExisting: (after, criteria) => ((window as any).__ngspiceLog as RequestSummary[])
.find((entry) => requestMatches(entry, after, criteria)),
matches: requestMatches,
capacityError: 'ngspice request receipt waiter capacity exceeded',
timeoutError: (timeoutMs) =>
`ngspice request receipt timed out after ${timeoutMs} ms`,
});
const appliedReceipts = makeReceiptChannel<AppliedGenerationReceipt, undefined>({
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<RequestSummary> => requestReceipts.wait(after, criteria, timeoutMs);
const waitForAppliedGenerationAfter = (
after: number,
timeoutMs = RECEIPT_TIMEOUT_MS,
): Promise<AppliedGenerationReceipt> => 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);
}
};
const failAllPending = (why: string) => {
for (const [, resolve] of pending) resolve({ error: why });
pending.clear();
// 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 ensureWorker = (): Promise<Worker> => {
if (!workerP) {
workerP = (async () => {
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 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<WorkerSlot> => {
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<never>((_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<void>((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<void>((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 workerP;
return workerSlot.ready;
};
const post = (slot: WorkerSlot, req: any): Promise<any> => {
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`;
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;
try {
worker = await ensureWorker();
} catch (e) {
return { error: `ngspice_service unavailable: ${e}` };
// 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;
}
const id = nextId++;
const res: any = await new Promise((resolve) => {
pending.set(id, resolve);
worker.postMessage({ id, req });
});
(window as any).__ngspiceLog.push({
let slot: WorkerSlot;
try {
slot = await ensureWorker();
} catch (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 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<void> {
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 });
}

View file

@ -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<void> {
await page.addInitScript((workerSrc: string) => {
export interface OccHarnessWatchdogs {
bootTimeoutMs?: number;
}
export async function installOccServiceStub(
page: Page,
watchdogs: OccHarnessWatchdogs = {},
): Promise<void> {
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<Worker> | null = null;
const pending = new Map<number, (res: any) => void>();
let nextId = 1;
interface WorkerSlot {
generation: number;
worker?: Worker;
failed: boolean;
ready: Promise<WorkerSlot>;
bootTimer?: ReturnType<typeof setTimeout>;
rejectBoot?: (reason?: unknown) => void;
removeBootListener?: () => void;
/** The exact lifecycle transition used by Worker.onmessageerror. */
failDecode: () => void;
}
const ensureWorker = (): Promise<Worker> => {
if (!workerP) {
workerP = (async () => {
interface PendingRequest {
generation: number;
resolve: (res: any) => void;
}
let nextGeneration = 1;
let workerSlot: WorkerSlot | null = null;
const pending = new Map<number, PendingRequest>();
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 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<WorkerSlot> => {
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<never>((_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<void>((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<void>((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<void> {
};
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<void> {
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 });
}

View file

@ -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<void> {
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<string> {
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<void> {
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<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.
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;
}

View file

@ -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 ─────────────────────────────────────────────────────────────────

View file

@ -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<void> {
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<boolean> {
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<void> {
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<number> {
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<void> {
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);
}

View file

@ -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",

View file

@ -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");

View file

@ -0,0 +1,57 @@
import { readFileSync } from "node:fs";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Parse `const NAME = <numeric expr>;` 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<string, number> {
const out: Record<string, number> = {};
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<string, number> {
return parseConstants(
readRepoFile(NGSPICE_WORKER_REL),
NGSPICE_WORKER_CONSTANTS,
NGSPICE_WORKER_REL,
);
}

View file

@ -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<string>();
@ -141,6 +173,8 @@ function specUniverse(dir = TESTS_ROOT, rel = ''): string[] {
}
// ── run ───────────────────────────────────────────────────────────────────────
assertNonPlaywrightGates();
const invocations = ciTestScripts().map(resolveScript);
const covered = new Set<string>();

View file

@ -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: <why>`
// — a marker without the `: <why>` 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: <why>` (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)',

View file

@ -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<void>;
};
async function createWorkerHarness(
moduleOverrides: Record<string, any> = {},
): Promise<WorkerHarness> {
const frames: Frame[] = [];
let messageHandler!: (event: { data: unknown }) => Promise<void>;
const module: Record<string, any> = {
init: () => 0,
circ: () => 0,
command: () => 0,
getVecInfo: () => ({ found: false }),
curPlot: () => "",
allPlots: () => [],
allVecs: () => [],
running: () => false,
cmInputPath: () => undefined,
...moduleOverrides,
};
const workerGlobal: Record<string, unknown> = {
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<void> {
for (const frame of harness.frames) {
if (!frame.eventSequence) continue;
await harness.message({
eventAck: {
sequence: frame.eventSequence,
bytes: frame.eventBytes,
},
});
}
}
async function main(): Promise<void> {
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;
});

View file

@ -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");

View file

@ -90,10 +90,10 @@ test('P-1 standalone: fit lands after a keyboard rotate of a clicked footprint',
const sx = box.x + (fp.x - vp.cx) * vp.scale + vp.w / 2;
const sy = box.y + (fp.y - vp.cy) * vp.scale + vp.h / 2;
await page.mouse.move(sx, sy);
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell (pointer hover before press)
await page.waitForTimeout(300); // eslint-disable-line -- documented interaction dwell: pointer hover must settle before press so the tool picks the item under the cursor
await page.mouse.down();
await page.mouse.up();
await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell (selection tool commit)
await page.waitForTimeout(400); // eslint-disable-line -- documented interaction dwell: selection-tool commit has no DOM observable
return (await selection()).includes(fp.id);
}, { timeout: 15000, intervals: [500], message: 'click-select of the first footprint' }).toBe(true)
.then(() => true, () => false);

View file

@ -36,7 +36,7 @@ async function clickTreeRow(page: Page, label: string): Promise<boolean> {
}, 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');

View file

@ -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 });

View file

@ -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 })

View file

@ -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.

View file

@ -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.

View file

@ -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<bool> 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" );
// 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 )
return nullptr;
if( !meta[0] )
// E-11: a plain-JS throw mid-prepare (after some mallocs landed) resolves
// the inertResult without adopting the buffers into the arena — free
// whatever was written on EVERY failure path (free(nullptr) is a no-op,
// so this covers all partial orderings).
if( wxWasmYieldUntil( token ) != 0 || !meta[0] )
{
std::free( vname );
std::free( real );
std::free( comp );
return nullptr;
}
s_name = vname;
s_real = real;

View file

@ -679,6 +679,13 @@ async function doBoot(opts: BootOptions): Promise<void> {
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: () => {},

View file

@ -398,6 +398,17 @@ export class GatewayDocFacade implements YjsProvider {
this.resetCbs.push(cb);
}
/**
* See {@link YjsProvider.repull} a manual SyncStep1 so a passive replica
* can refresh without waiting for a `touched` frame (which the gateway
* debounces leading-edge and may drop entirely).
*/
repull(): void {
if (this.destroyed || this.dead || this.isPresence || this.isHintOnly) return;
if (!this.conn.isOpen()) return;
this.sendSyncStep1();
}
/** `files` hints — project rows changed on the files route (0002 §1). */
onFiles(cb: (seq: number, changes: GatewayFileChange[]) => void): void {
this.filesCbs.push(cb);

View file

@ -46,6 +46,16 @@ export interface YjsProvider {
* must destroy doc + provider and connect afresh.
*/
onReset?(cb: () => void): void;
/**
* Gateway transport only: ask the server for a fresh diff NOW (SyncStep1).
* A passive subscription normally re-pulls on `touched` control frames, but
* the gateway debounces those 2s leading-edge with no trailing emit a
* dropped frame leaves the replica stale until an unrelated later edit.
* Callers invoke this at moments freshness matters (the pcbnew tab
* regaining focus before an update-from-schematic). No-op when absent or
* the socket is down.
*/
repull?(): void;
}
export type ProviderKind =

View file

@ -35,6 +35,7 @@ interface FakeSession {
destroy: ReturnType<typeof vi.fn>;
awareness: { setLocalState: ReturnType<typeof vi.fn> };
onReset: ReturnType<typeof vi.fn>;
repull: ReturnType<typeof vi.fn>;
/** Fire the gateway's `reset` (0004 §2.3). */
emitReset: () => void;
};
@ -58,6 +59,7 @@ function makeSession(room: string): FakeSession {
destroy: vi.fn(),
awareness: { setLocalState: vi.fn() },
onReset: vi.fn((cb: () => void) => resets.add(cb)),
repull: vi.fn(),
emitReset: () => resets.forEach((h) => h()),
},
};
@ -169,6 +171,42 @@ describe("startSiblingRestage", () => {
expect(restageFile).not.toHaveBeenCalled();
});
it("window focus re-pulls every live watch, rate-limited (touched-drop recovery)", async () => {
const listeners = new Map<string, () => void>();
const winFake = {
addEventListener: (ev: string, cb: () => void) => listeners.set(ev, cb),
removeEventListener: vi.fn(),
document: {
visibilityState: "visible",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
},
};
const handle = await startSiblingRestage({
win: winFake as never,
slug: "proj",
scopeId: "S",
projectId: "P",
files: [{ path: "main.kicad_sch" }, { path: "sub.kicad_sch" }],
provider: { kind: "none" } as never,
log: () => {},
});
const focus = listeners.get("focus")!;
expect(focus).toBeDefined();
focus();
for (const s of sessions) expect(s.provider.repull).toHaveBeenCalledTimes(1);
// immediate re-focus inside the rate window is a no-op
focus();
for (const s of sessions) expect(s.provider.repull).toHaveBeenCalledTimes(1);
// past the window it pulls again
vi.advanceTimersByTime(3_000);
focus();
for (const s of sessions) expect(s.provider.repull).toHaveBeenCalledTimes(2);
// destroy detaches the listeners
handle.destroy();
expect(winFake.removeEventListener).toHaveBeenCalled();
});
it("debounces remote updates into one restage", async () => {
await start(["main.kicad_sch"]);
restageFile.mockClear();

View file

@ -170,6 +170,41 @@ export async function startSiblingRestage(opts: {
return session;
};
/* ------------------- focus-triggered freshness (repull) ----------------- */
// A passive watch re-pulls only on `touched` control frames, and the
// gateway debounces those 2s leading-edge with NO trailing emit — a dropped
// frame leaves this mirror stale until an unrelated later edit, which reads
// as "delete + sync doesn't delete until I reload the tab". The sync
// gesture always brings the pcbnew tab to the front first, so ask every
// live watch for a fresh diff whenever this window regains focus; any news
// arrives as doc updates and restages through the normal debounce.
const REPULL_MIN_MS = 2_000;
let lastRepullAt = Number.NEGATIVE_INFINITY;
const attachFocusRepull = (
live: () => Iterable<KicadDocSession | undefined>,
): (() => void) => {
const target = win as unknown as {
document?: { visibilityState?: string; addEventListener?: Window["addEventListener"]; removeEventListener?: Window["removeEventListener"] };
addEventListener?: Window["addEventListener"];
removeEventListener?: Window["removeEventListener"];
};
const repullAll = () => {
if (destroyed) return;
if (target.document?.visibilityState === "hidden") return;
const now = Date.now();
if (now - lastRepullAt < REPULL_MIN_MS) return;
lastRepullAt = now;
for (const s of live()) s?.provider.repull?.();
};
target.addEventListener?.("focus", repullAll);
target.document?.addEventListener?.("visibilitychange", repullAll);
return () => {
target.removeEventListener?.("focus", repullAll);
target.document?.removeEventListener?.("visibilitychange", repullAll);
};
};
/* ------------------------- eager fallback (no presence room) ------------ */
if (!opts.presence) {
@ -184,9 +219,11 @@ export async function startSiblingRestage(opts: {
}
}),
);
const detachFocus = attachFocusRepull(() => sessions);
return {
destroy() {
destroyed = true;
detachFocus();
for (const t of timers.values()) clearTimeout(t);
timers.clear();
for (const s of sessions) {
@ -295,6 +332,9 @@ export async function startSiblingRestage(opts: {
const unsubscribe = presence.subscribe(reconcile);
reconcile();
const detachFocus = attachFocusRepull(
() => [...watches.values()].map((w) => w.session),
);
log(
`[sibling] presence-scoped watch over ${sheetPaths.length} sheet(s) — ` +
`connecting only while a peer has one open`,
@ -303,6 +343,7 @@ export async function startSiblingRestage(opts: {
return {
destroy() {
destroyed = true;
detachFocus();
unsubscribe();
for (const t of timers.values()) clearTimeout(t);
timers.clear();

View file

@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import {
collectBoardModelFiles,
ensureModelInMemfs,
@ -7,6 +7,7 @@ import {
runDeferredModelPrescan,
normalizeModelRef,
scanModelRefs,
type BoardModelFile,
} from "./models-bridge";
import type { Model3dSource } from "./models-source";
@ -140,6 +141,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<Uint8Array | null>((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<Uint8Array | null>((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<Uint8Array | null>((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", () => {

View file

@ -91,6 +91,15 @@ const materialized = new Map<string, string>();
/** In-flight ensures, coalesced per ref (prescan and the C++ fallback race). */
const ensuring = new Map<string, Promise<string | null>>();
/**
* 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<string, string>();
/** 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, wrlstep 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<BoardModelFile[]> {
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<string>();
let idx = 0;
const worker = async (): Promise<void> => {
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;
}

View file

@ -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<NgspiceResponse>; 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<string>((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 });
});
});

View file

@ -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<WorkerSlot>;
bootTimer?: ReturnType<typeof setTimeout>;
rejectBoot?: (reason?: unknown) => void;
removeBootListener?: () => void;
}
interface PendingRequest {
generation: number;
resolve: (res: NgspiceResponse) => void;
timer: ReturnType<typeof setTimeout>;
}
let nextId = 1;
const pending = new Map<number, (res: NgspiceResponse) => void>();
let workerP: Promise<Worker> | null = null;
let nextGeneration = 1;
const pending = new Map<number, PendingRequest>();
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 ensureWorker = (): Promise<Worker> => {
if (!workerP) {
workerP = (async () => {
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 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<WorkerSlot> => {
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<never>((_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<void>((resolve, reject) => {
await new Promise<void>((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<NgspiceResponse> => {
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<NgspiceResponse>((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<NgspiceResponse> => {
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<NgspiceResponse>((resolve) => {
pending.set(id, resolve);
worker.postMessage({ id, req });
});
return post(slot, req);
};
globalThis.ngspiceService = { request };

View file

@ -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<OccResponse>;
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<string>((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" });
});
});

View file

@ -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;
let nextId = 1;
const pending = new Map<number, (res: OccResponse) => void>();
let workerP: Promise<Worker> | null = null;
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;
const ensureWorker = (): Promise<Worker> => {
if (!workerP) {
workerP = (async () => {
interface WorkerSlot {
generation: number;
worker?: Worker;
workerUrl?: string;
failed: boolean;
ready: Promise<WorkerSlot>;
bootTimer?: ReturnType<typeof setTimeout>;
rejectBoot?: (reason?: unknown) => void;
removeBootListener?: () => void;
}
interface PendingRequest {
generation: number;
resolve: (res: OccResponse) => void;
timer: ReturnType<typeof setTimeout>;
}
let nextId = 1;
let nextGeneration = 1;
const pending = new Map<number, PendingRequest>();
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<WorkerSlot> => {
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<never>((_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 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<void>((resolve, reject) => {
await new Promise<void>((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<OccResponse> => {
const post = (slot: WorkerSlot, req: OccRequest): Promise<OccResponse> => {
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<OccResponse>((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<Outcome> = collectBoardModelFiles(
new TextDecoder().decode(board),
6,
controller.signal,
progress,
).then(
(models) => ({ kind: "ready", models }),
(error) => ({ kind: "failed", error }),
);
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<Outcome>((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<OccResponse> => {
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;

View file

@ -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);
});
});

View file

@ -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<MessageListener>();
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<FakeWorker> {
await vi.waitFor(() => expect(FakeWorker.instances.length).toBeGreaterThan(index));
return FakeWorker.instances[index]!;
}

@ -1 +1 @@
Subproject commit a7436d105271815592943aebfc91f42295a74b48
Subproject commit 4e6cc5a441e46ac07b242caa3adacfd8e7c7ec50