| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .. | ||
| README.md | ||
ngspice-split — the eeschema simulator as a lazy worker service
Status: implemented (2026-07-17). The SPICE analog of the 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_haltinterrupts 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:
- libtool static-mode override. ngspice hardwires libtool's
-sharedmode for the ngshared build (STATIC=-sharedconsumed as AM_CFLAGS everywhere, plus literal-sharedinlibngspice_la_{CFLAGS,LDFLAGS}); libtool hard-errors on-sharedwithout shared-lib support. Fixed withmake STATIC=-static(command line beats makefile) + a sed on the two generatedsrc/Makefilelines. - XSPICE code models without dlopen. Natively each
.cmis dlopen'd viaload_opus()(src/spicelib/devices/dev.c). Statically: the icm build is redirected (envNGCM_STATIC/NGCM_DLMAIN) to compile per-cm renamed tables (ngcm_<cm>_cmDEVices…,ngcm_dlmain_static.c) andemareach model into a.cmarchive; a registry appended to dev.c resolves the seven bundled basenames straight intoadd_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 intongcm_common.a, withcm_getvarbound directly to the core'scp_getvar(the dllitf mapping,cmexport.c). - 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:.cmarchives quietly containing onlydlmain.o("Permission denied" from the wasm binary). - verilog/vhdl subdirs skipped (generated
src/xspice/Makefilesed): 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. - Upstream 32-bit bug fixed (TODO: upstream). CIDER card parsing frees
through
dataType & IF_REALVEC— a composite mask (0x8004) that also matches scalarIF_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 exactIF_VARTYPEStests. /proc/meminfoheader 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.-pthreadeverywhere: withoutHAVE_LIBPTHREAD,bg_runsilently degrades to a synchronous blocking call (sharedspice.crunc()).- spinit installs to the sysroot and is
--embed-file'd into the service at/ngspice/scripts/spinit;main()setsSPICE_LIB_DIR=/ngspiceso ngspice's env-first search finds it regardless of the baked build prefix. Itscodemodel <prefix>/<cm>.cmlines 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_LockReallocinside the service — this replaces KiCad's client-side RAII lock (a no-op across RPC; the ifdef leavesm_ngSpice_LockReallocnull) 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 them_ngSpice_*pointers topcbjam_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: itswxSetWorkingDirectory( 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-callvector_infoarena (every NGSPICE consumer copies within the same call), an atomicngSpice_runningmirror (the UI polls on a timer; zero RPC per poll), and the.include/.libshipper:NETLIST_EXPORTER_SPICEemits absolute.includepaths (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 toglobalThis.__ngspiceOnEvent(installed by the stub at first init), which calls the exportedpcbjam_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
BGThreadRunningcallback argument is "not running" (true = finished) — KiCad treats it asaFinished; keep the polarity. - The
runningmirror flips true atbg_runACCEPTANCE (not at the bg 'started' event) so an immediateIsRunning()poll already sees it. - Emscripten's
HEAPF64.setafter_mallocinside 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
.cmcode 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.