pcbjam/docs/features/ngspice-split/README.md
2026-07-19 15:59:21 +02:00

213 lines
14 KiB
Markdown

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