eeschema simulator: lazy ngspice_service worker — static sharedspice (XSPICE registry + CIDER), init_dll ifdef, e2e both engines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Viktor Vaczi 2026-07-19 15:59:21 +02:00
commit 703cb010b7
30 changed files with 2872 additions and 82 deletions

View file

@ -81,7 +81,7 @@ trap 'kw_fail 130; exit 130' INT TERM
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
VALID_APPS="kicad_editor | pcbnew | eeschema | calculator | pl_editor | gerbview | kicad_tools | occ_service | all" VALID_APPS="kicad_editor | pcbnew | eeschema | calculator | pl_editor | gerbview | kicad_tools | occ_service | ngspice_service | all"
usage() { usage() {
echo "Usage: ./docker/build.sh <app>[,<app>...] [args...]" >&2 echo "Usage: ./docker/build.sh <app>[,<app>...] [args...]" >&2
@ -112,12 +112,12 @@ shift
# it finalizes in-container (no host wasm-opt tail), so it never contends # it finalizes in-container (no host wasm-opt tail), so it never contends
# with the editor's critical path. # with the editor's critical path.
if [[ "$APP_NAME" == "all" ]]; then if [[ "$APP_NAME" == "all" ]]; then
APPS=(kicad_editor occ_service calculator pl_editor gerbview kicad_tools) APPS=(kicad_editor occ_service ngspice_service calculator pl_editor gerbview kicad_tools)
else else
IFS=',' read -r -a APPS <<< "$APP_NAME" IFS=',' read -r -a APPS <<< "$APP_NAME"
for app in "${APPS[@]}"; do for app in "${APPS[@]}"; do
case "$app" in case "$app" in
kicad_editor|pcbnew|eeschema|calculator|pl_editor|gerbview|kicad_tools|occ_service) ;; kicad_editor|pcbnew|eeschema|calculator|pl_editor|gerbview|kicad_tools|occ_service|ngspice_service) ;;
*) *)
echo "Error: unknown app '$app' (expected: ${VALID_APPS})" >&2 echo "Error: unknown app '$app' (expected: ${VALID_APPS})" >&2
usage usage
@ -275,10 +275,10 @@ postprocess_app() {
local app="$1" local app="$1"
local out_dir="output" local out_dir="output"
# The headless CLI and the OCC service are finalized in-container (real # The headless CLI and the OCC/ngspice services are finalized in-container
# tools, small -g0 wasm) and build with ASYNCIFY=0, so they need no host # (real tools, small -g0 wasm) and build with ASYNCIFY=0, so they need no
# post-processing (no dyncall shims, no finalize, no asyncify). # host post-processing (no dyncall shims, no finalize, no asyncify).
if [ "$app" = "kicad_tools" ] || [ "$app" = "occ_service" ]; then if [ "$app" = "kicad_tools" ] || [ "$app" = "occ_service" ] || [ "$app" = "ngspice_service" ]; then
echo "Skipping host post-processing for ${app} (finalized in-container)" echo "Skipping host post-processing for ${app} (finalized in-container)"
return 0 return 0
fi fi

View file

@ -1,9 +1,12 @@
# 11 — Re-enable the SPICE simulator # 11 — Re-enable the SPICE simulator
> **Verdict: port, ~12 weeks.** The groundwork is half-done in-repo: a wasm ngspice build > **DONE (2026-07-17), as a worker service rather than a static link — see
> script already exists, the simulator sources already compile, and the only unavoidable > [docs/features/ngspice-split/](../ngspice-split/README.md).** The analysis
> upstream-file edit is a ~25-line block swapping `wxDynamicLibrary` for direct static > below predates the implementation; the "static link into eeschema.wasm"
> symbols. Multiple working emscripten ngspice ports exist as precedent. > recipe it recommends was superseded by the occ_service-style split (crash
> isolation, lazy loading, and an in-service solution for XSPICE's .cm dlopen
> via a static code-model registry). The ~25-line `init_dll()` edit happened
> as predicted; `KICAD_SPICE=OFF` and the model-data stubs are gone.
## Current state (more nuanced than "KICAD_SPICE=OFF") ## Current state (more nuanced than "KICAD_SPICE=OFF")

View file

@ -0,0 +1,213 @@
# ngspice-split — the eeschema simulator as a lazy worker service
> Status: implemented (2026-07-17). The SPICE analog of the [occ split](../occ-split/):
> ngspice's sharedspice engine runs in a dedicated Web Worker module
> (`ngspice_service.wasm`, ~5.4 MB, `-O2`), the editor binds a statically linked
> RPC client, and Inspect → Simulator works with **full native parity**
> XSPICE (all seven bundled code models), CIDER, the complete BSIM4/B3SOI/
> B4SOI/HSIM parameter tables, and real background-run semantics (`bg_halt`
> interrupts a running simulation).
## Why a worker service
- **No dlopen in wasm.** Native KiCad dlopens libngspice and resolves ~10
symbols (`eeschema/sim/ngspice.cpp init_dll`). A static emscripten build has
no dynamic linking, and the occ-split analysis rates MAIN_MODULE dynamic
linking RED for the editor (wasm-EH + pthreads + asyncify).
- **Crash isolation.** KiCad's native crash recovery installs SIGSEGV/SIGABRT/
SIGFPE handlers around the simulation thread — emscripten stubs these
(emscripten#8567, wontfix). In-process, a hard ngspice fault kills the tab;
in a worker, the provider fails in-flight requests, KiCad's `m_error`
`NGSPICE::validate()` path re-inits, and a **fresh worker** boots.
- **The editor stays lean.** kicad_editor.wasm carries zero ngspice; the 5.4 MB
service is fetched on the first simulator open (lazy boundary asserted in
e2e).
- **KiCad's usage is RPC-friendly.** It passes nullptr for the streaming
SendData/SendInitData callbacks and pulls vectors after (or during) the run
via `ngGet_Vec_Info`; only console/status text and run-state transitions
stream mid-run.
## Architecture
```
kicad_editor (eeschema, ASYNCIFY=1) ngspice_service (ASYNCIFY=0, pthreads)
┌────────────────────────────────────┐ ┌──────────────────────────────────┐
│ NGSPICE (upstream; ONE ifdef in │ postMessage │ ngspice_service_main.cpp (embind)│
│ init_dll binds pcbjam_ngSpice_*) │◄───────────►│ libngspice.a: sharedspice static,│
│ wasm/stubs/sharedspice_client.cpp: │ │ XSPICE static registry, CIDER │
│ EM_ASYNC_JS request suspend, │ {evt} │ callbacks → MAIN_THREAD_ASYNC_ │
│ event dispatch (fresh entries), │◄────────────│ EM_ASM → Module.ngspiceEmit │
│ vec arena, .include shipper, │ │ bg_run = real ngspice pthread; │
│ atomic running mirror │ │ main thread free → bg_halt works │
└────────────────────────────────────┘ └──────────────────────────────────┘
provider: web/standalone/src/wasm/ngspice-service.ts (lazy blob worker)
worker wrapper (shared app/tests): web/standalone/src/wasm/ngspice-worker.js
```
### Pieces
| Piece | Where |
|---|---|
| dep build (sharedspice static + code models) | `scripts/deps/build-ngspice.sh` (ngspice 46) |
| static code-model registry sources | `scripts/deps/ngspice-wasm/{ngcm_registry.c,ngcm_dlmain_static.c}` |
| Gate-1 node smoke (rc/xspice/cider/halt) | `scripts/deps/ngspice-wasm/smoke/` |
| service module | `wasm/ngspice-service/{CMakeLists.txt,ngspice_service_main.cpp}` |
| service build (standalone emcmake, NOT the kicad tree) | `scripts/kicad/build-ngspice_service.sh` |
| editor client stub | `wasm/stubs/sharedspice_client.cpp` + decls in `wasm/stubs/ngspice/sharedspice.h` |
| the single kicad-fork edit | `eeschema/sim/ngspice.cpp` `init_dll()` `#ifdef __EMSCRIPTEN__` block |
| model-data tables restored at `-O2` | `eeschema/CMakeLists.txt` EMSCRIPTEN block |
| app provider / bundle | `web/standalone/src/wasm/{ngspice-service.ts,ngspice-worker.js}`, `constants.ts`, `boot.ts` |
| e2e | `tests/kicad/{ngspice-probe,eeschema-sim}.spec.ts`, harness stub `tests/kicad/utils/ngspice-service.ts` |
## The ngspice dep build (`build-ngspice.sh`)
`--with-ngshared --disable-shared --enable-static` builds `libngspice.a` with
the sharedspice API and no CLI (upstream gates `bin_PROGRAMS` on
`!SHARED_MODULE`). Idempotent (marker-guarded) edits to the extracted tarball,
each load-bearing:
1. **libtool static-mode override.** ngspice hardwires libtool's `-shared`
mode for the ngshared build (`STATIC=-shared` consumed as AM_CFLAGS
everywhere, plus literal `-shared` in `libngspice_la_{CFLAGS,LDFLAGS}`);
libtool hard-errors on `-shared` without shared-lib support. Fixed with
`make STATIC=-static` (command line beats makefile) + a sed on the two
generated `src/Makefile` lines.
2. **XSPICE code models without dlopen.** Natively each `.cm` is dlopen'd via
`load_opus()` (`src/spicelib/devices/dev.c`). Statically: the icm build is
redirected (env `NGCM_STATIC`/`NGCM_DLMAIN`) to compile per-cm renamed
tables (`ngcm_<cm>_cmDEVices…`, `ngcm_dlmain_static.c`) and `emar` each
model into a `.cm` archive; a registry appended to dev.c resolves the seven
bundled basenames straight into `add_device`/`add_udn`, falling through to
dlopen (→ ngspice's normal error) for unknown paths. dlmain.c's coreitf
wrapper section is deliberately dropped (it would shadow real core symbols
with calls through a never-initialized coreitf); its utility tail
(`fopen_with_path`, `cm_message_printf`, `cm_is_inertial`) is extracted at
build time into `ngcm_common.a`, with `cm_getvar` bound directly to the
core's `cp_getvar` (the dllitf mapping, `cmexport.c`).
3. **cmpp runs on the build host** — automatic in ngspice 46 when
`cross_compiling=yes` (`src/xspice/cmpp/build/`), BUT the icm makefile's
`$(shell cmpp -p …)` model-list calls hardcode the CROSS-compiled cmpp:
patched to `$(CMPP)`. Symptom of the unpatched bug: `.cm` archives quietly
containing only `dlmain.o` ("Permission denied" from the wasm binary).
4. **verilog/vhdl subdirs skipped** (generated `src/xspice/Makefile` sed):
ivlng/ivlngvpi VPI co-simulation shims are inherently shared objects
plugging into an external Icarus/GHDL process — impossible in wasm; the
d_cosim model fails at runtime exactly like a native install without a
cosimulator.
5. **Upstream 32-bit bug fixed (TODO: upstream).** CIDER card parsing frees
through `dataType & IF_REALVEC` — a composite mask (0x8004) that also
matches scalar `IF_SET|IF_REAL` (0x2004) parameters and then frees the
vec-pointer union member overlaying the parsed scalar double. On 64-bit
the misread lands in zero padding (free(NULL)); on wasm32 the pointer
member overlays the HIGH half of the double → heap fault on e.g.
`.model … numd … defa=1p`. Patched to exact `IF_VARTYPES` tests.
6. `/proc/meminfo` header check forced off (`ac_cv_header__proc_meminfo=no`):
configure runs on a Linux build host, the browser has no procfs, and
ngspice treats "0 bytes available" as OOM.
7. `-pthread` everywhere: without `HAVE_LIBPTHREAD`, `bg_run` silently
degrades to a synchronous blocking call (sharedspice.c `runc()`).
8. spinit installs to the sysroot and is `--embed-file`'d into the service at
`/ngspice/scripts/spinit`; `main()` sets `SPICE_LIB_DIR=/ngspice` so
ngspice's env-first search finds it regardless of the baked build prefix.
Its `codemodel <prefix>/<cm>.cm` lines resolve by basename in the registry.
Gate 1 (`scripts/deps/ngspice-wasm/smoke/run-smoke.sh`, node): static link,
RC transient numerics, XSPICE gain block through the registry, CIDER numd DC
sweep, and bg_run → mid-run bg_halt → BGThreadRunning(finished). **The
emscripten default 64 KB stack overflows in ngspice's parser** — the smoke
and the service both run `-sSTACK_SIZE=4MB -sDEFAULT_PTHREAD_STACK_SIZE=2MB`
(the unchecked overflow corrupts the heap and detonates later as free()
faults; found via `-sSAFE_HEAP=1`).
## The service module
Pure libngspice + a ~350-line embind shim — **no KiCad/wx code**, so it
builds standalone with emcmake (`build-ngspice_service.sh`, seconds, no
docker) rather than through the kicad CMake tree like occ_service; the
artifact lands in the standard `build-wasm/kicad-ngspice_service/ngspice_service/`
layout so docker copy / test staging / publish treat it like any app.
- RPC surface mirrors sharedspice 1:1: `init/circ/command/getVecInfo/curPlot/
allPlots/allVecs/running/cmInputPath`.
- **Events**: callbacks fire on ngspice's bg pthread → `strdup` +
`MAIN_THREAD_ASYNC_EM_ASM``Module.ngspiceEmit` (per-target FIFO keeps
order; the service main thread is idle during a run so it drains promptly);
the worker wrapper batches char/stat lines per microtask into one `{evt}`
postMessage (flood guard) and flushes the batch before bg/exit events.
- **bg_halt works mid-run** because the simulation occupies its own pthread —
the module main thread stays free to service the halt RPC. Never shrink the
pthread pool (occ lesson: a blocked browser thread cannot spawn workers).
- **Vector reads are copied under `ngSpice_LockRealloc`** inside the service —
this replaces KiCad's client-side RAII lock (a no-op across RPC; the ifdef
leaves `m_ngSpice_LockRealloc` null) and makes the UI's mid-run plot
refresh safe against the growing tran vectors.
- getVecInfo returns heap views; the worker wrapper copies them into fresh
arrays before postMessage (**a SAB-backed view cannot be transferred**).
## The editor side
- `NGSPICE::init_dll()` gets one `#ifdef __EMSCRIPTEN__` block binding the
`m_ngSpice_*` pointers to `pcbjam_ngSpice_*` (prefix required: the class-
scope typedef names would shadow same-named globals inside the member),
plus a second `#ifndef __EMSCRIPTEN__` guard skipping the client-side
spinit/codemodel staging. That staging is NOT harmless in wasm: its
`wxSetWorkingDirectory( exe dir )` always fails in MEMFS (error 44), the
queued wxLogError then flushes as a MODAL dialog over the freshly opened
simulator frame, and the modal event pump dies with an asyncify-corruption
signature ("index out of bounds" / "indirect call to null" — the known
nested-modal-inside-doRewind limitation documented in
wxwidgets/src/wasm/dialog.cpp; the pump's cancel-recovery keeps the app
alive, but the corruption gate in eeschema-sim.spec.ts rightly fails). The
service embeds its own spinit + code models, so the staging has nothing to
do here anyway. Still the ONLY kicad-fork source file touched.
- `wasm/stubs/sharedspice_client.cpp`: EM_ASYNC_JS request bridge (suspends
the editor; `__asyncjs__*` is pre-covered by asyncify-imports.txt — do NOT
add any of this path to the removelist), a dedicated get_vec bridge that
mallocs vector doubles straight into the editor heap, a per-call
`vector_info` arena (every NGSPICE consumer copies within the same call),
an atomic `ngSpice_running` mirror (the UI polls on a timer; zero RPC per
poll), and the **`.include`/`.lib` shipper**: `NETLIST_EXPORTER_SPICE`
emits absolute `.include` paths (Sim.Library models, the IBIS cache) that
ngspice must open in ITS filesystem — the stub scans the deck recursively
(depth ≤ 4), reads the files from editor MEMFS and ships `{path,text}`
pairs for the service to stage at identical paths.
- **Events into a suspended editor**: the provider hands `{evt}` frames to
`globalThis.__ngspiceOnEvent` (installed by the stub at first init), which
calls the exported `pcbjam_ngspice_event` — a fresh wasm entry from JS,
the same mechanism every wx-dom DOM event uses while the main loop is
asyncify-suspended. KiCad's callbacks only take a mutex + `wxQueueEvent`,
so nothing on the path can suspend.
- The four giant model-data files (bsim4/b3soi/b4soi/hsim) are restored with
per-source `-O2` (the "too many locals" limit was an -O0 artifact; same
workaround as the msys block in eeschema/CMakeLists.txt) — full parameter
tables in the model dialogs.
## Traps (learned the hard way)
- **`MIF*/` inside a C block comment terminates it** — the registry's early
drafts broke the build with prose. (clangd flagged it; the diagnostics were
right.)
- The dev.c registry hook must be inserted BEFORE the registry body is
appended, and its idempotency guard must match the CALL (`ngcm_static_load(name)`),
not the name — the appended definition otherwise masks the hook forever.
- ngspice's `BGThreadRunning` callback argument is *"not running"* (true =
finished) — KiCad treats it as `aFinished`; keep the polarity.
- The `running` mirror flips true at `bg_run` ACCEPTANCE (not at the bg
'started' event) so an immediate `IsRunning()` poll already sees it.
- Emscripten's `HEAPF64.set` after `_malloc` inside EM_ASYNC_JS is safe under
memory growth (the views are refreshed), but always re-read the global
after allocating.
- sharedspice error recovery longjmps (`errbufm`/`errbufc`) — fine under the
tree-wide wasm-SjLj model and an ASYNCIFY=0 module; never let it meet an
asyncify-instrumented stack.
## Known limitations (parity-consistent)
- User-compiled `.cm` code models and `.osdi` (OpenVAF) binaries cannot load —
wasm can't dlopen user binaries. ngspice reports them with its native error
text. (Native parity minus the ability to install binary plugins.)
- Verilog/GHDL co-simulation (`d_cosim`) needs an external simulator process —
same failure text as a native install without Icarus.
- OpenMP is off (unsupported in emscripten) — BSIM model evaluation runs
single-threaded per timestep; the simulation itself still runs on its own
background thread.

2
kicad

@ -1 +1 @@
Subproject commit d0e1705a24b5d446c27694aa03a6ed7c8ad10970 Subproject commit cf1dd3fbd6cf14d4ba5e721ba4082b4e0f3cea42

View file

@ -106,6 +106,18 @@ echo " BINARYEN_CORES=${BINARYEN_CORES} LD_PRELOAD=${WASM_OPT_PRELOAD:-<none>}
SRC="${INPUT_WASM}" SRC="${INPUT_WASM}"
# Refuse to double-instrument. A postprocess re-run on an artifact that a
# previous (killed/partial) run already asyncified re-instruments the
# instrumented module: the pass balloons to OOM/jetsam death, and the output
# would be broken anyway. The asyncify export names only exist in a module
# the pass already touched. Recover by re-copying the pristine post-link
# artifact from the docker volume (docker/build.sh compile copy step).
if LC_ALL=C grep -aq "asyncify_start_unwind" "${INPUT_WASM}"; then
echo "ERROR: ${INPUT_WASM} already contains asyncify exports - refusing to" >&2
echo "double-instrument. Restore the pristine post-link wasm first." >&2
exit 1
fi
# 1. (native wasm-EH only) hoist C++ catch arms so Asyncify can suspend from inside them. # 1. (native wasm-EH only) hoist C++ catch arms so Asyncify can suspend from inside them.
if [ "$DO_HOIST" = 1 ]; then if [ "$DO_HOIST" = 1 ]; then
echo "Running --hoist-cpp-catches${HOIST_G:+ (keeping names for removelist matching)}..." echo "Running --hoist-cpp-catches${HOIST_G:+ (keeping names for removelist matching)}..."

View file

@ -24,3 +24,9 @@ BRepCheck_ParallelAnalyzer::operator*
ShapeFix_Wire::FixGap3d* ShapeFix_Wire::FixGap3d*
ShapeFix_Wire::FixGap2d* ShapeFix_Wire::FixGap2d*
PCB_EDIT_FRAME::setupUIConditions* PCB_EDIT_FRAME::setupUIConditions*
# The ngspice model-parameter table initializers (sim_model_ngspice_data_*.cpp,
# restored for the simulator split): thousands of straight-line emplace_backs,
# nothing that can suspend. Uninstrumented they stay under the JS engines'
# per-function locals limit and shave the post-asyncify module size
# (bsim4/b3soi/b4soi/hsim alone are the four largest functions in eeschema).
NGSPICE_MODEL_INFO_MAP::add*

View file

@ -11,7 +11,7 @@ export KICAD_VERSION="8.99"
# From vcpkg.json overrides (pinned versions) # From vcpkg.json overrides (pinned versions)
export GLM_VERSION="0.9.9.8" export GLM_VERSION="0.9.9.8"
export NGSPICE_VERSION="45.2" export NGSPICE_VERSION="46"
export PROTOBUF_VERSION="3.21.12" export PROTOBUF_VERSION="3.21.12"
export PYTHON_VERSION="3.11.5" export PYTHON_VERSION="3.11.5"
export WXWIDGETS_VERSION="3.3.1" export WXWIDGETS_VERSION="3.3.1"
@ -51,7 +51,9 @@ export CAIRO_URL="https://cairographics.org/releases/cairo-${CAIRO_VERSION}.tar.
export PIXMAN_URL="https://cairographics.org/releases/pixman-${PIXMAN_VERSION}.tar.gz" export PIXMAN_URL="https://cairographics.org/releases/pixman-${PIXMAN_VERSION}.tar.gz"
export OCC_URL="https://github.com/Open-Cascade-SAS/OCCT/archive/refs/tags/V${OCC_VERSION//./_}.tar.gz" export OCC_URL="https://github.com/Open-Cascade-SAS/OCCT/archive/refs/tags/V${OCC_VERSION//./_}.tar.gz"
export RAPIDJSON_URL="https://github.com/Tencent/rapidjson/archive/${RAPIDJSON_COMMIT}.tar.gz" export RAPIDJSON_URL="https://github.com/Tencent/rapidjson/archive/${RAPIDJSON_COMMIT}.tar.gz"
export NGSPICE_URL="https://sourceforge.net/projects/ngspice/files/ng-spice-rework/${NGSPICE_VERSION}/ngspice-${NGSPICE_VERSION}.tar.gz/download" # downloads.sourceforge.net serves the file directly; the projects/... /download
# form returns an HTML redirect page that breaks curl-based fetches.
export NGSPICE_URL="https://downloads.sourceforge.net/project/ngspice/ng-spice-rework/${NGSPICE_VERSION}/ngspice-${NGSPICE_VERSION}.tar.gz"
# SHA256 checksums (to be filled in after first successful download) # SHA256 checksums (to be filled in after first successful download)
# export ZSTD_SHA256="" # export ZSTD_SHA256=""

View file

@ -43,12 +43,13 @@ const TOOLS = [
"gerbview", "gerbview",
"calculator", "calculator",
"occ_service", "occ_service",
"ngspice_service",
]; ];
// Files that make up a self-contained tool bundle. `<tool>` is substituted. // Files that make up a self-contained tool bundle. `<tool>` is substituted.
const SHARED_FILES = ["wx.js", "wx-dom.js", "images.tar.gz"]; const SHARED_FILES = ["wx.js", "wx-dom.js", "images.tar.gz"];
const toolFiles = (tool) => const toolFiles = (tool) =>
tool === "occ_service" tool === "occ_service" || tool === "ngspice_service"
? [`${tool}.wasm`, `${tool}.js`] ? [`${tool}.wasm`, `${tool}.js`]
: [`${tool}.wasm`, `${tool}.js`, ...SHARED_FILES]; : [`${tool}.wasm`, `${tool}.js`, ...SHARED_FILES];

View file

@ -1,6 +1,30 @@
#!/bin/bash #!/bin/bash
# Build ngspice for WebAssembly w# Build ngspice for WebAssembly: sharedspice STATIC library + statically
# ngspice provides SPICE simulation for KiCad's Eeschema # registered XSPICE code models. Consumed by the ngspice_service worker that
# backs KiCad eeschema's simulator.
#
# Deviations from a stock ngspice build, all load-bearing for wasm:
# - --with-ngshared + --disable-shared: libtool builds libngspice.a with the
# sharedspice API (ngSpice_Init & co.) compiled in and no CLI programs
# (bin_PROGRAMS is gated !SHARED_MODULE upstream, so no duplicate main()).
# - XSPICE code models (.cm) are dlopen'd at runtime natively, which a static
# wasm module cannot do. Under NGCM_STATIC the icm build archives each code
# model instead of linking a shared object (per-cm renamed table symbols via
# ngcm_dlmain_static.c), and a registry appended to dev.c resolves the seven
# bundled .cm basenames without dlopen. dlopen remains the fallback for
# unknown paths so user code models fail with ngspice's normal error text.
# Sources in scripts/deps/ngspice-wasm/; edits to the ngspice tree are
# idempotent (marker-guarded) since the tree is an extracted tarball.
# - /proc/meminfo header check is forced off: configure runs on the build
# host (Linux in docker), the browser runtime has no procfs, and ngspice's
# memory guard treats "0 bytes available" as out-of-memory.
# - -pthread everywhere: sharedspice's bg_run/bg_halt background thread is
# gated on HAVE_LIBPTHREAD; without it bg_run silently degrades to a
# synchronous blocking call.
# - Exception model must match the rest of the tree (DEPS_EH_FLAGS).
# - XSPICE + CIDER enabled: parity with native KiCad's bundled ngspice.
# - cmpp (XSPICE preprocessor) runs on the build host; ngspice's configure
# handles that itself when cross_compiling=yes (src/xspice/cmpp/build/).
set -e set -e
@ -12,6 +36,7 @@ source "${SCRIPT_DIR}/../common/functions.sh"
NGSPICE_DIR="${DEPS_ROOT}/ngspice-${NGSPICE_VERSION}" NGSPICE_DIR="${DEPS_ROOT}/ngspice-${NGSPICE_VERSION}"
NGSPICE_BUILD="${BUILD_ROOT}/deps/ngspice" NGSPICE_BUILD="${BUILD_ROOT}/deps/ngspice"
NGSPICE_STAMP="${BUILD_ROOT}/stamps/ngspice.stamp" NGSPICE_STAMP="${BUILD_ROOT}/stamps/ngspice.stamp"
NGCM_SRC_DIR="${SCRIPT_DIR}/ngspice-wasm"
# Parse arguments # Parse arguments
CLEAN=0 CLEAN=0
@ -41,50 +66,285 @@ if [ ! -d "${NGSPICE_DIR}" ]; then
mkdir -p "${DEPS_ROOT}" mkdir -p "${DEPS_ROOT}"
cd "${DEPS_ROOT}" cd "${DEPS_ROOT}"
NGSPICE_URL="https://sourceforge.net/projects/ngspice/files/ng-spice-rework/${NGSPICE_VERSION}/ngspice-${NGSPICE_VERSION}.tar.gz/download" download_file "${NGSPICE_URL}" "ngspice-${NGSPICE_VERSION}.tar.gz"
curl -L "${NGSPICE_URL}" -o "ngspice-${NGSPICE_VERSION}.tar.gz"
tar -xzf "ngspice-${NGSPICE_VERSION}.tar.gz" tar -xzf "ngspice-${NGSPICE_VERSION}.tar.gz"
rm "ngspice-${NGSPICE_VERSION}.tar.gz" rm "ngspice-${NGSPICE_VERSION}.tar.gz"
fi fi
# ---------------------------------------------------------------------------
# Static code-model support (NGCM_STATIC) - idempotent source edits
# ---------------------------------------------------------------------------
DEV_C="${NGSPICE_DIR}/src/spicelib/devices/dev.c"
ICM_MK="${NGSPICE_DIR}/src/xspice/icm/GNUmakefile.in"
# 1. Hook load_opus(): consult the registry before attempting dlopen.
# Must run BEFORE the registry append: the registry body contains the
# ngcm_static_load definition, so a plain grep for the name would mask the
# hook forever. The guard matches the call site only.
if ! grep -q "ngcm_static_load(name)" "${DEV_C}"; then
log_info "Inserting registry hook into load_opus()"
python3 - "${DEV_C}" <<'EOF'
import sys
path = sys.argv[1]
src = open(path).read()
anchor = " lib = dlopen(name, RTLD_NOW);"
hook = """#ifdef NGCM_STATIC
{
extern int ngcm_static_load(const char *path);
int ngcm_ret = ngcm_static_load(name);
if (ngcm_ret >= 0)
return ngcm_ret;
/* not a bundled code model: fall through to dlopen */
}
#endif
"""
assert src.count(anchor) == 1, "load_opus dlopen anchor not unique"
open(path, "w").write(src.replace(anchor, hook + anchor))
EOF
fi
# 2. Append the code-model registry to dev.c (marker-guarded).
if ! grep -q "NGCM_REGISTRY_MARKER" "${DEV_C}"; then
log_info "Appending static code-model registry to dev.c"
cat "${NGCM_SRC_DIR}/ngcm_registry.c" >> "${DEV_C}"
fi
# 3. Fix an upstream 32-bit union-punning bug in the CIDER card parser
# (found by the Gate-1 smoke, ngspice 46). The cleanup tests use
# `dataType & IF_REALVEC` (0x8004): every scalar IF_SET|IF_REAL parameter
# (0x2004) matches through the shared 0x4 bit, and the code then frees the
# vec-pointer union member overlaying the parsed scalar double. On 64-bit
# hosts that misread lands in zeroed padding after the double
# (free(NULL) no-op), which is why upstream never noticed; on wasm32 the
# pointer member overlays the HIGH HALF of the double and free() faults.
# TODO: upstream this to ngspice.
python3 - "${NGSPICE_DIR}/src/spicelib/parser/inpgmod.c" <<'EOF'
import sys
path = sys.argv[1]
src = open(path).read()
old = """ if (info->cardParms[idx].dataType & IF_STRING) {
FREE(value->sValue);
} else if (info->cardParms[idx].dataType & IF_REALVEC) {
FREE(value->v.vec.rVec);
} else if (info->cardParms[idx].dataType & IF_INTVEC) {
FREE(value->v.vec.iVec);
}"""
new = """ /* kicad-wasm: exact variant-type tests. The original
* `& IF_REALVEC` composite masks also match scalar
* IF_SET|IF_REAL parameters (shared 0x4 bit) and free the
* vec-pointer union member overlaying the scalar double -
* benign on 64-bit (lands in zero padding), heap fault on
* wasm32. */
int ngcm_vt = info->cardParms[idx].dataType & IF_VARTYPES;
if (ngcm_vt == IF_STRING) {
FREE(value->sValue);
} else if (ngcm_vt == IF_REALVEC) {
FREE(value->v.vec.rVec);
} else if (ngcm_vt == IF_INTVEC) {
FREE(value->v.vec.iVec);
}"""
if new in src:
pass # already applied
else:
assert src.count(old) == 1, "inpgmod.c cleanup-tests anchor not found"
open(path, "w").write(src.replace(old, new))
print("patched inpgmod.c IF_VARTYPES cleanup tests")
EOF
# 4. Redirect the icm build: archive code models instead of shared-linking,
# compile our renamed-tables TU instead of dlmain.c, and pass the cm name.
# All three edits are exact-string replacements, applied once.
python3 - "${ICM_MK}" <<'EOF'
import sys
path = sys.argv[1]
src = open(path).read()
edits = [
# .cm link recipe -> archive under NGCM_STATIC. The three common objects
# are excluded: dstring.o duplicates the core's, and the tline commons are
# shipped once via ngcm_common.a (they are prerequisites of every cm here
# but only the tlines models reference them).
("\t$(CC) $(CFLAGS) $(EXTRA_CFLAGS) $(VIS_CFLAGS) $(LDFLAGS) $^ $(LIBS) -o $@",
"\t$(if $(NGCM_STATIC),emar rcs $@ $(filter-out dstring.o msline_common.o tline_common.o,$^),$(CC) $(CFLAGS) $(EXTRA_CFLAGS) $(VIS_CFLAGS) $(LDFLAGS) $^ $(LIBS) -o $@)"),
# dlmain.o compiles our static-tables TU under NGCM_STATIC ($< follows the
# first prerequisite).
("$(cm)/dlmain.o : $(srcdir)/dlmain.c $(cm-descr)",
"$(cm)/dlmain.o : $(if $(NGCM_STATIC),$(NGCM_DLMAIN),$(srcdir)/dlmain.c) $(cm-descr)"),
# Per-cm symbol prefix for the tables TU (harmless for the other objects).
("COMPILE = $(CC) $(INCLUDES) -I$(cm) -I$(srcdir)/$(cm) $(CFLAGS) $(EXTRA_CFLAGS) $(VIS_CFLAGS)",
"COMPILE = $(CC) $(INCLUDES) -I$(cm) -I$(srcdir)/$(cm) $(CFLAGS) $(EXTRA_CFLAGS) $(VIS_CFLAGS) $(if $(NGCM_STATIC),-DNGCM_NAME=$(cm))"),
# The $(shell cmpp -p) model-list calls hardcode the in-tree cmpp, which
# is the CROSS-compiled (wasm) binary the build host cannot execute -
# the lists come back empty and the code models silently lose all their
# cfunc/ifspec objects. makedefs' CMPP is the host-built one under
# cross-compilation (configure.ac:1470-1475).
("""ifeq ($(OS),Windows_NT)
cmpp = ../cmpp/cmpp.exe
else
cmpp = ../cmpp/cmpp
endif""",
"cmpp = $(CMPP)"),
]
changed = False
for old, new in edits:
if new in src:
continue # already applied
assert old in src, f"icm GNUmakefile.in anchor not found: {old!r}"
src = src.replace(old, new)
changed = True
if changed:
open(path, "w").write(src)
print("patched icm GNUmakefile.in")
EOF
log_info "Building ngspice ${NGSPICE_VERSION} for WASM..." log_info "Building ngspice ${NGSPICE_VERSION} for WASM..."
mkdir -p "${NGSPICE_BUILD}" mkdir -p "${NGSPICE_BUILD}"
cd "${NGSPICE_BUILD}" cd "${NGSPICE_BUILD}"
# ngspice uses autoconf # ngspice must run at real speed even in debug builds of the rest of the tree:
# Set compiler flags based on debug mode # the simulator is compute-bound and its own module is finalized separately.
if [ "${DEBUG_BUILD:-1}" = "1" ]; then if [ "${DEBUG_BUILD:-1}" = "1" ]; then
export CFLAGS="-g -O0 -pthread" NGSPICE_OPT="-O2 -g"
export CXXFLAGS="-g -O0 -pthread"
NGSPICE_DEBUG_FLAG="--enable-debug"
else else
export CFLAGS="-O2 -pthread" NGSPICE_OPT="-O2"
export CXXFLAGS="-O2 -pthread"
NGSPICE_DEBUG_FLAG="--disable-debug"
fi fi
export LDFLAGS="-pthread"
# Configure ngspice as a static library for WASM # -Wno-error guards: CIDER and parts of XSPICE are legacy C that modern clang
# Note: --with-ngshared requires shared libs which WASM doesn't support # (emcc >= llvm 16) rejects by default.
# We build static lib instead export CFLAGS="${NGSPICE_OPT} -pthread ${DEPS_EH_FLAGS} -DNGCM_STATIC \
-Wno-error=implicit-function-declaration -Wno-error=implicit-int"
export CXXFLAGS="${NGSPICE_OPT} -pthread ${DEPS_EH_FLAGS}"
export LDFLAGS="-pthread ${DEPS_EH_FLAGS}"
# Makefile-level knobs for the icm (code model) build, read from the
# environment by our GNUmakefile.in edits above.
export NGCM_STATIC=1
export NGCM_DLMAIN="${NGCM_SRC_DIR}/ngcm_dlmain_static.c"
emconfigure "${NGSPICE_DIR}/configure" \ emconfigure "${NGSPICE_DIR}/configure" \
--prefix="${SYSROOT}" \ --prefix="${SYSROOT}" \
--host=wasm32-unknown-emscripten \ --host=wasm32-unknown-emscripten \
--build=$(uname -m)-linux-gnu \ --build="$("${NGSPICE_DIR}/config.guess")" \
--with-ngshared \
--disable-shared \ --disable-shared \
--enable-static \ --enable-static \
${NGSPICE_DEBUG_FLAG} \
--disable-dependency-tracking \
--disable-openmp \
--enable-cider \
--enable-xspice \ --enable-xspice \
--enable-cider \
--disable-openmp \
--disable-debug \
--without-x \ --without-x \
--without-readline \ --with-readline=no \
--without-editline --without-editline \
ac_cv_header__proc_meminfo=no
emmake make -j${JOBS} # ngspice hardwires libtool's -shared mode for the ngshared build: configure
emmake make install # sets STATIC=-shared (consumed as AM_CFLAGS by every convenience lib) and
# src/Makefile.am gives libngspice_la_{CFLAGS,LDFLAGS} a literal -shared.
# libtool refuses -shared outright on a target without shared-library support
# ("Fatal configuration error"), so force static mode: the make command line
# overrides $(STATIC) everywhere, and the two hardwired lines are rewritten in
# the generated Makefile (regenerated by configure on every build, so this
# stays idempotent).
sed -i.ngcm.bak \
-e 's/^\(libngspice_la_[A-Z]*FLAGS *=.*\)-shared/\1-static/' \
src/Makefile
# The XSPICE verilog/vhdl subdirs build VPI co-simulation shims (ivlng.la,
# ivlngvpi.la) that are inherently SHARED objects plugging into an external
# Icarus/GHDL process - impossible in wasm and unbuildable without shared-lib
# support. Skipping them matches native behaviour when no cosimulator is
# installed: the d_cosim code model fails at runtime with ngspice's normal
# error message.
sed -i.ngcm.bak \
-e 's/^\(SUBDIRS = mif cm enh evt idn cmpp icm\) verilog vhdl$/\1/' \
src/xspice/Makefile
emmake make -j${JOBS} STATIC=-static
emmake make install STATIC=-static
# dlmain.c's tail (fopen_with_path, cm_message_printf, cm_is_inertial) is
# utility code the cfunc objects call but that exists nowhere in the core -
# natively each .cm DLL carries its own copy. Extract it once from the
# pristine dlmain.c (BSD-3) so the seven archives stay collision-free; the
# coreitf wrapper section above the marker must NOT come along (it would
# shadow real core functions with calls through a never-initialized coreitf).
python3 - "${NGSPICE_DIR}/src/xspice/icm/dlmain.c" "${NGSPICE_BUILD}/ngcm_cmutil.c" <<'EOF'
import sys
src_path, out_path = sys.argv[1], sys.argv[2]
src = open(src_path).read()
marker = "#define DFLT_BUF_SIZE 256"
assert src.count(marker) == 1, "dlmain.c utility-tail marker not unique"
tail = src[src.index(marker):]
preamble = """/* Generated by build-ngspice.sh: utility tail of ngspice's
* src/xspice/icm/dlmain.c (BSD-3-Clause, Copyright 2000 The ngspice team),
* shared once across the statically linked code models. */
#include <stdarg.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ngspice/config.h"
#include "ngspice/cpextern.h"
#include "ngspice/devdefs.h"
#include "ngspice/dstring.h"
#include "ngspice/dllitf.h"
#include "ngspice/evtudn.h"
#include "ngspice/inpdefs.h"
#include "ngspice/inertial.h"
#include "ngspice/cmproto.h"
/* In the DLL world cm_getvar is a dlmain.c wrapper that reaches the core's
* cp_getvar through coreitf (see cmexport.c binding dllitf_cm_getvar to
* cp_getvar). Statically there is no coreitf; bind it directly. */
bool cm_getvar(char *name, enum cp_types type, void *retval, size_t rsize)
{
return cp_getvar(name, type, retval, rsize);
}
"""
open(out_path, "w").write(preamble + tail)
EOF
emcc -c "${NGSPICE_BUILD}/ngcm_cmutil.c" -o "${NGSPICE_BUILD}/ngcm_cmutil.o" \
${CFLAGS} \
-I"${NGSPICE_DIR}/src/include" \
-I"${NGSPICE_BUILD}/src/include"
# The tline/msline common objects every cm build compiles but only the tlines
# models reference; shipped once so whole-archiving the .cm archives stays
# duplicate-free.
emar rcs "${SYSROOT}/lib/ngspice/ngcm_common.a" \
src/xspice/icm/msline_common.o \
src/xspice/icm/tline_common.o \
src/xspice/icm/dstring.o \
"${NGSPICE_BUILD}/ngcm_cmutil.o"
# Sanity: everything the ngspice_service link needs must exist.
for f in \
"${SYSROOT}/lib/libngspice.a" \
"${SYSROOT}/include/ngspice/sharedspice.h" \
"${SYSROOT}/lib/ngspice/analog.cm" \
"${SYSROOT}/lib/ngspice/digital.cm" \
"${SYSROOT}/lib/ngspice/spice2poly.cm" \
"${SYSROOT}/lib/ngspice/table.cm" \
"${SYSROOT}/lib/ngspice/tlines.cm" \
"${SYSROOT}/lib/ngspice/xtradev.cm" \
"${SYSROOT}/lib/ngspice/xtraevt.cm" \
"${SYSROOT}/share/ngspice/scripts/spinit"; do
if [ ! -f "$f" ]; then
log_error "ngspice install incomplete: missing $f"
exit 1
fi
done
create_stamp "${NGSPICE_STAMP}" create_stamp "${NGSPICE_STAMP}"
log_info "ngspice build complete!" log_info "ngspice build complete!"

View file

@ -0,0 +1,57 @@
/*
* Static-build replacement for ngspice's src/xspice/icm/dlmain.c.
*
* In the WASM build the XSPICE code models (.cm) cannot be dlopen'd, so each
* code-model directory is compiled into a static archive instead of a shared
* object (see build-ngspice.sh, NGCM_STATIC). This TU provides only the two
* model tables, renamed per code model (ngcm_<cm>_cmDEVices etc.) so all seven
* archives can coexist in one image. Everything else dlmain.c contains - the
* CMdevs()/CMudns() accessor exports and the coreitf-forwarding wrappers for
* the MIF core functions - is deliberately omitted: the registry appended to
* dev.c (ngcm_registry.c) reads the tables directly, and in a static link the
* code-model objects bind straight to the real core functions, which the
* wrappers would otherwise collide with.
*
* Compiled once per code model with -DNGCM_NAME=<cm> and -I<cm-build-dir> so
* the cmpp-generated cmextrn.h/cminfo.h/udnextrn.h/udninfo.h of that model
* are picked up.
*/
#include <stdarg.h>
#include <stdlib.h>
#include <string.h>
#include "ngspice/config.h"
#include "ngspice/cpextern.h"
#include "ngspice/devdefs.h"
#include "ngspice/dstring.h"
#include "ngspice/dllitf.h"
#include "ngspice/evtudn.h"
#include "ngspice/inpdefs.h"
#include "ngspice/inertial.h"
#include "cmextrn.h"
#include "udnextrn.h"
#ifndef NGCM_NAME
#error "ngcm_dlmain_static.c must be compiled with -DNGCM_NAME=<code model name>"
#endif
#define NGCM_PASTE2(a, b) a##b
#define NGCM_PASTE(a, b) NGCM_PASTE2(a, b)
#define NGCM_SYM(s) NGCM_PASTE(NGCM_PASTE(ngcm_, NGCM_NAME), NGCM_PASTE2(_, s))
SPICEdev *NGCM_SYM(cmDEVices)[] = {
#include "cminfo.h"
NULL
};
int NGCM_SYM(cmDEVicesCNT) =
sizeof(NGCM_SYM(cmDEVices)) / sizeof(SPICEdev *) - 1;
Evt_Udn_Info_t *NGCM_SYM(cmEVTudns)[] = {
#include "udninfo.h"
NULL
};
int NGCM_SYM(cmEVTudnCNT) =
sizeof(NGCM_SYM(cmEVTudns)) / sizeof(Evt_Udn_Info_t *) - 1;

View file

@ -0,0 +1,82 @@
/* NGCM_REGISTRY_MARKER - appended to src/spicelib/devices/dev.c by
* scripts/deps/build-ngspice.sh (idempotent: guarded by this marker).
*
* Registry of the statically-linked XSPICE code models. In the WASM build the
* bundled .cm files are static archives (see ngcm_dlmain_static.c) and cannot
* be dlopen'd; load_opus() consults this registry first (hook inserted by
* build-ngspice.sh) and only falls back to dlopen - which then fails with
* ngspice's normal error reporting - for paths it does not recognize, e.g.
* user-compiled code models, which cannot exist as loadable binaries in wasm.
*
* Matching is by basename so the spinit "codemodel <path>/analog.cm" lines
* keep working regardless of the install prefix baked into spinit.
*
* No coreitf wiring happens here: in a static link the code-model objects call
* the MIF and cm_ core functions directly, so the dlmain.c indirection table
* the dlopen path has to fill in does not exist.
*/
#if defined(NGCM_STATIC) && defined(XSPICE)
#include "ngspice/devdefs.h"
#include "ngspice/evtudn.h"
#include <string.h>
#define NGCM_DECL(cm) \
extern SPICEdev *ngcm_##cm##_cmDEVices[]; \
extern int ngcm_##cm##_cmDEVicesCNT; \
extern Evt_Udn_Info_t *ngcm_##cm##_cmEVTudns[]; \
extern int ngcm_##cm##_cmEVTudnCNT;
NGCM_DECL(analog)
NGCM_DECL(digital)
NGCM_DECL(spice2poly)
NGCM_DECL(table)
NGCM_DECL(tlines)
NGCM_DECL(xtradev)
NGCM_DECL(xtraevt)
struct ngcm_static_entry {
const char *basename;
SPICEdev **devs;
int *devnum;
Evt_Udn_Info_t **udns;
int *udnnum;
};
#define NGCM_ENTRY(cm) \
{ #cm ".cm", ngcm_##cm##_cmDEVices, &ngcm_##cm##_cmDEVicesCNT, \
ngcm_##cm##_cmEVTudns, &ngcm_##cm##_cmEVTudnCNT }
static const struct ngcm_static_entry ngcm_static_entries[] = {
NGCM_ENTRY(analog),
NGCM_ENTRY(digital),
NGCM_ENTRY(spice2poly),
NGCM_ENTRY(table),
NGCM_ENTRY(tlines),
NGCM_ENTRY(xtradev),
NGCM_ENTRY(xtraevt),
};
/* Returns load_opus()-compatible status: 0 = registered, -1 = not a bundled
* code model (caller falls through to the dlopen path). */
int ngcm_static_load(const char *path)
{
const char *base = strrchr(path, '/');
size_t i;
base = base ? base + 1 : path;
for (i = 0; i < sizeof(ngcm_static_entries) / sizeof(ngcm_static_entries[0]); i++) {
const struct ngcm_static_entry *e = &ngcm_static_entries[i];
if (strcmp(base, e->basename) == 0) {
add_device(*e->devnum, e->devs, 1);
add_udn(*e->udnnum, e->udns);
return 0;
}
}
return -1;
}
#endif /* NGCM_STATIC && XSPICE */

View file

@ -0,0 +1,40 @@
#!/bin/bash
# Gate-1 smoke for the wasm ngspice build: compiles smoke.c against the
# installed sysroot artifacts and runs it under node. Proves, in one shot:
# static sharedspice links; RC transient numerics; XSPICE code models resolve
# through the static registry; CIDER (numd) simulates; bg_run/bg_halt work on
# a real pthread. See smoke.c for the assertions.
#
# Usage: scripts/deps/ngspice-wasm/smoke/run-smoke.sh
# (build ngspice first: scripts/deps/build-ngspice.sh)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
QUIET=1 source "${SCRIPT_DIR}/../../../common/env.sh"
SMOKE_BUILD="${BUILD_ROOT}/ngspice-smoke"
mkdir -p "${SMOKE_BUILD}"
# NODERAWFS: the wasm module sees the real filesystem, so the NGSPICEDATADIR
# baked into libngspice (the sysroot prefix) resolves and spinit + code-model
# registration run exactly as they will in the service worker.
# PROXY_TO_PTHREAD: main() may block (usleep) while ngspice's bg thread runs.
emcc "${SCRIPT_DIR}/smoke.c" -o "${SMOKE_BUILD}/smoke.js" \
-I"${SYSROOT}/include" \
-O1 -g -pthread ${DEPS_EH_FLAGS} \
"${SYSROOT}/lib/libngspice.a" \
"${SYSROOT}"/lib/ngspice/*.cm \
"${SYSROOT}/lib/ngspice/ngcm_common.a" \
-sENVIRONMENT=node \
-sNODERAWFS=1 \
-sALLOW_MEMORY_GROWTH=1 \
-sINITIAL_MEMORY=256MB \
-sPROXY_TO_PTHREAD \
-sPTHREAD_POOL_SIZE=8 \
-sEXIT_RUNTIME=1 \
-sSTACK_SIZE=4MB \
-sDEFAULT_PTHREAD_STACK_SIZE=2MB
# Optional argument: run a single scenario (rc|xspice|cider|halt).
node "${SMOKE_BUILD}/smoke.js" "$@"

View file

@ -0,0 +1,330 @@
/*
* Gate-1 smoke test for the wasm ngspice sharedspice static build.
* Runs under node (see run-smoke.sh). Four scenarios, each printing
* "SMOKE PASS <name>" on success; any failure prints "SMOKE FAIL <name>: why"
* and exits non-zero at the end.
*
* rc - foreground .tran of an RC charge curve, numeric check
* xspice - gain a-device .op (proves the static code-model registry:
* the model only exists if spinit's codemodel lines resolved)
* cider - numd (CIDER) silicon resistor DC sweep completes
* halt - bg_run on a heavy deck, bg_halt mid-run, BGThreadRunning
* callback fires with finished=true (proves the real pthread path)
*
* Built with -sPROXY_TO_PTHREAD so main() may block (usleep) while ngspice's
* own background thread simulates.
*/
#include <stdatomic.h>
#include <stdbool.h> /* sharedspice.h's NG_BOOL fallback typedef needs it */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "ngspice/sharedspice.h"
static atomic_int g_bg_finished_events;
static atomic_int g_exit_called;
static atomic_int g_char_lines;
static int g_failures;
static int cb_send_char(char *what, int id, void *user)
{
(void) id;
(void) user;
atomic_fetch_add(&g_char_lines, 1);
if (getenv("SMOKE_VERBOSE"))
fprintf(stderr, "[ngspice] %s\n", what);
return 0;
}
static int cb_send_stat(char *what, int id, void *user)
{
(void) what;
(void) id;
(void) user;
return 0;
}
static int cb_controlled_exit(int status, NG_BOOL immediate, NG_BOOL quit,
int id, void *user)
{
(void) immediate;
(void) quit;
(void) id;
(void) user;
fprintf(stderr, "[smoke] ControlledExit status=%d\n", status);
atomic_store(&g_exit_called, 1);
return 0;
}
static int cb_bg_running(NG_BOOL finished, int id, void *user)
{
(void) id;
(void) user;
if (finished)
atomic_fetch_add(&g_bg_finished_events, 1);
return 0;
}
static void fail(const char *name, const char *why)
{
printf("SMOKE FAIL %s: %s\n", name, why);
g_failures++;
}
static void pass(const char *name)
{
printf("SMOKE PASS %s\n", name);
}
/* Fetch a vector, returning its length and (optionally) the last real value. */
static int vec_last(const char *vec, int *len_out, double *last_out)
{
pvector_info vi = ngGet_Vec_Info((char *) vec);
if (!vi || vi->v_length <= 0)
return -1;
if (len_out)
*len_out = vi->v_length;
if (last_out) {
if (vi->v_realdata)
*last_out = vi->v_realdata[vi->v_length - 1];
else if (vi->v_compdata)
*last_out = vi->v_compdata[vi->v_length - 1].cx_real;
else
return -1;
}
return 0;
}
static int run_circ(const char *const *lines)
{
/* ngSpice_Circ wants a NULL-terminated array of writable strings. */
int n = 0;
while (lines[n])
n++;
char **arr = malloc((size_t) (n + 1) * sizeof(char *));
for (int i = 0; i < n; i++)
arr[i] = strdup(lines[i]);
arr[n] = NULL;
int ret = ngSpice_Circ(arr);
for (int i = 0; i < n; i++)
free(arr[i]);
free(arr);
return ret;
}
static void test_rc(void)
{
static const char *const deck[] = {
"rc smoke",
"V1 in 0 1",
"R1 in out 1k",
"C1 out 0 1u",
".tran 10u 5m",
".end",
NULL,
};
if (run_circ(deck) != 0)
return fail("rc", "ngSpice_Circ failed");
if (ngSpice_Command("run") != 0)
return fail("rc", "run command failed");
int len = 0;
double last = 0.0;
if (vec_last("out", &len, &last) != 0)
return fail("rc", "vector 'out' missing");
/* 5 tau: v = 1 - exp(-5) = 0.99326 */
if (last < 0.98 || last > 1.0) {
char buf[128];
snprintf(buf, sizeof buf, "v(out) final %.5f not in [0.98, 1.0] (len %d)",
last, len);
return fail("rc", buf);
}
pass("rc");
}
static void test_xspice(void)
{
static const char *const deck[] = {
"xspice smoke",
"V1 in 0 2",
"A1 in aout gainblk",
".model gainblk gain(gain=3)",
"R1 aout 0 1k",
".op",
".end",
NULL,
};
if (run_circ(deck) != 0)
return fail("xspice", "ngSpice_Circ failed (code models not registered?)");
if (ngSpice_Command("run") != 0)
return fail("xspice", "run command failed");
double v = 0.0;
if (vec_last("aout", NULL, &v) != 0)
return fail("xspice", "vector 'aout' missing");
if (v < 5.999 || v > 6.001) {
char buf[96];
snprintf(buf, sizeof buf, "v(aout) %.6f != 6.0", v);
return fail("xspice", buf);
}
pass("xspice");
}
static void test_cider(void)
{
/* Reduced examples/cider/resistor/sires.cir: numd level=1 needs the whole
* CIDER machinery (mesh, doping, mobility models) to produce a current. */
static const char *const deck[] = {
"cider smoke - silicon resistor",
"VPP 1 0 2v",
"VNN 2 0 0.0v",
"D1 1 2 M_RES AREA=1",
".MODEL M_RES numd level=1",
"+ options resistor defa=1p",
"+ x.mesh loc=0.0 num=1",
"+ x.mesh loc=1.0 num=21",
"+ domain num=1 material=1",
"+ material num=1 silicon",
"+ doping unif n.type conc=2.5e16",
"+ models bgn srh conctau auger concmob fieldmob",
".DC VPP 0.0v 2.01v 0.5v",
".END",
NULL,
};
if (run_circ(deck) != 0)
return fail("cider", "ngSpice_Circ failed (CIDER not compiled in?)");
if (ngSpice_Command("run") != 0)
return fail("cider", "run command failed");
int len = 0;
double i_last = 0.0;
if (vec_last("vpp#branch", &len, &i_last) != 0)
return fail("cider", "vector 'vpp#branch' missing");
if (len < 4)
return fail("cider", "DC sweep produced too few points");
if (!(i_last < 0.0) || i_last < -1.0)
return fail("cider", "resistor current magnitude implausible");
pass("cider");
}
static void test_halt(void)
{
/* Heavy enough that bg_halt lands mid-run: a long transient of a 100-stage
* nonlinear RC/diode ladder, storage bounded via .save. */
const int stages = 100;
const char **deck = malloc((size_t) (stages * 3 + 8) * sizeof(char *));
char **owned = malloc((size_t) (stages * 3 + 8) * sizeof(char *));
int n = 0;
owned[n] = strdup("halt smoke - rc/diode ladder");
deck[n] = owned[n];
n++;
owned[n] = strdup("V1 n0 0 SIN(0 5 10k)");
deck[n] = owned[n];
n++;
for (int i = 0; i < stages; i++) {
char line[96];
snprintf(line, sizeof line, "R%d n%d n%d 100", i + 1, i, i + 1);
owned[n] = strdup(line);
deck[n] = owned[n];
n++;
snprintf(line, sizeof line, "C%d n%d 0 10n", i + 1, i + 1);
owned[n] = strdup(line);
deck[n] = owned[n];
n++;
snprintf(line, sizeof line, "D%d n%d 0 dmod", i + 1, i + 1);
owned[n] = strdup(line);
deck[n] = owned[n];
n++;
}
owned[n] = strdup(".model dmod d(is=1e-14)");
deck[n] = owned[n];
n++;
owned[n] = strdup(".save v(n100)");
deck[n] = owned[n];
n++;
owned[n] = strdup(".tran 100n 10");
deck[n] = owned[n];
n++;
owned[n] = strdup(".end");
deck[n] = owned[n];
n++;
deck[n] = NULL;
owned[n] = NULL;
int circ_ret = ngSpice_Circ((char **) deck);
for (int i = 0; i < n; i++)
free(owned[i]);
free(owned);
free(deck);
if (circ_ret != 0)
return fail("halt", "ngSpice_Circ failed");
int before = atomic_load(&g_bg_finished_events);
if (ngSpice_Command("bg_run") != 0)
return fail("halt", "bg_run command failed");
/* Give the background thread time to actually start and chew. */
usleep(400 * 1000);
if (!ngSpice_running())
return fail("halt", "ngSpice_running false 400ms into a 10s transient "
"(bg thread never started or deck too light)");
if (ngSpice_Command("bg_halt") != 0)
return fail("halt", "bg_halt command failed");
/* bg_halt joins the bg thread; give the finished callback a moment. */
for (int i = 0; i < 50 && atomic_load(&g_bg_finished_events) == before; i++)
usleep(100 * 1000);
if (atomic_load(&g_bg_finished_events) == before)
return fail("halt", "BGThreadRunning(finished) never fired after bg_halt");
if (ngSpice_running())
return fail("halt", "still running after bg_halt");
pass("halt");
}
int main(int argc, char **argv)
{
/* Optional argv[1]: run only the named scenario (rc|xspice|cider|halt). */
const char *only = argc > 1 ? argv[1] : NULL;
int ret = ngSpice_Init(cb_send_char, cb_send_stat, cb_controlled_exit,
NULL, NULL, cb_bg_running, NULL);
if (ret != 0) {
printf("SMOKE FAIL init: ngSpice_Init returned %d\n", ret);
return 1;
}
printf("SMOKE PASS init\n");
if (!only || !strcmp(only, "rc"))
test_rc();
if (!only || !strcmp(only, "xspice"))
test_xspice();
if (!only || !strcmp(only, "cider"))
test_cider();
if (!only || !strcmp(only, "halt"))
test_halt();
if (atomic_load(&g_exit_called))
fail("exit", "ControlledExit fired during the smoke run");
printf(g_failures ? "SMOKE RESULT: %d failure(s)\n" : "SMOKE RESULT: all passed\n",
g_failures);
return g_failures ? 1 : 0;
}

View file

@ -235,7 +235,9 @@ else
fi fi
# Step 2: Build dependencies # Step 2: Build dependencies
# Note: --with-occ for OpenCASCADE, but NOT ngspice since KICAD_SPICE=OFF # Note: --with-occ for OpenCASCADE. The ngspice dep is NOT needed here — the
# editor links only the sharedspice client stub; the engine is the separate
# ngspice_service app (scripts/kicad/build-ngspice_service.sh builds its dep).
if [ $SKIP_DEPS -eq 0 ]; then if [ $SKIP_DEPS -eq 0 ]; then
kw_stage deps kw_stage deps
log_info "Building dependencies..." log_info "Building dependencies..."
@ -547,7 +549,6 @@ emcmake cmake "${KICAD_DIR}" \
-DwxWidgets_CONFIG_EXECUTABLE="${WX_BUILD}/wx-config" \ -DwxWidgets_CONFIG_EXECUTABLE="${WX_BUILD}/wx-config" \
\ \
-DKICAD_BUILD_QA_TESTS=OFF \ -DKICAD_BUILD_QA_TESTS=OFF \
-DKICAD_SPICE=OFF \
-DKICAD_USE_EGL=OFF \ -DKICAD_USE_EGL=OFF \
-DKICAD_USE_BUNDLED_GLEW=ON \ -DKICAD_USE_BUNDLED_GLEW=ON \
-DKICAD_BUILD_3D_VIEWER_WASM=${BUILD_3D_VIEWER} \ -DKICAD_BUILD_3D_VIEWER_WASM=${BUILD_3D_VIEWER} \

View file

@ -0,0 +1,60 @@
#!/bin/bash
# Build ngspice_service.{js,wasm} — the eeschema simulator's ngspice worker
# module (wasm/ngspice-service/). Unlike occ_service this does NOT go through
# the kicad CMake tree: the module contains no KiCad/wx code, so it configures
# standalone against the sysroot ngspice artifacts (much faster to iterate,
# and buildable without the full editor dependency set).
#
# Artifacts: ${BUILD_ROOT}/kicad-ngspice_service/ngspice_service/ngspice_service.{js,wasm}
# — the kicad-<app>/<subdir>/ layout every other app uses, so docker/build.sh's
# output copy and tests/scripts/setup-kicad-wasm.sh's docker-volume fallback
# find them without special-casing.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/../common/env.sh"
# Parse arguments
CLEAN=0
for arg in "$@"; do
case $arg in
--clean)
CLEAN=1
shift
;;
esac
done
NGSPICE_SERVICE_BUILD="${BUILD_ROOT}/kicad-ngspice_service"
if [ $CLEAN -eq 1 ]; then
log_info "Cleaning ngspice_service build..."
rm -rf "${NGSPICE_SERVICE_BUILD}"
fi
# The ngspice dep (sharedspice static lib + code-model archives + spinit) is
# stamped, so this is a no-op when already built.
"${SCRIPT_DIR}/../deps/build-ngspice.sh"
log_info "Building ngspice_service..."
mkdir -p "${NGSPICE_SERVICE_BUILD}"
cd "${NGSPICE_SERVICE_BUILD}"
emcmake cmake "${WASM_COMPAT}/ngspice-service" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_RUNTIME_OUTPUT_DIRECTORY="${NGSPICE_SERVICE_BUILD}/ngspice_service" \
-DNGSPICE_SYSROOT="${SYSROOT}" \
-DNGSPICE_EH_FLAGS="${DEPS_EH_FLAGS}"
emmake make -j${JOBS}
for f in ngspice_service.js ngspice_service.wasm; do
if [ ! -f "${NGSPICE_SERVICE_BUILD}/ngspice_service/$f" ]; then
log_error "ngspice_service build incomplete: missing $f"
exit 1
fi
done
log_info "ngspice_service build complete: ${NGSPICE_SERVICE_BUILD}/ngspice_service/ngspice_service.{js,wasm}"

View file

@ -0,0 +1,204 @@
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';
/**
* eeschema simulator end-to-end (docs/features/ngspice-split/): the historic
* kill-point was SIMULATOR_FRAME never opening (no dlopen for libngspice);
* now NGSPICE binds the sharedspice client stub and the engine runs in the
* lazy ngspice_service worker. These specs drive the REAL UI path:
* project open Inspect Simulator Run plot, asserting the RPC/event
* plumbing (window.__ngspiceEvents / __ngspiceLog from the harness provider,
* tests/kicad/utils/ngspice-service.ts) and the rendered result.
*
* Fixture: the complete kicad demo rectifier project its 1N4148 lives in a
* sibling diode.mod pulled in via `.include`, so a passing transient also
* proves the client stub's netlist file shipping (a missing model fails the
* 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.
await expect
.poll(async () => {
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.
const stepX = Math.max(1, Math.floor(png.width / 8));
const stepY = Math.max(1, Math.floor(png.height / 8));
for (let y = 0; y < png.height; y += stepY) {
for (let x = 0; x < png.width; x += stepX) {
const i = (png.width * y + x) << 2;
colors.add((png.data[i] << 16) | (png.data[i + 1] << 8) | png.data[i + 2]);
}
}
return colors.size;
}
test.describe('eeschema simulator', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(300000);
test('Inspect → Simulator opens the frame; service fetches lazily', async ({ page, testLogger }) => {
const ngspiceFetches: string[] = [];
page.on('request', (r) => {
if (r.url().includes('ngspice_service')) ngspiceFetches.push(r.url());
});
await page.goto('/kicad/eeschema.html');
await waitForEditorReady(page);
await loadRectifier(page);
expect(ngspiceFetches,
'ngspice_service must NOT be fetched before the simulator opens')
.toHaveLength(0);
await openSimulator(page);
await stableShot(page, 'eeschema-sim-frame.png');
// NGSPICE::init_dll ran inside the frame ctor → the client stub's init
// RPC booted the worker.
expect(ngspiceFetches.length,
'ngspice_service fetched lazily by the simulator open')
.toBeGreaterThan(0);
const all = [...testLogger.consoleLogs, ...testLogger.errors];
expect(all.filter((l) => l.includes('Aborted(')), 'no aborts').toHaveLength(0);
});
test('transient run: live console stream, vectors reach the plot, plot renders', async ({ page, testLogger }) => {
await page.goto('/kicad/eeschema.html');
await waitForEditorReady(page);
await loadRectifier(page);
const simWin = await openSimulator(page);
await runSimulation(page);
const evts = await page.evaluate(() => (window as any).__ngspiceEvents as Array<{
kind: string; lines?: string[]; finished?: boolean; t: number }>);
// Live streaming: console/status output must precede the finish event.
const finishT = evts.filter((e) => e.kind === 'bg' && e.finished).map((e) => e.t)[0];
const streamed = evts.filter(
(e) => (e.kind === 'char' || e.kind === 'stat') && e.t <= finishT);
expect(streamed.length, 'ngspice output streamed during the run')
.toBeGreaterThan(3);
// The model shipped via .include resolved (a miss fails the run with
// "unable to find definition" and produces no transient).
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.
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);
expect(vecPulls, 'plot fetched transient vectors').toBeGreaterThan(0);
// The plot area rendered something beyond a flat background.
const shot = await page.locator(`#${simWin}`).screenshot({
scale: 'css', animations: 'disabled' });
const png = PNG.sync.read(shot);
expect(distinctColors(png), 'plot window shows structure (axes/trace)')
.toBeGreaterThan(6);
await stableShot(page, 'eeschema-sim-plot.png');
const all = [...testLogger.consoleLogs, ...testLogger.errors];
expect(all.filter((l) => l.includes('Aborted(')), 'no aborts').toHaveLength(0);
const corruption = all.filter((l) =>
l.includes('index out of bounds') || l.includes('indirect call to null')
|| l.includes('uncaught exception: unwind'));
expect(corruption, 'no asyncify corruption').toHaveLength(0);
});
test('a second run after the first succeeds (engine reset path)', async ({ page, testLogger }) => {
await page.goto('/kicad/eeschema.html');
await waitForEditorReady(page);
await loadRectifier(page);
await openSimulator(page);
await runSimulation(page);
await runSimulation(page);
const finishCount = await page.evaluate(() =>
((window as any).__ngspiceEvents as Array<{ kind: string; finished?: boolean }>)
.filter((e) => e.kind === 'bg' && e.finished === true).length);
expect(finishCount, 'two completed runs').toBeGreaterThanOrEqual(2);
const all = [...testLogger.consoleLogs, ...testLogger.errors];
expect(all.filter((l) => l.includes('Aborted(')), 'no aborts').toHaveLength(0);
});
});

View file

@ -1,6 +1,7 @@
import { test as base } from '@playwright/test'; import { test as base } from '@playwright/test';
import * as path from 'path'; import * as path from 'path';
import { setupTestLogger, writeTestLogs, TestLogger, MAIN_CANVAS, waitForApp, tryLoadApp, getCanvasBox, KICAD_LOGS_DIR, getTestFileName } from '../e2e/utils/test-utils'; import { setupTestLogger, writeTestLogs, TestLogger, MAIN_CANVAS, waitForApp, tryLoadApp, getCanvasBox, KICAD_LOGS_DIR, getTestFileName } from '../e2e/utils/test-utils';
import { installNgspiceServiceStub } from './utils/ngspice-service';
import { installOccServiceStub } from './utils/occ-service'; import { installOccServiceStub } from './utils/occ-service';
// Extend base test with automatic logging // Extend base test with automatic logging
@ -14,6 +15,10 @@ export const test = base.extend<{
// the lazy-load boundary. // the lazy-load boundary.
page: async ({ page }, use) => { page: async ({ page }, use) => {
await installOccServiceStub(page); await installOccServiceStub(page);
// The ngspice_service provider follows the same ambient pattern (the
// standalone installs it for every kicad_editor boot); the worker is only
// fetched on the first simulator request.
await installNgspiceServiceStub(page);
await use(page); await use(page);
}, },

View file

@ -0,0 +1,176 @@
import { test, expect } from './fixtures';
/**
* Minimal ngspice_service probe (no eeschema boot): drive the sharedspice RPC
* surface directly from the page. Isolates worker/module behavior from the
* editor entirely when the simulator breaks, this answers "service module
* or editor-side bridge?" in seconds. The provider arrives via the fixtures'
* ambient init script (tests/kicad/utils/ngspice-service.ts), which also
* captures every event frame into window.__ngspiceEvents.
*
* Covers Gate 2 of docs/features/ngspice-split/: browser-side parity of the
* Gate-1 node smoke foreground transient numerics, XSPICE via the static
* code-model registry, CIDER, live event streaming during bg_run, mid-run
* bg_halt, and the lazy-load boundary.
*/
type SvcRes = {
ret?: number; error?: string; found?: boolean; length?: number;
real?: number[] | Float64Array | null; name?: string; names?: string[];
running?: boolean;
};
async function svcRequest(page: import('@playwright/test').Page, req: unknown): Promise<SvcRes> {
return await page.evaluate(async (r: any) => {
const res = await (globalThis as any).ngspiceService.request(r);
// Float64Array doesn't survive evaluate serialization on all engines —
// flatten to a plain array (probe vectors are small).
if (res && res.real) res.real = Array.from(res.real);
if (res && res.comp) res.comp = Array.from(res.comp);
return res;
}, req as any);
}
test.describe('ngspice_service probe', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(240000);
test('lazy boundary + RC transient + vector readback', async ({ page }) => {
const ngspiceFetches: string[] = [];
page.on('request', (r) => {
if (r.url().includes('ngspice_service')) ngspiceFetches.push(r.url());
});
// Any harness page gives the COI (COOP/COEP) context; don't wait for wasm.
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
expect(ngspiceFetches, 'ngspice_service must NOT be fetched before first use')
.toHaveLength(0);
const init = await svcRequest(page, { kind: 'init' });
expect(init.error, 'init error').toBeUndefined();
expect(init.ret, 'ngSpice_Init').toBe(0);
expect(ngspiceFetches.length, 'ngspice_service was fetched lazily by init')
.toBeGreaterThan(0);
const circ = await svcRequest(page, {
kind: 'circ',
lines: ['rc probe', 'V1 in 0 1', 'R1 in out 1k', 'C1 out 0 1u',
'.tran 10u 5m', '.end'],
});
expect(circ.ret, 'ngSpice_Circ').toBe(0);
const run = await svcRequest(page, { kind: 'command', cmd: 'run' });
expect(run.ret, 'run command').toBe(0);
const plot = await svcRequest(page, { kind: 'cur_plot' });
expect(plot.name, 'a tran plot exists').toMatch(/^tran/);
const vecs = await svcRequest(page, { kind: 'all_vecs', plot: plot.name! });
expect(vecs.names, 'tran vectors').toContain('out');
const vi = await svcRequest(page, { kind: 'get_vec_info', name: 'out' });
expect(vi.found, 'v(out) found').toBe(true);
expect(vi.length ?? 0, 'plausible point count').toBeGreaterThan(100);
const last = (vi.real as number[])[(vi.length ?? 1) - 1];
// DC operating point seeds the transient at the steady state: flat 1V.
expect(last, 'v(out) end value').toBeGreaterThan(0.98);
expect(last, 'v(out) end value').toBeLessThanOrEqual(1.0);
});
test('XSPICE code model resolves through the static registry', async ({ page }) => {
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
await svcRequest(page, { kind: 'init' });
const circ = await svcRequest(page, {
kind: 'circ',
lines: ['xspice probe', 'V1 in 0 2', 'A1 in aout gainblk',
'.model gainblk gain(gain=3)', 'R1 aout 0 1k', '.op', '.end'],
});
expect(circ.ret, 'circ with a-device').toBe(0);
expect((await svcRequest(page, { kind: 'command', cmd: 'run' })).ret).toBe(0);
const vi = await svcRequest(page, { kind: 'get_vec_info', name: 'aout' });
expect(vi.found, 'v(aout) found').toBe(true);
expect((vi.real as number[])[0], 'gain block output 2*3').toBeCloseTo(6.0, 3);
});
test('CIDER numd device simulates', async ({ page }) => {
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
await svcRequest(page, { kind: 'init' });
const circ = await svcRequest(page, {
kind: 'circ',
lines: ['cider probe - silicon resistor',
'VPP 1 0 2v', 'VNN 2 0 0.0v', 'D1 1 2 M_RES AREA=1',
'.MODEL M_RES numd level=1',
'+ options resistor defa=1p',
'+ x.mesh loc=0.0 num=1', '+ x.mesh loc=1.0 num=21',
'+ domain num=1 material=1', '+ material num=1 silicon',
'+ doping unif n.type conc=2.5e16',
'+ models bgn srh conctau auger concmob fieldmob',
'.DC VPP 0.0v 2.01v 0.5v', '.END'],
});
expect(circ.ret, 'circ with numd model').toBe(0);
expect((await svcRequest(page, { kind: 'command', cmd: 'run' })).ret).toBe(0);
const vi = await svcRequest(page, { kind: 'get_vec_info', name: 'vpp#branch' });
expect(vi.found, 'sweep current vector found').toBe(true);
expect(vi.length ?? 0, 'DC sweep points').toBeGreaterThanOrEqual(4);
const iLast = (vi.real as number[])[(vi.length ?? 1) - 1];
expect(iLast, 'resistor draws current (negative through VPP)').toBeLessThan(0);
expect(Math.abs(iLast), 'plausible magnitude').toBeLessThan(1.0);
});
test('bg_run streams events live and bg_halt stops mid-run', async ({ page, testLogger }) => {
await page.goto('/kicad/eeschema.html', { waitUntil: 'domcontentloaded' });
await svcRequest(page, { kind: 'init' });
// Heavy enough that the halt lands mid-run: 150-stage nonlinear
// RC/diode ladder, 20s transient, storage bounded via .save.
const deck = ['halt probe', 'V1 n0 0 SIN(0 5 10k)'];
for (let i = 0; i < 150; i++) {
deck.push(`R${i + 1} n${i} n${i + 1} 100`);
deck.push(`C${i + 1} n${i + 1} 0 10n`);
deck.push(`D${i + 1} n${i + 1} 0 dmod`);
}
deck.push('.model dmod d(is=1e-14)', '.save v(n150)', '.tran 100n 20', '.end');
expect((await svcRequest(page, { kind: 'circ', lines: deck })).ret).toBe(0);
const evtsBefore = await page.evaluate(
() => (window as any).__ngspiceEvents.length as number);
expect((await svcRequest(page, { kind: 'command', cmd: 'bg_run' })).ret,
'bg_run accepted').toBe(0);
// Live streaming: char/stat frames must arrive WHILE the background
// thread simulates (not only after completion).
await page.waitForFunction((n: number) => {
const evts = (window as any).__ngspiceEvents as Array<{ kind: string }>;
return evts.slice(n).filter((e) => e.kind === 'char' || e.kind === 'stat').length >= 3;
}, evtsBefore, { timeout: 60000 });
const midRunning = await svcRequest(page, { kind: 'running' });
expect(midRunning.running,
'still running while events streamed (deck heavy enough)').toBe(true);
expect((await svcRequest(page, { kind: 'command', cmd: 'bg_halt' })).ret,
'bg_halt accepted').toBe(0);
// BGThreadRunning(finished) must arrive after the halt joins the thread.
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: 60000 });
const after = await svcRequest(page, { kind: 'running' });
expect(after.running, 'stopped after bg_halt').toBe(false);
// Standard corruption gate.
const all = [...testLogger.consoleLogs, ...testLogger.errors];
expect(all.filter((l) => l.includes('Aborted(')), 'no aborts').toHaveLength(0);
});
});

View file

@ -0,0 +1,125 @@
import * as fs from 'fs';
import * as path from 'path';
import type { Page } from '@playwright/test';
/**
* Install a REAL `globalThis.ngspiceService` provider into a harness page
* the same worker-backed ngspice_service boot the standalone app does
* (web/standalone/src/wasm/ngspice-service.ts), minus the CDN manifest
* resolution: the harness serves ngspice_service.{js,wasm} same-origin next
* to the tool page (tests/scripts/setup-kicad-wasm.sh copies them from
* output/).
*
* The worker-side wrapper is the SHARED source of truth
* (web/standalone/src/wasm/ngspice-worker.js the standalone imports it via
* vite `?raw`; the harness reads it off disk and injects it verbatim), so the
* boot logic cannot drift between app and tests.
*
* Additions for assertability:
* - every `{ evt }` frame is appended to window.__ngspiceEvents
* ({ kind, lines?, finished?, status?, t: ms-since-install }) BEFORE being
* 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.
*
* The worker fetches ngspice_service.js lazily on the FIRST request specs
* assert the lazy-load boundary by watching network requests.
*/
const NGSPICE_WORKER_SRC = fs.readFileSync(
path.resolve(__dirname, '..', '..', '..',
'web', 'standalone', 'src', 'wasm', 'ngspice-worker.js'),
'utf8');
export async function installNgspiceServiceStub(page: Page): Promise<void> {
await page.addInitScript((workerSrc: string) => {
if ((globalThis as any).ngspiceService) return;
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;
const evtQueue: any[] = [];
const dispatchEvt = (evt: any) => {
(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);
} else {
evtQueue.push(evt);
}
};
const failAllPending = (why: string) => {
for (const [, resolve] of pending) resolve({ error: why });
pending.clear();
};
const ensureWorker = (): Promise<Worker> => {
if (!workerP) {
workerP = (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(
[`self.NGSPICE_GLUE_URL = ${JSON.stringify(glue)};\n`, workerSrc],
{ type: 'text/javascript' })));
worker.onmessage = (e) => {
const data = e.data ?? {};
if (data.evt) { dispatchEvt(data.evt); return; }
if (typeof data.id !== 'number') return;
const resolve = pending.get(data.id);
if (resolve) { pending.delete(data.id); 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 */ }
};
await new Promise<void>((resolve, reject) => {
const onFirst = (e: MessageEvent) => {
if (e.data?.ready) { worker.removeEventListener('message', onFirst); resolve(); }
else if (e.data?.bootError) reject(new Error(e.data.bootError));
};
worker.addEventListener('message', onFirst);
});
console.log('[TEST-NGSPICE] ngspice_service ready');
return worker;
})().catch((e) => { workerP = null; throw e; });
}
return workerP;
};
const request = async (req: any) => {
let worker: Worker;
try {
worker = await ensureWorker();
} catch (e) {
return { error: `ngspice_service unavailable: ${e}` };
}
const id = nextId++;
const res: any = await new Promise((resolve) => {
pending.set(id, resolve);
worker.postMessage({ id, req });
});
(window as any).__ngspiceLog.push({
kind: req.kind,
cmd: req.cmd,
name: req.name,
ret: res?.ret,
error: res?.error,
length: res?.length,
t: Date.now() - t0,
});
return res;
};
(globalThis as any).ngspiceService = { request };
}, NGSPICE_WORKER_SRC);
}

View file

@ -89,6 +89,8 @@ copy_app pl_editor && found_any=1
copy_app gerbview && found_any=1 copy_app gerbview && found_any=1
# OCC 3D service (lazy worker module; pcbnew's STEP export + model parsing) # OCC 3D service (lazy worker module; pcbnew's STEP export + model parsing)
copy_app occ_service || true copy_app occ_service || true
# ngspice simulation service (lazy worker module; eeschema's simulator)
copy_app ngspice_service || true
if [ "$found_any" -eq 0 ]; then if [ "$found_any" -eq 0 ]; then
echo "Error: no kicad_editor/calculator/pl_editor/gerbview artifacts found in output/ or docker volume" >&2 echo "Error: no kicad_editor/calculator/pl_editor/gerbview artifacts found in output/ or docker volume" >&2

View file

@ -1,17 +1,19 @@
# Findngspice.cmake - Stub for WASM builds # Findngspice.cmake - Stub for WASM builds
# ngspice is not available in WASM builds (KICAD_SPICE=OFF) # The editor never links libngspice: the simulator engine runs in the
# We provide stub values so CMake configuration succeeds # ngspice_service worker (docs/features/ngspice-split/), and eeschema binds to
# the statically linked sharedspice CLIENT (wasm/stubs/sharedspice_client.cpp)
# in NGSPICE::init_dll()'s __EMSCRIPTEN__ branch.
if(EMSCRIPTEN OR NOT KICAD_SPICE) if(EMSCRIPTEN OR NOT KICAD_SPICE)
message(STATUS "ngspice not available for WASM build (using header stub)") message(STATUS "ngspice: WASM build uses the sharedspice client header stub")
# Set variables to indicate ngspice is "found" but disabled # Set variables to indicate ngspice is "found"
set(ngspice_FOUND TRUE) set(ngspice_FOUND TRUE)
set(NGSPICE_FOUND TRUE) set(NGSPICE_FOUND TRUE)
# Point at our header-only stub at wasm/stubs/ngspice/sharedspice.h so # Point at wasm/stubs/ngspice/sharedspice.h: the sharedspice types plus
# eeschema's sim/ngspice.{h,cpp} can compile. The library link line stays # the pcbjam_ngSpice_* client declarations. The library link line stays
# empty the simulator frame is never instantiated in WASM. # empty the engine lives in ngspice_service.wasm.
set(NGSPICE_INCLUDE_DIR "${CMAKE_CURRENT_LIST_DIR}/../stubs") set(NGSPICE_INCLUDE_DIR "${CMAKE_CURRENT_LIST_DIR}/../stubs")
set(NGSPICE_LIBRARY "") set(NGSPICE_LIBRARY "")
set(NGSPICE_LIBRARIES "") set(NGSPICE_LIBRARIES "")

View file

@ -0,0 +1,79 @@
# ngspice_service — ngspice's sharedspice engine as a standalone emscripten
# module run in a dedicated Web Worker (the SPICE analog of wasm/occ-service).
# The eeschema simulator drives it over postMessage RPC via the sharedspice
# client stub (wasm/stubs/sharedspice_client.cpp); the editor bundle links no
# ngspice code at all.
#
# Unlike occ_service this module contains NO KiCad or wxWidgets code, so it is
# NOT hooked into the kicad CMake tree: it configures standalone against the
# sysroot artifacts produced by scripts/deps/build-ngspice.sh (see
# scripts/kicad/build-ngspice_service.sh).
#
# Threading: ngspice's bg_run spawns its own pthread; the module main thread
# stays free to service RPC (bg_halt, status, vector reads) mid-simulation.
# The 4MB/2MB stacks are load-bearing: ngspice's parser overflows emscripten's
# default 64KB stack (found by the Gate-1 smoke; the CIDER deck died parsing
# spinit). ASYNCIFY stays off: nothing in the service suspends.
# PTHREAD_POOL_DELAY_LOAD: boot must not block on the pool — Firefox stalls
# nested-worker spawning under multi-page pressure (the SECOND simulator
# session in an e2e run hung forever in module boot without it); bg_run's
# thread starts as soon as a pool worker lands.
cmake_minimum_required( VERSION 3.20 )
project( ngspice_service CXX )
if( NOT EMSCRIPTEN )
message( FATAL_ERROR "ngspice_service is an emscripten-only target (use emcmake)" )
endif()
if( NOT NGSPICE_SYSROOT )
message( FATAL_ERROR "pass -DNGSPICE_SYSROOT=<build-wasm/sysroot> (run scripts/deps/build-ngspice.sh first)" )
endif()
# The exception model must match the objects in libngspice.a (DEPS_EH_FLAGS
# from scripts/common/env.sh: wasm EH + wasm setjmp/longjmp — sharedspice's
# error recovery longjmps through errbufm/errbufc). Passed by
# scripts/kicad/build-ngspice_service.sh.
if( NOT NGSPICE_EH_FLAGS )
message( FATAL_ERROR "pass -DNGSPICE_EH_FLAGS=\"\${DEPS_EH_FLAGS}\" (see scripts/common/env.sh)" )
endif()
separate_arguments( NGSPICE_EH_FLAGS_LIST UNIX_COMMAND "${NGSPICE_EH_FLAGS}" )
set( NGSPICE_LIB_DIR "${NGSPICE_SYSROOT}/lib" )
set( NGSPICE_CM_DIR "${NGSPICE_SYSROOT}/lib/ngspice" )
add_executable( ngspice_service ngspice_service_main.cpp )
target_include_directories( ngspice_service PRIVATE "${NGSPICE_SYSROOT}/include" )
# The sysroot libngspice.a is built with XSPICE; sharedspice.h guards the
# XSPICE-only declarations (ngCM_Input_Path & co.) behind this define.
target_compile_definitions( ngspice_service PRIVATE XSPICE )
# -pthread at COMPILE stage too (atomics/bulk-memory object features must
# match --shared-memory at link).
target_compile_options( ngspice_service PRIVATE -pthread ${NGSPICE_EH_FLAGS_LIST} )
# The .cm archives resolve through the static code-model registry appended to
# dev.c (its extern table references pull each archive's tables member, which
# pulls that model's cfunc/ifspec objects); ngcm_common.a carries the shared
# dlmain utility tail + tline commons. lld's implicit group semantics handle
# the cm -> core back-references.
target_link_libraries( ngspice_service PRIVATE
"${NGSPICE_LIB_DIR}/libngspice.a"
"${NGSPICE_CM_DIR}/analog.cm"
"${NGSPICE_CM_DIR}/digital.cm"
"${NGSPICE_CM_DIR}/spice2poly.cm"
"${NGSPICE_CM_DIR}/table.cm"
"${NGSPICE_CM_DIR}/tlines.cm"
"${NGSPICE_CM_DIR}/xtradev.cm"
"${NGSPICE_CM_DIR}/xtraevt.cm"
"${NGSPICE_CM_DIR}/ngcm_common.a"
)
# spinit is embedded at the path main() points SPICE_LIB_DIR at, so ngspice's
# own init (incl. the `codemodel .../<cm>.cm` lines the registry resolves by
# basename) runs identically to a native install regardless of the prefix the
# dep build happened to use.
set_target_properties( ngspice_service PROPERTIES
LINK_FLAGS "-O2 -g0 --bind -pthread ${NGSPICE_EH_FLAGS} -sASYNCIFY=0 -sMODULARIZE=1 -sEXPORT_NAME=NgspiceService -sENVIRONMENT=worker,node -sEXIT_RUNTIME=0 -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=256MB -sMAXIMUM_MEMORY=4GB -sPTHREAD_POOL_SIZE=4 -sPTHREAD_POOL_SIZE_STRICT=0 -sPTHREAD_POOL_DELAY_LOAD=1 -sSTACK_SIZE=4MB -sDEFAULT_PTHREAD_STACK_SIZE=2MB --embed-file ${NGSPICE_SYSROOT}/share/ngspice/scripts/spinit@/ngspice/scripts/spinit" )

View file

@ -0,0 +1,309 @@
/*
* ngspice_service worker-side entry points wrapping ngspice's sharedspice
* API for the eeschema simulator (see wasm/ngspice-service/CMakeLists.txt and
* docs/features/ngspice-split/).
*
* RPC surface (embind, called by web/standalone/src/wasm/ngspice-worker.js):
* init() -> int ngSpice_Init with the service callbacks
* circ(lines, files) -> int stage .include files, then ngSpice_Circ
* command(cmd) -> int ngSpice_Command
* getVecInfo(name) -> {found, vname, vtype, flags, length, real, comp}
* curPlot() -> string
* allPlots()/allVecs(p) -> string[]
* running() -> bool
* cmInputPath(path) -> void
*
* Event stream: sharedspice callbacks fire on ngspice's background pthread
* during bg_run (and synchronously on this module's main thread during
* commands). Every callback is forwarded with MAIN_THREAD_ASYNC_EM_ASM to
* Module.ngspiceEmit the per-target FIFO of emscripten's proxying queue
* preserves order, and the main thread is idle while a bg simulation runs, so
* the queue drains promptly. The worker wrapper batches char/stat lines and
* postMessages { evt } frames to the editor-side provider.
*
* KiCad parity notes: SendData/SendInitData are nullptr exactly like
* eeschema's NGSPICE::init_dll (vectors are pulled after the run); vector
* copies happen under ngSpice_LockRealloc so mid-run plot refresh cannot race
* the growing simulation vectors (this replaces KiCad's client-side lock,
* which is a no-op across the RPC boundary).
*/
#include <atomic>
#include <cerrno>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include <sys/stat.h>
#include <sys/types.h>
#include <emscripten.h>
#include <emscripten/bind.h>
#include "ngspice/sharedspice.h"
using namespace emscripten;
namespace
{
enum EvtKind
{
EVT_CHAR = 0, // SendChar console line
EVT_STAT = 1, // SendStat status line
EVT_BG = 2, // BGThreadRunning: a = 1 when finished/not-running
EVT_EXIT = 3, // ControlledExit: a = status, b = immediate|quit<<1
};
// Safe from any thread. The string is strdup'd and freed by the proxied JS
// after conversion (the pointer must outlive the async hop).
void emitEvt( int aKind, const char* aText, int aA, int aB )
{
char* copy = aText ? strdup( aText ) : nullptr;
MAIN_THREAD_ASYNC_EM_ASM(
{
var s = $1 ? UTF8ToString( $1 ) : null;
if( $1 )
_free( $1 );
if( Module.ngspiceEmit )
Module.ngspiceEmit( $0, s, $2, $3 );
},
aKind, copy, aA, aB );
}
int cbSendChar( char* aWhat, int, void* )
{
emitEvt( EVT_CHAR, aWhat, 0, 0 );
return 0;
}
int cbSendStat( char* aWhat, int, void* )
{
emitEvt( EVT_STAT, aWhat, 0, 0 );
return 0;
}
int cbControlledExit( int aStatus, NG_BOOL aImmediate, NG_BOOL aQuit, int, void* )
{
emitEvt( EVT_EXIT, nullptr, aStatus, ( aImmediate ? 1 : 0 ) | ( aQuit ? 2 : 0 ) );
return 0;
}
int cbBGThreadRunning( NG_BOOL aNotRunning, int, void* )
{
emitEvt( EVT_BG, nullptr, aNotRunning ? 1 : 0, 0 );
return 0;
}
std::atomic<bool> s_inited{ false };
int svcInit()
{
if( s_inited.load() )
return 0;
int ret = ngSpice_Init( cbSendChar, cbSendStat, cbControlledExit,
nullptr, nullptr, cbBGThreadRunning, nullptr );
if( ret == 0 )
s_inited.store( true );
return ret;
}
void mkdirRecursive( const std::string& aDir )
{
std::string partial;
for( size_t i = 0; i < aDir.size(); i++ )
{
partial += aDir[i];
if( aDir[i] == '/' && partial.size() > 1 )
::mkdir( partial.c_str(), 0777 );
}
if( !partial.empty() )
::mkdir( partial.c_str(), 0777 );
}
// lines: string[]; files: [{ path, text }] — netlist-referenced .include /
// .lib files read out of the editor's MEMFS, staged here at identical
// absolute paths so ngspice's own file access resolves them.
int svcCirc( val aLines, val aFiles )
{
const unsigned nFiles = aFiles["length"].as<unsigned>();
for( unsigned i = 0; i < nFiles; i++ )
{
val f = aFiles[i];
std::string path = f["path"].as<std::string>();
std::string text = f["text"].as<std::string>();
size_t slash = path.find_last_of( '/' );
if( slash != std::string::npos && slash > 0 )
mkdirRecursive( path.substr( 0, slash ) );
FILE* fp = fopen( path.c_str(), "w" );
if( !fp )
{
fprintf( stderr, "[ngspice_service] cannot stage %s: %s\n",
path.c_str(), strerror( errno ) );
return 1;
}
fwrite( text.data(), 1, text.size(), fp );
fclose( fp );
}
const unsigned n = aLines["length"].as<unsigned>();
std::vector<std::string> strs;
strs.reserve( n );
for( unsigned i = 0; i < n; i++ )
strs.push_back( aLines[i].as<std::string>() );
// ngSpice_Circ wants a NULL-terminated char* array; it copies the lines.
std::vector<char*> arr( n + 1 );
for( unsigned i = 0; i < n; i++ )
arr[i] = const_cast<char*>( strs[i].c_str() );
arr[n] = nullptr;
return ngSpice_Circ( arr.data() );
}
int svcCommand( std::string aCmd )
{
return ngSpice_Command( const_cast<char*>( aCmd.c_str() ) );
}
val svcGetVecInfo( std::string aName )
{
val out = val::object();
// Persistent copy buffers: the returned typed_memory_views stay valid
// until the next call; the worker structured-clones them into fresh
// arrays before posting (a SAB-backed view cannot be transferred).
static std::vector<double> s_realBuf;
static std::vector<double> s_compBuf;
ngSpice_LockRealloc();
pvector_info vi = ngGet_Vec_Info( const_cast<char*>( aName.c_str() ) );
if( !vi )
{
ngSpice_UnlockRealloc();
out.set( "found", false );
return out;
}
out.set( "found", true );
out.set( "vname", std::string( vi->v_name ? vi->v_name : "" ) );
out.set( "vtype", vi->v_type );
out.set( "flags", (int) vi->v_flags );
out.set( "length", vi->v_length );
if( vi->v_realdata && vi->v_length > 0 )
{
s_realBuf.assign( vi->v_realdata, vi->v_realdata + vi->v_length );
out.set( "real", val( typed_memory_view( s_realBuf.size(), s_realBuf.data() ) ) );
}
else
{
out.set( "real", val::null() );
}
if( vi->v_compdata && vi->v_length > 0 )
{
s_compBuf.resize( (size_t) vi->v_length * 2 );
for( int i = 0; i < vi->v_length; i++ )
{
s_compBuf[(size_t) i * 2] = vi->v_compdata[i].cx_real;
s_compBuf[(size_t) i * 2 + 1] = vi->v_compdata[i].cx_imag;
}
out.set( "comp", val( typed_memory_view( s_compBuf.size(), s_compBuf.data() ) ) );
}
else
{
out.set( "comp", val::null() );
}
ngSpice_UnlockRealloc();
return out;
}
std::string svcCurPlot()
{
char* name = ngSpice_CurPlot();
return name ? name : "";
}
val svcAllPlots()
{
val out = val::array();
char** names = ngSpice_AllPlots();
for( int i = 0; names && names[i]; i++ )
out.call<void>( "push", std::string( names[i] ) );
return out;
}
val svcAllVecs( std::string aPlot )
{
val out = val::array();
char** names = ngSpice_AllVecs( const_cast<char*>( aPlot.c_str() ) );
for( int i = 0; names && names[i]; i++ )
out.call<void>( "push", std::string( names[i] ) );
return out;
}
bool svcRunning()
{
return ngSpice_running();
}
void svcCmInputPath( std::string aPath )
{
ngCM_Input_Path( aPath.empty() ? nullptr : aPath.c_str() );
}
} // namespace
EMSCRIPTEN_BINDINGS( ngspice_service )
{
function( "init", &svcInit );
function( "circ", &svcCirc );
function( "command", &svcCommand );
function( "getVecInfo", &svcGetVecInfo );
function( "curPlot", &svcCurPlot );
function( "allPlots", &svcAllPlots );
function( "allVecs", &svcAllVecs );
function( "running", &svcRunning );
function( "cmInputPath", &svcCmInputPath );
}
int main()
{
// ngspice resolves spinit via $SPICE_LIB_DIR/scripts/spinit before the
// compiled-in NGSPICEDATADIR (whose baked host path is meaningless in
// MEMFS); the CMake link embeds spinit at this fixed location.
setenv( "SPICE_LIB_DIR", "/ngspice", 1 );
fprintf( stderr, "[ngspice_service] ready\n" );
// EXIT_RUNTIME=0: the module stays alive for embind calls from onmessage.
return 0;
}

View file

@ -1,28 +0,0 @@
/*
* Empty replacements for the four largest ngspice model data initializers.
*
* Each of sim_model_ngspice_data_{bsim4,b3soi,b4soi,hsim}.cpp defines a
* single function (addBSIM4/addB3SOI/addB4SOI/addHSIM) that pushes hundreds
* of entries into NGSPICE_MODEL_INFO_MAP::modelInfos[...]. Once compiled to
* WASM these functions exceed the V8/SpiderMonkey limit on locals per
* function ("too many locals"), so Firefox refuses to instantiate the
* resulting module.
*
* The simulator UI is never reachable in the WASM build (FRAME_SIMULATOR
* fails to instantiate via the ngspice header stub at
* wasm/stubs/ngspice/sharedspice.h), so leaving these tables empty is safe.
*
* eeschema/CMakeLists.txt excludes the original four sources from
* EESCHEMA_SIM_SRCS for EMSCRIPTEN and adds this file instead.
*/
#ifdef __EMSCRIPTEN__
#include <sim/sim_model_ngspice.h>
void NGSPICE_MODEL_INFO_MAP::addBSIM4() {}
void NGSPICE_MODEL_INFO_MAP::addB3SOI() {}
void NGSPICE_MODEL_INFO_MAP::addB4SOI() {}
void NGSPICE_MODEL_INFO_MAP::addHSIM() {}
#endif // __EMSCRIPTEN__

View file

@ -1,10 +1,12 @@
/* /*
* Minimal stub of ngspice's sharedspice.h for KiCad WASM builds. * Minimal stub of ngspice's sharedspice.h for KiCad WASM builds.
* *
* Only the type names referenced by kicad/eeschema/sim/ngspice.{h,cpp} need * Provides the type names referenced by kicad/eeschema/sim/ngspice.{h,cpp}
* to exist. The eeschema sim layer compiles but the simulator frame is never * plus the declarations of the sharedspice CLIENT (sharedspice_client.cpp):
* instantiated in WASM (FRAME_SIMULATOR's try/catch in IFACE::CreateKiWindow * the real engine runs in the ngspice_service worker
* catches the init failure and returns nullptr). * (docs/features/ngspice-split/), and NGSPICE::init_dll()'s __EMSCRIPTEN__
* branch binds its function pointers to the pcbjam_ngSpice_* forwarders
* declared below instead of dlopen'ing libngspice.
* *
* We intentionally do NOT define NGSPICE_PACKAGE_VERSION so that ngspice.h's * We intentionally do NOT define NGSPICE_PACKAGE_VERSION so that ngspice.h's
* fallback `typedef bool NG_BOOL;` (line 46) provides the boolean type. * fallback `typedef bool NG_BOOL;` (line 46) provides the boolean type.
@ -48,6 +50,23 @@ typedef int (SendData)(pvecvaluesall, int, int, void*);
typedef int (SendInitData)(pvecinfoall, int, void*); typedef int (SendInitData)(pvecinfoall, int, void*);
typedef int (BGThreadRunning)(bool, int, void*); typedef int (BGThreadRunning)(bool, int, void*);
/*
* The sharedspice client (wasm/stubs/sharedspice_client.cpp): RPC forwarders
* to the ngspice_service worker, signature-compatible with NGSPICE's private
* function-pointer typedefs (the pcbjam_ prefix avoids shadowing by those
* class-scope typedef names inside init_dll).
*/
void pcbjam_ngSpice_Init(SendChar*, SendStat*, ControlledExit*, SendData*,
SendInitData*, BGThreadRunning*, void*);
int pcbjam_ngSpice_Circ(char** circarray);
int pcbjam_ngSpice_Command(char* command);
pvector_info pcbjam_ngGet_Vec_Info(char* vecname);
char* pcbjam_ngCM_Input_Path(const char* path);
char* pcbjam_ngSpice_CurPlot(void);
char** pcbjam_ngSpice_AllPlots(void);
char** pcbjam_ngSpice_AllVecs(char* plotname);
bool pcbjam_ngSpice_Running(void);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif

View file

@ -0,0 +1,490 @@
/*
* sharedspice client stub the editor side of the ngspice_service split
* (docs/features/ngspice-split/; the SPICE analog of exporter_step_stub.cpp).
*
* eeschema's NGSPICE class normally dlopens libngspice and binds ~10 function
* pointers; in WASM the simulator engine lives in a separate worker module
* (wasm/ngspice-service/), and NGSPICE::init_dll()'s __EMSCRIPTEN__ branch
* binds its pointers to the pcbjam_ngSpice_* functions here instead. Each
* forwards over the `globalThis.ngspiceService` provider
* (web/standalone/src/wasm/ngspice-service.ts) via EM_ASYNC_JS the editor
* suspends through Asyncify while the worker answers (the `__asyncjs__*`
* import is auto-covered by scripts/common/asyncify-imports.txt).
*
* Callbacks: KiCad registers its cbSendChar/cbSendStat/cbControlledExit/
* cbBGThreadRunning with pcbjam_ngSpice_Init; the worker streams `{ evt }`
* frames which the provider hands to `globalThis.__ngspiceOnEvent` (installed
* here). The dispatcher calls the exported pcbjam_ngspice_event a fresh
* WASM entry from JS, safe while the main C++ stack is Asyncify-suspended
* (the wx-dom DOM-event mechanism, wxwidgets/src/wasm/domevents.cpp); KiCad's
* callbacks only take a mutex and wxQueueEvent, so nothing on this path can
* suspend.
*
* ngSpice_running stays cheap: a client-side atomic mirror maintained from
* command results and bg events the simulator UI polls it on a refresh
* timer and an RPC per poll would be pure overhead.
*
* Netlist file shipping: NETLIST_EXPORTER_SPICE emits `.include "<abs path>"`
* lines (Sim.Library models, the IBIS cache) that ngspice opens from ITS
* filesystem pcbjam_ngSpice_Circ scans the deck, reads those files from the
* editor MEMFS (recursively, bounded), and ships them with the circ request
* so the service stages them at identical paths.
*/
#ifdef __EMSCRIPTEN__
#include <atomic>
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <set>
#include <string>
#include <vector>
#include <emscripten.h>
#include <nlohmann/json.hpp>
#include <ngspice/sharedspice.h>
using nlohmann::json;
// -------------------------------------------------------------------------
// JS bridges
// -------------------------------------------------------------------------
// Generic request: JSON in, JSON out (malloc'd; caller frees). Vector data
// never travels this path — see js_ngspice_get_vec.
// clang-format off
EM_ASYNC_JS( char*, js_ngspice_request, ( const char* aReqJson ), {
let res;
try {
const svc = globalThis.ngspiceService;
if( !svc )
res = { error: 'ngspiceService provider not installed' };
else
res = await svc.request( JSON.parse( UTF8ToString( aReqJson ) ) );
} catch( e ) {
res = { error: String( e ) };
}
const s = JSON.stringify( res ?? {} );
const n = lengthBytesUTF8( s ) + 1;
const p = _malloc( n );
stringToUTF8( s, p, n );
return p;
} );
// Vector fetch: fills editor-heap buffers directly (no JSON for MB arrays).
// aMeta: int[4] = { found, vtype, flags, length }; aReal/aComp receive
// malloc'd double buffers (comp interleaved re,im — the ngcomplex_t layout);
// aVName receives a malloc'd name string. Returns non-zero on transport error.
EM_ASYNC_JS( int, js_ngspice_get_vec,
( const char* aName, int* aMeta, double** aReal, double** aComp, char** aVName ), {
let res;
try {
const svc = globalThis.ngspiceService;
res = svc ? await svc.request( { kind: 'get_vec_info', name: UTF8ToString( aName ) } )
: { error: 'ngspiceService provider not installed' };
} catch( e ) {
res = { error: String( e ) };
}
HEAP32[aMeta >> 2] = 0;
HEAPU32[aReal >> 2] = 0;
HEAPU32[aComp >> 2] = 0;
HEAPU32[aVName >> 2] = 0;
if( !res || res.error )
return 1;
if( !res.found )
return 0;
HEAP32[( aMeta >> 2 ) + 1] = res.vtype | 0;
HEAP32[( aMeta >> 2 ) + 2] = res.flags | 0;
HEAP32[( aMeta >> 2 ) + 3] = res.length | 0;
if( res.real && res.real.length ) {
const p = _malloc( res.real.length * 8 );
HEAPF64.set( res.real, p >> 3 );
HEAPU32[aReal >> 2] = p;
}
if( res.comp && res.comp.length ) {
const p = _malloc( res.comp.length * 8 );
HEAPF64.set( res.comp, p >> 3 );
HEAPU32[aComp >> 2] = p;
}
const s = res.vname || '';
const n = lengthBytesUTF8( s ) + 1;
const vp = _malloc( n );
stringToUTF8( s, vp, n );
HEAPU32[aVName >> 2] = vp;
HEAP32[aMeta >> 2] = 1;
return 0;
} );
// 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.
EM_JS( void, js_ngspice_install_events, (), {
if( globalThis.__ngspiceOnEvent )
return;
globalThis.__ngspiceOnEvent = ( evt ) => {
const call = ( kind, text, a, b ) => {
let p = 0;
if( text != null ) {
const n = lengthBytesUTF8( text ) + 1;
p = _malloc( n );
stringToUTF8( text, p, n );
}
Module._pcbjam_ngspice_event( kind, p, a | 0, b | 0 );
};
if( evt.kind === 'char' || evt.kind === 'stat' ) {
for( const line of evt.lines || [] )
call( evt.kind === 'char' ? 0 : 1, line, 0, 0 );
} else if( evt.kind === 'bg' ) {
call( 2, null, evt.finished ? 1 : 0, 0 );
} else if( evt.kind === 'exit' ) {
call( 3, null, evt.status | 0,
( evt.immediate ? 1 : 0 ) | ( evt.quit ? 2 : 0 ) );
}
};
} );
// clang-format on
// -------------------------------------------------------------------------
// Client state
// -------------------------------------------------------------------------
namespace
{
SendChar* s_sendChar = nullptr;
SendStat* s_sendStat = nullptr;
ControlledExit* s_controlledExit = nullptr;
BGThreadRunning* s_bgThreadRunning = nullptr;
void* s_user = nullptr;
// Mirror of the service's bg-run state (see header comment).
std::atomic<bool> s_bgRunning{ false };
json rpc( const json& aReq )
{
char* raw = js_ngspice_request( aReq.dump().c_str() );
json res = json::parse( raw ? raw : "{}", nullptr, /* allow_exceptions */ false );
std::free( raw );
if( res.is_discarded() )
res = json::object();
if( res.contains( "error" ) )
{
fprintf( stderr, "[sharedspice_client] %s: %s\n",
aReq.value( "kind", "?" ).c_str(),
res["error"].dump().c_str() );
}
return res;
}
// Read an editor-MEMFS file; returns false if it doesn't exist.
bool readFile( const std::string& aPath, std::string* aOut )
{
FILE* fp = fopen( aPath.c_str(), "rb" );
if( !fp )
return false;
fseek( fp, 0, SEEK_END );
long size = ftell( fp );
fseek( fp, 0, SEEK_SET );
aOut->resize( size > 0 ? (size_t) size : 0 );
if( size > 0 && fread( aOut->data(), 1, (size_t) size, fp ) != (size_t) size )
{
fclose( fp );
return false;
}
fclose( fp );
return true;
}
// Extract the file path from a `.include "<path>"` / `.inc` / `.lib "<path>"
// [section]` deck line; empty if the line is not an include directive.
std::string includePathFromLine( const std::string& aLine )
{
size_t i = 0;
while( i < aLine.size() && isspace( (unsigned char) aLine[i] ) )
i++;
if( i >= aLine.size() || aLine[i] != '.' )
return std::string();
size_t wordEnd = i;
while( wordEnd < aLine.size() && !isspace( (unsigned char) aLine[wordEnd] ) )
wordEnd++;
std::string word = aLine.substr( i, wordEnd - i );
for( char& c : word )
c = (char) tolower( (unsigned char) c );
if( word != ".include" && word != ".inc" && word != ".lib" )
return std::string();
size_t p = wordEnd;
while( p < aLine.size() && isspace( (unsigned char) aLine[p] ) )
p++;
if( p >= aLine.size() )
return std::string();
if( aLine[p] == '"' || aLine[p] == '\'' )
{
char quote = aLine[p++];
size_t end = aLine.find( quote, p );
return end == std::string::npos ? std::string() : aLine.substr( p, end - p );
}
size_t end = p;
while( end < aLine.size() && !isspace( (unsigned char) aLine[end] ) )
end++;
return aLine.substr( p, end - p );
}
// Collect the deck's referenced model files (recursively — a shipped library
// may itself .include others), bounded against cycles and runaway depth.
void collectIncludeFiles( const std::vector<std::string>& aLines, json* aFiles,
std::set<std::string>* aSeen, int aDepth )
{
if( aDepth > 4 )
return;
for( const std::string& line : aLines )
{
std::string path = includePathFromLine( line );
if( path.empty() || aSeen->count( path ) )
continue;
aSeen->insert( path );
std::string text;
if( !readFile( path, &text ) )
continue; // ngspice will report the miss with its native error
aFiles->push_back( { { "path", path }, { "text", text } } );
std::vector<std::string> nested;
size_t start = 0;
while( start <= text.size() )
{
size_t nl = text.find( '\n', start );
if( nl == std::string::npos )
{
nested.push_back( text.substr( start ) );
break;
}
nested.push_back( text.substr( start, nl - start ) );
start = nl + 1;
}
collectIncludeFiles( nested, aFiles, aSeen, aDepth + 1 );
}
}
} // namespace
// -------------------------------------------------------------------------
// Event entry from JS (fresh wasm entry; must never suspend)
// -------------------------------------------------------------------------
extern "C" EMSCRIPTEN_KEEPALIVE void pcbjam_ngspice_event( int aKind, char* aText, int aA, int aB )
{
switch( aKind )
{
case 0: // char
if( s_sendChar )
s_sendChar( aText ? aText : const_cast<char*>( "" ), 0, s_user );
break;
case 1: // stat
if( s_sendStat )
s_sendStat( aText ? aText : const_cast<char*>( "" ), 0, s_user );
break;
case 2: // bg: aA = finished
s_bgRunning.store( aA == 0 );
if( s_bgThreadRunning )
s_bgThreadRunning( aA != 0, 0, s_user );
break;
case 3: // exit: aA = status, aB = immediate|quit<<1
s_bgRunning.store( false );
if( s_controlledExit )
s_controlledExit( aA, ( aB & 1 ) != 0, ( aB & 2 ) != 0, 0, s_user );
break;
}
std::free( aText );
}
// -------------------------------------------------------------------------
// The sharedspice API surface NGSPICE::init_dll binds to
// -------------------------------------------------------------------------
void pcbjam_ngSpice_Init( SendChar* aSendChar, SendStat* aSendStat, ControlledExit* aExit,
SendData*, SendInitData*, BGThreadRunning* aBgRunning, void* aUser )
{
s_sendChar = aSendChar;
s_sendStat = aSendStat;
s_controlledExit = aExit;
s_bgThreadRunning = aBgRunning;
s_user = aUser;
js_ngspice_install_events();
// Boots the worker lazily; a failure surfaces on the first command too
// (KiCad ignores ngSpice_Init's status, matching its native call).
rpc( { { "kind", "init" } } );
}
int pcbjam_ngSpice_Circ( char** aCircArray )
{
std::vector<std::string> lines;
for( int i = 0; aCircArray && aCircArray[i]; i++ )
lines.emplace_back( aCircArray[i] );
json files = json::array();
std::set<std::string> seen;
collectIncludeFiles( lines, &files, &seen, 0 );
json req = { { "kind", "circ" }, { "lines", lines }, { "files", std::move( files ) } };
return rpc( req ).value( "ret", 1 );
}
int pcbjam_ngSpice_Command( char* aCommand )
{
std::string cmd = aCommand ? aCommand : "";
int ret = rpc( { { "kind", "command" }, { "cmd", cmd } } ).value( "ret", 1 );
// The bg 'started' event arrives asynchronously; flip the mirror at the
// acceptance edge so an immediate IsRunning() poll already sees it.
if( ret == 0 && cmd.rfind( "bg_run", 0 ) == 0 )
s_bgRunning.store( true );
return ret;
}
pvector_info pcbjam_ngGet_Vec_Info( char* aVecName )
{
// Per-call arena: valid until the next call, matching every NGSPICE
// consumer (they copy within the same call).
static vector_info s_vi;
static char* s_name = nullptr;
static double* s_real = nullptr;
static double* s_comp = nullptr;
std::free( s_name );
std::free( s_real );
std::free( s_comp );
s_name = nullptr;
s_real = nullptr;
s_comp = nullptr;
int meta[4] = { 0, 0, 0, 0 };
double* real = nullptr;
double* comp = nullptr;
char* vname = nullptr;
if( js_ngspice_get_vec( aVecName ? aVecName : "", meta, &real, &comp, &vname ) != 0 )
return nullptr;
if( !meta[0] )
return nullptr;
s_name = vname;
s_real = real;
s_comp = comp;
s_vi.v_name = s_name;
s_vi.v_type = meta[1];
s_vi.v_flags = (short) meta[2];
s_vi.v_length = meta[3];
s_vi.v_realdata = s_real;
// Interleaved re,im doubles ARE the ngcomplex_t array layout.
s_vi.v_compdata = reinterpret_cast<ngcomplex_t*>( s_comp );
return &s_vi;
}
char* pcbjam_ngSpice_CurPlot( void )
{
static std::string s_plot;
s_plot = rpc( { { "kind", "cur_plot" } } ).value( "name", "" );
return s_plot.data();
}
namespace
{
// Shared marshalling for the two NULL-terminated string-array calls.
char** stringArrayResult( const json& aRes )
{
static std::vector<std::string> s_store;
static std::vector<char*> s_ptrs;
s_store.clear();
s_ptrs.clear();
if( aRes.contains( "names" ) && aRes["names"].is_array() )
{
for( const auto& n : aRes["names"] )
s_store.push_back( n.get<std::string>() );
}
for( std::string& s : s_store )
s_ptrs.push_back( s.data() );
s_ptrs.push_back( nullptr );
return s_ptrs.data();
}
} // namespace
char** pcbjam_ngSpice_AllPlots( void )
{
return stringArrayResult( rpc( { { "kind", "all_plots" } } ) );
}
char** pcbjam_ngSpice_AllVecs( char* aPlotName )
{
return stringArrayResult(
rpc( { { "kind", "all_vecs" }, { "plot", aPlotName ? aPlotName : "" } } ) );
}
bool pcbjam_ngSpice_Running( void )
{
return s_bgRunning.load();
}
char* pcbjam_ngCM_Input_Path( const char* aPath )
{
static std::string s_path;
s_path = aPath ? aPath : "";
rpc( { { "kind", "cm_input_path" }, { "path", s_path } } );
return s_path.data();
}
#endif // __EMSCRIPTEN__

View file

@ -12,6 +12,7 @@ import {
} from "./constants"; } from "./constants";
import { installModel3dHandler } from "./libs/models-bridge"; import { installModel3dHandler } from "./libs/models-bridge";
import type { Model3dSource } from "./libs/models-source"; import type { Model3dSource } from "./libs/models-source";
import { installNgspiceService } from "./ngspice-service";
import { installOccService } from "./occ-service"; import { installOccService } from "./occ-service";
import { import {
buildFpLibTable, buildFpLibTable,
@ -262,6 +263,9 @@ async function doBoot(opts: BootOptions): Promise<void> {
// set; the worker itself is only fetched lazily on first use. // set; the worker itself is only fetched lazily on first use.
if (bundle === "kicad_editor") { if (bundle === "kicad_editor") {
installOccService(log); installOccService(log);
// ngspice service (eeschema simulator): same lazy pattern — synchronous
// provider install here, worker fetched on first Inspect → Simulator.
installNgspiceService(log);
} }
if (libsSource && libKinds.length) { if (libsSource && libKinds.length) {

View file

@ -50,7 +50,11 @@ export type Bundle =
// Headless lazy OCC worker module (docs/features/occ-split/) — fetched by the // Headless lazy OCC worker module (docs/features/occ-split/) — fetched by the
// occService provider on first STEP export / STEP-IGES model parse; backs no // occService provider on first STEP export / STEP-IGES model parse; backs no
// tool/route of its own. // tool/route of its own.
| "occ_service"; | "occ_service"
// Headless lazy ngspice worker module (docs/features/ngspice-split/) —
// fetched by the ngspiceService provider when eeschema's simulator first
// initializes; backs no tool/route of its own.
| "ngspice_service";
/** /**
* Which deployed WASM bundle actually backs each tool. The four editors share the * Which deployed WASM bundle actually backs each tool. The four editors share the

View file

@ -0,0 +1,191 @@
// The worker-side wrapper as text (vite ?raw): one shared source of truth,
// also injected by the e2e harness stub (tests/kicad/utils/ngspice-service.ts).
import ngspiceWorkerSource from "./ngspice-worker.js?raw";
import { resolveWasmBase } from "./wasm-assets";
/**
* `globalThis.ngspiceService` the lazy ngspice simulation service provider
* (docs/features/ngspice-split/; the SPICE analog of occ-service.ts).
*
* kicad_editor.wasm carries no ngspice: eeschema's NGSPICE class binds to the
* sharedspice client stub (wasm/stubs/sharedspice_client.cpp), whose
* EM_ASYNC_JS bridges suspend the editor and land here. The ngspice_service
* module (own emscripten instance, pthreads, `-sASYNCIFY=0`) boots in a
* dedicated Worker on the FIRST request a session that never opens the
* simulator never fetches it.
*
* Requests mirror the sharedspice API 1:1 (init/circ/command/get_vec_info/
* cur_plot/all_plots/all_vecs/running/cm_input_path). The worker additionally
* streams `{ evt }` frames (batched SendChar/SendStat lines, BGThreadRunning
* transitions, ControlledExit) which are handed to
* `globalThis.__ngspiceOnEvent` installed by the client stub at first init;
* frames arriving earlier are queued.
*
* A worker death (hard ngspice crash wasm has no SIGSEGV recovery) settles
* every in-flight request with { error } and resets the boot promise: KiCad's
* normal error path (`m_error` `NGSPICE::validate()` re-init) then
* transparently boots a FRESH worker. That worker-restart isolation is the
* whole reason the simulator lives out-of-process.
*/
export interface NgspiceEvent {
kind: "char" | "stat" | "bg" | "exit";
lines?: string[];
finished?: boolean;
status?: number;
immediate?: boolean;
quit?: boolean;
}
export type NgspiceRequest =
| { kind: "init" }
| { kind: "circ"; lines: string[]; files?: { path: string; text: string }[] }
| { kind: "command"; cmd: string }
| { kind: "get_vec_info"; name: string }
| { kind: "cur_plot" }
| { kind: "all_plots" }
| { kind: "all_vecs"; plot: string }
| { kind: "running" }
| { kind: "cm_input_path"; path: string };
// Response shape depends on the request kind; `error` is set on any failure
// (including worker death).
export interface NgspiceResponse {
ret?: number;
found?: boolean;
vname?: string;
vtype?: number;
flags?: number;
length?: number;
real?: Float64Array | null;
comp?: Float64Array | null;
name?: string;
names?: string[];
running?: boolean;
ok?: boolean;
error?: string;
}
declare global {
// eslint-disable-next-line no-var
var ngspiceService:
| { request(req: NgspiceRequest): Promise<NgspiceResponse> }
| undefined;
// eslint-disable-next-line no-var
var __ngspiceOnEvent: ((evt: NgspiceEvent) => void) | undefined;
}
/** Worker blob parts: prelude with the glue URL + the shared wrapper source. */
export function ngspiceWorkerBlobParts(glueHref: string): string[] {
return [
`self.NGSPICE_GLUE_URL = ${JSON.stringify(glueHref)};\n`,
ngspiceWorkerSource,
];
}
export function installNgspiceService(log: (msg: string) => void): void {
if (globalThis.ngspiceService) return;
let nextId = 1;
const pending = new Map<number, (res: NgspiceResponse) => void>();
let workerP: Promise<Worker> | 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) => {
const handler = globalThis.__ngspiceOnEvent;
if (handler) {
while (evtQueue.length) handler(evtQueue.shift()!);
handler(evt);
} else {
evtQueue.push(evt);
}
};
const failAllPending = (why: string) => {
for (const [, resolve] of pending) resolve({ error: why });
pending.clear();
};
const ensureWorker = (): Promise<Worker> => {
if (!workerP) {
workerP = (async () => {
const base = await resolveWasmBase("ngspice_service");
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" }),
),
);
worker.onmessage = (e) => {
const data = e.data ?? {};
if (data.evt) {
dispatchEvt(data.evt as NgspiceEvent);
return;
}
if (typeof data.id !== "number") return;
const resolve = pending.get(data.id);
if (resolve) {
pending.delete(data.id);
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 */
}
};
await new Promise<void>((resolve, reject) => {
const onFirst = (e: MessageEvent) => {
if (e.data?.ready) {
worker.removeEventListener("message", onFirst);
resolve();
} else if (e.data?.bootError) {
reject(new Error(e.data.bootError));
}
};
worker.addEventListener("message", onFirst);
});
log("[ngspice] ngspice_service ready");
return worker;
})().catch((e) => {
workerP = null; // a failed boot must stay retryable
throw e;
});
}
return workerP;
};
const request = async (req: NgspiceRequest): Promise<NgspiceResponse> => {
let worker: Worker;
try {
worker = 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 });
});
};
globalThis.ngspiceService = { request };
log("[ngspice] ngspice_service provider installed (lazy)");
}

View file

@ -0,0 +1,141 @@
/*
* Worker-side wrapper for the ngspice_service MODULARIZE module the SINGLE
* source of truth for the worker boot, shared verbatim by:
* - the standalone app provider (ngspice-service.ts, vite `?raw` import), and
* - the e2e harness stub (tests/kicad/utils/ngspice-service.ts, read off disk).
*
* The host prepends one prelude line to the blob before this file's content:
* self.NGSPICE_GLUE_URL = "<absolute URL of ngspice_service.js>";
*
* Protocol (docs/features/ngspice-split/):
* host -> worker { id, req } req.kind: init | circ | command |
* get_vec_info | cur_plot | all_plots |
* all_vecs | running | cm_input_path
* worker -> host { id, res }
* worker -> host { evt } unsolicited event stream:
* { evt: { kind: "char"|"stat", lines: [...] } } batched console/status
* { evt: { kind: "bg", finished: bool } } BGThreadRunning
* { evt: { kind: "exit", status, immediate, quit } } ControlledExit
* boot: one-shot { ready: true } | { bootError }.
*/
const GLUE = self.NGSPICE_GLUE_URL;
self.addEventListener("error", (e) =>
console.error("[ngspice_service] worker error:", e.message, e.filename, e.lineno));
self.addEventListener("unhandledrejection", (e) =>
console.error("[ngspice_service] unhandled rejection:", e.reason));
importScripts(GLUE);
const modP = NgspiceService({
onAbort: (what) => console.error("[ngspice_service] ABORT:", what),
// Same blob-importScripts trick as boot.ts / occ-worker.js: the module's own
// pthread children (ngspice's bg_run thread) must boot from a same-origin
// script even when the glue lives on a CDN.
mainScriptUrlOrBlob: new Blob(
["importScripts(" + JSON.stringify(GLUE) + ");"],
{ type: "text/javascript" }),
// A blob: worker has no http base URL — absolutize every asset path against
// the glue's URL or the .wasm fetch dies with "Failed to parse URL".
locateFile: (f) => new URL(f, GLUE).href,
print: (s) => console.log("[ngspice_service]", s),
printErr: (s) => console.warn("[ngspice_service]", s),
});
// --- event stream -----------------------------------------------------------
// char/stat lines are batched per microtask: a chatty simulation can emit
// thousands of SendChar lines per second, and one postMessage per line would
// swamp the editor's main thread. bg/exit events flush the pending batch first
// so relative order is preserved.
const EVT_CHAR = 0, EVT_STAT = 1, EVT_BG = 2, EVT_EXIT = 3;
let pendingLines = null; // { kind, lines } of the open batch
let flushQueued = false;
function flushLines() {
flushQueued = false;
if (pendingLines) {
const batch = pendingLines;
pendingLines = null;
postMessage({ evt: { kind: batch.kind, lines: batch.lines } });
}
}
function onEmit(kind, text, a, b) {
if (kind === EVT_CHAR || kind === EVT_STAT) {
const k = kind === EVT_CHAR ? "char" : "stat";
if (pendingLines && pendingLines.kind !== k) flushLines();
if (!pendingLines) pendingLines = { kind: k, lines: [] };
pendingLines.lines.push(text);
if (!flushQueued) {
flushQueued = true;
queueMicrotask(flushLines);
}
return;
}
flushLines();
if (kind === EVT_BG) {
postMessage({ evt: { kind: "bg", finished: !!a } });
} else if (kind === EVT_EXIT) {
postMessage({ evt: { kind: "exit", status: a, immediate: !!(b & 1), quit: !!(b & 2) } });
}
}
modP.then((mod) => {
mod.ngspiceEmit = onEmit;
postMessage({ ready: true });
}, (e) => postMessage({ bootError: String(e) }));
// --- request dispatch -------------------------------------------------------
onmessage = async (e) => {
const { id, req } = e.data;
if (typeof id !== "number") return;
let res;
const transfer = [];
try {
const mod = await modP;
switch (req.kind) {
case "init":
res = { ret: mod.init() };
break;
case "circ":
res = { ret: mod.circ(req.lines, req.files ?? []) };
break;
case "command":
res = { ret: mod.command(req.cmd) };
break;
case "get_vec_info": {
const vi = mod.getVecInfo(req.name);
// The module returns views over its (shared) heap; copy into fresh
// non-shared arrays so they can be transferred out.
const real = vi.real ? new Float64Array(vi.real) : null;
const comp = vi.comp ? new Float64Array(vi.comp) : null;
res = { found: vi.found, vname: vi.vname, vtype: vi.vtype,
flags: vi.flags, length: vi.length, real, comp };
if (real) transfer.push(real.buffer);
if (comp) transfer.push(comp.buffer);
break;
}
case "cur_plot":
res = { name: mod.curPlot() };
break;
case "all_plots":
res = { names: mod.allPlots() };
break;
case "all_vecs":
res = { names: mod.allVecs(req.plot) };
break;
case "running":
res = { running: mod.running() };
break;
case "cm_input_path":
mod.cmInputPath(req.path ?? "");
res = { ok: true };
break;
default:
res = { error: "ngspice_service: unknown request kind " + req.kind };
}
} catch (err) {
res = { error: "ngspice_service worker: " + err };
}
postMessage({ id, res }, transfer);
};