feat(sim): Phase 1c F1 — NgSpiceNodeAdapter runs real WASM in Node tests

Loads the vendored ngspice-interactive WASM directly in the Vitest
Node process, no Web Worker required.  Implements the same SolverPort
contract as NgSpiceWorkerAdapter, so production code and tests share
ONE solver — closing the "no hybrid" gap.

loadNgSpiceForNode (Node-only loader):
- Reads ngspice-lib.js as text, wraps with a hoisted
  `var Module = config` so the emscripten singleton picks up our
  locateFile + callbacks.
- Re-wires Module.onRuntimeInitialized to copy closure-local FS /
  HEAP* into Module._velxio_* (the vendored build doesn't export
  them via EXPORTED_RUNTIME_METHODS so direct Module.FS triggers an
  abort accessor).

NgSpiceNodeAdapter:
- bindApi (cwrap), registerCallbacks (no-op via addFunction),
  stageFilesystem (recursive mkdir + writeFile of model .cm + spinit),
  initialiseNgspice (null callback pointers; the build still solves
  fine without print/data hooks).
- loadCircuit writes the netlist to /circuit.spc on the FS and
  issues `source /circuit.spc` — sidesteps `_malloc` (not exported
  by this build) that the obvious ngSpice_Circ path would need.
- solve() dispatches op/tran/ac, reads requested vectors via
  ngGet_Vec_Info using the actual struct offsets verified against
  the live build dump: flags=8, realdata=12, imagdata=16, length=20.
- alterSource issues `alter` for incremental re-solves.

5/5 SolverPort contract tests pass against real ngspice:
- init idempotent
- DC op solves a 100Ω/100Ω divider → V(mid) = 2.5 V exactly
- omits requested vectors that don't exist
- alterSource changes V1 → V(mid) tracks the new voltage
- transient RC charge (τ=1ms) reaches >4.5V after 5τ

Next: F2 — migrate the ~22 test files that use eecircuit-engine via
`runNetlist` to this adapter. After F2, F3 deletes eecircuit-engine
and `SpiceEngine.ts` for good.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
davidmonterocrespo24 2026-05-15 20:49:39 +02:00
parent 5d64654668
commit 8db973675a
4 changed files with 625 additions and 0 deletions

View File

@ -0,0 +1,48 @@
/**
* Phase 1c F1 smoke test — does the vendored WASM load cleanly in
* the Vitest Node process? If this fails, the entire Node adapter
* path is dead and we have to rethink F1.
*/
import { describe, it, expect } from 'vitest';
import {
loadNgSpiceForNode,
__resetNgSpiceForTests,
} from '../simulation/spice/adapters/node/loadNgSpiceForNode';
describe('Phase 1c F1 — Node WASM loader', () => {
it(
'boots the ngspice emscripten module and exposes cwrap',
{ timeout: 30_000 },
async () => {
__resetNgSpiceForTests();
const stdout: string[] = [];
const stderr: string[] = [];
const Module = await loadNgSpiceForNode({
onStdout: (t) => stdout.push(t),
onStderr: (t) => stderr.push(t),
});
expect(typeof Module.cwrap).toBe('function');
expect(typeof Module.addFunction).toBe('function');
expect(typeof Module.UTF8ToString).toBe('function');
// The vendored build doesn't export FS; the loader exposes it
// via _velxio_fs to esquivar the abort accessor.
expect(Module._velxio_fs).toBeDefined();
expect(typeof Module._velxio_fs?.writeFile).toBe('function');
},
);
it('cwrap binds ngSpice_Reset (a known no-arg ngspice symbol)', async () => {
const Module = await loadNgSpiceForNode();
const reset = Module.cwrap('ngSpice_Reset', 'number', []);
expect(typeof reset).toBe('function');
});
it('addFunction is callable and returns a function-table pointer', async () => {
const Module = await loadNgSpiceForNode();
expect(typeof Module.addFunction).toBe('function');
const noop = () => 0;
const ptr = Module.addFunction(noop, 'iiii');
expect(typeof ptr).toBe('number');
expect(ptr).toBeGreaterThan(0);
});
});

View File

@ -0,0 +1,103 @@
/**
* NgSpiceNodeAdapter integration test — runs the SAME SolverPort
* contract suite as solver-port-contract.test.ts but against the
* real WASM running in the Vitest Node process.
*
* If this passes, we have proof that production code and tests can
* share a single solver path (the "no hybrid" rule).
*
* Test cases include a real DC operating-point solve so we know
* the netlist pipeline + readVec extraction actually work.
*/
import { describe, it, expect, afterAll } from 'vitest';
import { NgSpiceNodeAdapter } from '../simulation/spice/adapters/NgSpiceNodeAdapter';
const adapter = new NgSpiceNodeAdapter();
afterAll(() => {
adapter.dispose();
});
describe('NgSpiceNodeAdapter — SolverPort contract on real WASM', () => {
it('init is idempotent', { timeout: 30_000 }, async () => {
await adapter.init();
await adapter.init();
// No throw.
});
it(
'loads + solves an .op on a simple divider, returns the expected v(net)',
{ timeout: 30_000 },
async () => {
const netlist = [
'* simple divider',
'V1 1 0 DC 5',
'R1 1 mid 100',
'R2 mid 0 100',
'.end',
].join('\n');
await adapter.loadCircuit(netlist);
const result = await adapter.solve(
{ kind: 'op' },
{ vectorsOfInterest: ['v(1)', 'v(mid)'] },
);
expect(result.vectors.size).toBeGreaterThanOrEqual(2);
expect(result.vectors.get('v(1)')?.real[0]).toBeCloseTo(5, 3);
expect(result.vectors.get('v(mid)')?.real[0]).toBeCloseTo(2.5, 3);
},
);
it(
'omits missing vectors silently',
{ timeout: 30_000 },
async () => {
const result = await adapter.solve(
{ kind: 'op' },
{ vectorsOfInterest: ['v(1)', 'v(does_not_exist)'] },
);
expect(result.vectors.has('v(1)')).toBe(true);
expect(result.vectors.has('v(does_not_exist)')).toBe(false);
},
);
it(
'alterSource updates a V source and the next solve sees the new value',
{ timeout: 30_000 },
async () => {
// Same divider as before — V1 is still loaded.
await adapter.alterSource('V1', 10);
const result = await adapter.solve(
{ kind: 'op' },
{ vectorsOfInterest: ['v(mid)'] },
);
expect(result.vectors.get('v(mid)')?.real[0]).toBeCloseTo(5, 3);
},
);
it(
'transient analysis returns a time axis + samples for an RC step',
{ timeout: 30_000 },
async () => {
// Single step at t=0 — uic forces V_cap(0)=0, then DC source
// charges through R into C. τ = R·C = 1k·1u = 1 ms.
const netlist = [
'* RC charge from a DC step',
'V1 1 0 DC 5',
'R1 1 mid 1k',
'C1 mid 0 1u IC=0',
'.end',
].join('\n');
await adapter.loadCircuit(netlist);
const result = await adapter.solve(
{ kind: 'tran', step: '1e-5', stop: '5e-3' },
{ vectorsOfInterest: ['v(mid)'] },
);
expect(result.timeAxis.length).toBeGreaterThan(10);
expect(result.vectors.get('v(mid)')?.real.length).toBe(result.timeAxis.length);
// After 5τ, vmid ≈ 0.993·5 ≈ 4.97 V.
const lastV = result.vectors.get('v(mid)')?.real.at(-1) ?? 0;
expect(lastV).toBeGreaterThan(4.5);
expect(lastV).toBeLessThan(5.1);
},
);
});

View File

@ -0,0 +1,293 @@
/**
* NgSpiceNodeAdapter — SolverPort implementation that runs the
* vendored ngspice WASM directly in the Vitest Node process, no
* Web Worker required.
*
* Architecture:
* loadNgSpiceForNode → emscripten Module (with _velxio_fs et al.
* patched on)
* ↓
* stageFilesystem — write the .cm files + spinit to the WASM FS
* ↓
* bindApi — Module.cwrap the C functions we need
* ↓
* initialiseNgspice — register callbacks, call ngSpice_Init,
* set xspice, source /spinit
*
* Once initialised, SolverPort methods translate domain calls to
* ngspice commands and read results via ngGet_Vec_Info.
*
* Concurrency: ngspice C API is not thread-safe; this adapter
* serialises every call. Vitest's worker-per-test-file isolation
* means each test file gets a fresh process and adapter.
*/
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import type {
SolverPort,
SolveAnalysis,
SolveResult,
SolveOptions,
SolveVector,
} from '../ports/SolverPort';
import {
loadNgSpiceForNode,
type NgSpiceEmscriptenModule,
} from './node/loadNgSpiceForNode';
const MODEL_FILES = [
'analog.cm',
'digital.cm',
'spice2poly.cm',
'table.cm',
'tlines.cm',
'xtradev.cm',
'xtraevt.cm',
];
// ngspice vector_info struct field offsets (32-bit pointers).
// Confirmed against the live struct dump from the vendored build.
const VECTOR_INFO_FLAGS_OFFSET = 8;
const VECTOR_INFO_REALDATA_OFFSET = 12;
const VECTOR_INFO_IMAGDATA_OFFSET = 16;
const VECTOR_INFO_LENGTH_OFFSET = 20;
const VECTOR_FLAG_COMPLEX = 1 << 1;
interface NgSpiceApi {
init: (
print: number,
status: number,
exit: number,
data: number,
dataInit: number,
bg: number,
arg: number,
) => number;
command: (cmd: string) => number;
circ: (linesPtr: number) => number;
allVecs: (plot: string) => number;
curPlot: () => string;
getVecInfo: (name: string) => number;
reset: () => number;
nospiceinit: () => number;
setInputPath: (path: string) => number;
}
function defaultWasmDir(): string {
const here = fileURLToPath(import.meta.url);
return path.resolve(here, '../../../../../public/wasm/ngspice-interactive');
}
export class NgSpiceNodeAdapter implements SolverPort {
private module: NgSpiceEmscriptenModule | null = null;
private api: NgSpiceApi | null = null;
private initialised = false;
private initPromise: Promise<void> | null = null;
private readonly wasmDir: string;
private stdoutBuffer: string[] = [];
private stderrBuffer: string[] = [];
private callbackPointers: number[] = [];
constructor(opts: { wasmDir?: string } = {}) {
this.wasmDir = opts.wasmDir ?? defaultWasmDir();
}
async init(): Promise<void> {
if (this.initialised) return;
if (this.initPromise) return this.initPromise;
this.initPromise = (async () => {
this.module = await loadNgSpiceForNode({
wasmDir: this.wasmDir,
onStdout: (text) => this.stdoutBuffer.push(text),
onStderr: (text) => this.stderrBuffer.push(text),
});
this.bindApi();
this.registerCallbacks();
this.stageFilesystem();
this.initialiseNgspice();
this.initialised = true;
})();
return this.initPromise;
}
private bindApi(): void {
if (!this.module) throw new Error('Module not ready');
const M = this.module;
this.api = {
init: M.cwrap('ngSpice_Init', 'number', [
'number', 'number', 'number', 'number', 'number', 'number', 'number',
]),
command: M.cwrap('ngSpice_Command', 'number', ['string']),
circ: M.cwrap('ngSpice_Circ', 'number', ['number']),
allVecs: M.cwrap('ngSpice_AllVecs', 'number', ['string']),
curPlot: M.cwrap('ngSpice_CurPlot', 'string', []),
getVecInfo: M.cwrap('ngGet_Vec_Info', 'number', ['string']),
reset: M.cwrap('ngSpice_Reset', 'number', []),
nospiceinit: M.cwrap('ngSpice_nospiceinit', 'number', []),
setInputPath: M.cwrap('ngCM_Input_Path', 'number', ['string']),
};
}
private registerCallbacks(): void {
if (!this.module) throw new Error('Module not ready');
const M = this.module;
// ngspice C callback signatures (from sharedspice.h):
// SendChar(char*, int, void*) → 4 ints (i ptr, i id, ptr) but emscripten counts ptrs as i
// SendStat(char*, int, void*) → iiii
// ControlledExit(int, bool, bool, int, void*) → iiiiii
// SendData(vecvaluesall*, int, int, void*) → iiiii
// SendInitData(vecinfoall*, int, void*) → iiii
// BGThreadRunning(bool, int, void*) → iiii
const noop = () => 0;
this.callbackPointers = [
M.addFunction(noop, 'iiii'), // print
M.addFunction(noop, 'iiii'), // status
M.addFunction(noop, 'iiiiii'), // exit
M.addFunction(noop, 'iiiii'), // data
M.addFunction(noop, 'iiii'), // dataInit
M.addFunction(noop, 'iiii'), // bg
];
}
private stageFilesystem(): void {
const M = this.module;
if (!M || !M._velxio_fs) throw new Error('Module FS not ready');
const FS = M._velxio_fs;
// Recursive mkdir — emscripten FS.mkdir is one level at a time
// and the default mounted root only has `/`.
const ensureDir = (full: string): void => {
const parts = full.split('/').filter((p) => p.length > 0);
let cur = '';
for (const p of parts) {
cur += '/' + p;
try { FS.mkdir(cur); } catch { /* exists */ }
}
};
ensureDir('/usr/local/lib/ngspice');
ensureDir('/usr/local/share/ngspice/scripts');
for (const name of MODEL_FILES) {
const data = readFileSync(path.join(this.wasmDir, name));
FS.writeFile(`/usr/local/lib/ngspice/${name}`, new Uint8Array(data));
}
const spinit = readFileSync(path.join(this.wasmDir, 'spinit'), 'utf8');
FS.writeFile('/usr/local/share/ngspice/scripts/spinit', spinit);
FS.writeFile('/spinit', spinit);
}
private initialiseNgspice(): void {
if (!this.api) throw new Error('API not ready');
this.api.nospiceinit();
// Pass 0 (null) for every callback — ngspice tolerates null and
// skips the corresponding event. This sidesteps a
// "function signature mismatch" we hit when feeding addFunction
// pointers; investigate the worker's exact emscripten build
// settings later if we need real callbacks (progress / stderr).
const rc = this.api.init(0, 0, 0, 0, 0, 0, 0);
if (rc !== 0) throw new Error(`ngSpice_Init returned ${rc}`);
this.api.setInputPath('/');
this.api.command('set xspice_enabled');
this.api.command('source /spinit');
}
async loadCircuit(netlist: string): Promise<void> {
await this.init();
if (!this.module || !this.api || !this.module._velxio_fs) throw new Error('not ready');
// The build doesn't export `_malloc`, so we can't construct the
// char** the `ngSpice_Circ` API needs from JS. Workaround: write
// the netlist to the virtual FS and ask ngspice to `source` it.
this.module._velxio_fs.writeFile('/circuit.spc', netlist);
const rc = this.api.command('source /circuit.spc');
if (rc !== 0) throw new Error(`source /circuit.spc returned ${rc}`);
}
async solve(analysis: SolveAnalysis, options: SolveOptions): Promise<SolveResult> {
await this.init();
if (!this.api) throw new Error('not ready');
const t0 = performance.now();
this.stderrBuffer.length = 0;
this.stdoutBuffer.length = 0;
// ngspice has two ways to run an analysis:
// .control / .endc block in the netlist (ran by 'source')
// bare `op` / `tran ...` / `ac ...` commands after sourcing
// Bare commands often fail silently in interactive mode unless
// a circuit is "current"; the safer pattern is to inject the
// analysis directive INTO the netlist before source. But since
// the source already ran, we issue `run` which runs whatever
// last `.tran` / `.op` directive was in the netlist + falls
// back to our manual analysis card.
const cmd = (() => {
switch (analysis.kind) {
case 'op': return 'op';
case 'tran': return `tran ${analysis.step} ${analysis.stop}`;
case 'ac': return `ac ${analysis.sweep} ${analysis.points} ${analysis.fstart} ${analysis.fstop}`;
}
})();
const rc = this.api.command(cmd);
if (rc !== 0) throw new Error(`ngspice command '${cmd}' returned ${rc}`);
const vectors = new Map<string, SolveVector>();
let timeAxis: Float64Array = new Float64Array(0);
for (const name of options.vectorsOfInterest) {
const vec = this.readVector(name);
if (vec) vectors.set(name.toLowerCase(), vec);
}
if (analysis.kind === 'tran') {
const t = this.readVector('time');
if (t) timeAxis = t.real;
}
const solveMs = performance.now() - t0;
return {
analysis,
vectors,
timeAxis,
solveMs,
warnings: [...this.stderrBuffer, ...this.stdoutBuffer.map((s) => `stdout: ${s}`)],
};
}
async alterSource(name: string, dcValue: number): Promise<void> {
await this.init();
if (!this.api) throw new Error('not ready');
this.api.command(`alter ${name} dc ${dcValue}`);
}
dispose(): void {
if (this.api) {
try { this.api.reset(); } catch { /* ignore */ }
}
this.initialised = false;
this.initPromise = null;
}
private readVector(name: string): SolveVector | null {
if (!this.module || !this.api) return null;
const M = this.module;
let infoPtr = this.api.getVecInfo(name);
if (!infoPtr && !name.includes('.')) {
const plot = this.api.curPlot();
if (plot) infoPtr = this.api.getVecInfo(`${plot}.${name}`);
}
if (!infoPtr) return null;
const heap32 = M._velxio_heap32;
const heapu32 = M._velxio_heapu32;
const heapf64 = M._velxio_heapf64;
if (!heap32 || !heapu32 || !heapf64) return null;
const realDataPtr = heapu32[(infoPtr + VECTOR_INFO_REALDATA_OFFSET) >> 2]!;
const imagDataPtr = heapu32[(infoPtr + VECTOR_INFO_IMAGDATA_OFFSET) >> 2]!;
const length = heap32[(infoPtr + VECTOR_INFO_LENGTH_OFFSET) >> 2]!;
const flags = heap32[(infoPtr + VECTOR_INFO_FLAGS_OFFSET) >> 2]!;
const complex = (flags & VECTOR_FLAG_COMPLEX) !== 0;
if (!realDataPtr || length <= 0) return null;
const real = new Float64Array(heapf64.buffer, realDataPtr, length).slice();
const imag = complex && imagDataPtr
? new Float64Array(heapf64.buffer, imagDataPtr, length).slice()
: null;
return { name: name.toLowerCase(), real, imag };
}
}

View File

@ -0,0 +1,181 @@
/**
* loadNgSpiceForNode — runtime loader for the vendored ngspice
* emscripten module in a Node (Vitest) process.
*
* The vendored `ngspice-lib.js` is an emscripten singleton build
* (not MODULARIZE), so the obvious `require('./ngspice-lib.js')`
* pattern doesn't pass a config to the Module factory. The CJS
* module evaluates `var Module = typeof Module != 'undefined' ?
* Module : {}` in its own scope, which can't see
* `globalThis.Module`.
*
* Workaround: read the source as text, wrap with a hoisted
* `var Module = config;` so the emscripten guard finds Module
* already defined (var hoisting + non-redeclaration semantics make
* the two `var Module` lines refer to the same variable), and
* evaluate via `new Function`. Result: our `config` survives and
* `locateFile` + callbacks work as expected.
*
* Only used in Node test contexts. The browser path
* (`NgSpiceWorkerAdapter`) goes through the Web Worker which evaluates
* the lib in its own scope.
*/
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Subset of the emscripten Module surface we use. The vendored
* build adds plenty more, but we only depend on these.
*/
export interface NgSpiceEmscriptenModule {
cwrap: (
name: string,
returnType: string | null,
argTypes: readonly string[],
) => (...args: unknown[]) => unknown;
addFunction: (fn: (...args: unknown[]) => unknown, signature: string) => number;
removeFunction: (ptr: number) => void;
UTF8ToString: (ptr: number) => string;
stringToUTF8: (str: string, ptr: number, max: number) => void;
lengthBytesUTF8: (str: string) => number;
_malloc: (bytes: number) => number;
_free: (ptr: number) => void;
_velxio_heap8?: Int8Array;
_velxio_heap32?: Int32Array;
_velxio_heapu32?: Uint32Array;
_velxio_heapf64?: Float64Array;
/**
* Note: the vendored build does NOT include `FS` in
* EXPORTED_RUNTIME_METHODS, so `Module.FS` triggers an abort
* accessor on this build. We expose the closure-captured locals
* via the `_velxio_*` namespace instead — see the wrapper in
* loadNgSpiceForNode.
*/
_velxio_fs?: {
writeFile: (path: string, data: Uint8Array | string) => void;
mkdir: (path: string) => void;
analyzePath: (path: string) => { exists: boolean };
};
_velxio_env?: Record<string, string>;
noInitialRun: boolean;
print?: (text: string) => void;
printErr?: (text: string) => void;
locateFile?: (path: string) => string;
onRuntimeInitialized?: () => void;
}
export interface LoadNgSpiceConfig {
/** Directory containing ngspice-lib.{js,wasm} + the .cm files. */
wasmDir: string;
/** Optional stdout sink (one line at a time). */
onStdout?: (text: string) => void;
/** Optional stderr sink. */
onStderr?: (text: string) => void;
}
function defaultWasmDir(): string {
// Resolve relative to this file's package location:
// frontend/src/simulation/spice/adapters/node/loadNgSpiceForNode.ts
// → frontend/public/wasm/ngspice-interactive
const here = fileURLToPath(import.meta.url);
return path.resolve(here, '../../../../../../public/wasm/ngspice-interactive');
}
let cachedModule: NgSpiceEmscriptenModule | null = null;
let cachedInit: Promise<NgSpiceEmscriptenModule> | null = null;
/**
* Load the ngspice WASM module in the current Node process. Returns
* the initialised Module on success.
*
* Caches the singleton — calling twice returns the same Module.
* emscripten singleton builds can't be re-initialised within a
* process, so dispose() is best-effort (resets ngspice state via
* `ngSpice_Reset`, doesn't free the WASM).
*/
export function loadNgSpiceForNode(
config: Partial<LoadNgSpiceConfig> = {},
): Promise<NgSpiceEmscriptenModule> {
if (cachedInit) return cachedInit;
const wasmDir = config.wasmDir ?? defaultWasmDir();
const libPath = path.join(wasmDir, 'ngspice-lib.js');
const moduleConfig: Partial<NgSpiceEmscriptenModule> = {
noInitialRun: true,
locateFile: (filename) => {
// Resolve all asset files (.wasm, .cm, etc.) relative to wasmDir.
return path.join(wasmDir, filename);
},
print: (text: string) => config.onStdout?.(text),
printErr: (text: string) => config.onStderr?.(text),
};
const src = readFileSync(libPath, 'utf8');
// Wrapping strategy:
// 1. Hoisted `var Module = config` so the emscripten body's
// `var Module = typeof Module != 'undefined' ? Module : {}`
// finds Module already defined and keeps our config.
// 2. After the emscripten body runs, FS / HEAP* are bound to
// `var` declarations inside the same closure. We re-wire
// `Module.onRuntimeInitialized` to ALSO copy those locals
// onto Module so external code can read them via
// `Module.FS`, `Module.HEAPU32`, etc. The build doesn't
// include FS in EXPORTED_RUNTIME_METHODS so this is the
// only path that doesn't require recompiling.
const wrapped = `
var Module = config;
${src}
var __velxioOriginalInit = Module.onRuntimeInitialized;
Module.onRuntimeInitialized = function () {
// Build does not export FS / HEAP* in EXPORTED_RUNTIME_METHODS;
// those keys on Module trigger abort accessors. Assign to the
// _velxio_ namespace instead and the NgSpiceNodeAdapter reads
// from there.
if (typeof FS !== 'undefined') Module._velxio_fs = FS;
if (typeof HEAP8 !== 'undefined') Module._velxio_heap8 = HEAP8;
if (typeof HEAP32 !== 'undefined') Module._velxio_heap32 = HEAP32;
if (typeof HEAPU32 !== 'undefined') Module._velxio_heapu32 = HEAPU32;
if (typeof HEAPF64 !== 'undefined') Module._velxio_heapf64 = HEAPF64;
if (typeof ENV !== 'undefined') Module._velxio_env = ENV;
if (__velxioOriginalInit) __velxioOriginalInit();
};
return Module;
`;
const factory = new Function('config', 'require', '__dirname', '__filename', wrapped) as (
config: unknown,
req: typeof require,
dirname: string,
filename: string,
) => NgSpiceEmscriptenModule;
const req = createRequire(import.meta.url);
cachedInit = new Promise<NgSpiceEmscriptenModule>((resolve, reject) => {
moduleConfig.onRuntimeInitialized = () => {
cachedModule = moduleConfig as NgSpiceEmscriptenModule;
resolve(cachedModule);
};
try {
factory(moduleConfig, req, wasmDir, libPath);
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
}
setTimeout(() => reject(new Error('ngspice WASM init timeout (30s)')), 30_000);
});
return cachedInit;
}
/**
* Reset the cached module reference (NOT free the WASM — that's not
* supported by emscripten singletons). Subsequent `loadNgSpiceForNode`
* calls will re-init — which only works if the previous Module was
* reset via `ngSpice_Reset`. Best-effort.
*/
export function __resetNgSpiceForTests(): void {
cachedModule = null;
cachedInit = null;
}