213 lines
14 KiB
Markdown
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.
|