Merge pull request #202 from davidmonterocrespo24/fix/circuit-sim-service-stop

fix(circuit-sim): stop() guard + drop edges with no V-source post-reb…
This commit is contained in:
David Montero Crespo 2026-05-19 22:52:08 -03:00 committed by GitHub
commit 4ff71765e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 100 additions and 12 deletions

View File

@ -20,19 +20,33 @@ import {
} from '../simulation/spice/MixedModeScheduler';
import { FakeSolverAdapter } from '../simulation/spice/adapters/FakeSolverAdapter';
// Tracks unsubscribe handles returned by `service.start()` so the
// store subscription is released after every test. Without this, the
// listener pins the simStore (and via closure the service + scheduler)
// in memory, and vitest's forks pool can't terminate cleanly when the
// suite finishes — manifesting as "Worker exited unexpectedly /
// Timeout terminating forks worker" on CI. The hang doesn't surface
// any failed assertion; everything passes, but the worker process
// never exits.
// Tracks unsubscribe handles + services so afterEach can fully shut
// the orchestrator down. Two leaks fixed together:
//
// 1. service.start() returns an unsubscribe handle for the store
// subscription. If the test never called it, the listener pinned
// the simStore (+ service + scheduler via closure) and vitest's
// forks pool stopped exiting cleanly.
// 2. Even with the subscription released, service.tick() is async
// and its finally-block recursively re-schedules itself when
// `pendingMcuEdges` is non-empty. After afterEach disposes the
// scheduler, those re-scheduled ticks throw "call loadCircuit
// first", get caught, and schedule ANOTHER tick — infinite
// Promise loop in the event queue that survives until the worker
// OOMs (the manifest of the original "circuit-sim solve failed"
// console.warn that kept appearing across files). Fix: call
// `service.stop()` which flips an internal `stopped` flag that
// short-circuits tick() + handleMcuEdge().
const _activeUnsubs: Array<() => void> = [];
const _activeServices: Array<{ stop: () => void }> = [];
function startTracked(service: { start: () => () => void }): () => void {
function startTracked(service: {
start: () => () => void;
stop: () => void;
}): () => void {
const unsub = service.start();
_activeUnsubs.push(unsub);
_activeServices.push(service);
return unsub;
}
@ -40,6 +54,9 @@ afterEach(() => {
for (const unsub of _activeUnsubs.splice(0)) {
try { unsub(); } catch { /* ignore */ }
}
for (const service of _activeServices.splice(0)) {
try { service.stop(); } catch { /* ignore */ }
}
__resetMixedModeScheduler();
});
@ -311,7 +328,13 @@ describe('handleMcuEdge (Phase 1c D1)', () => {
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
{ collectBoardPinStates: () => ({}) },
// Mark pin 9 as a digital MCU output so the netlist emits
// V_uno_9 from the very first solve. Without this the
// self-heal path in handleMcuEdge triggers a rebuild (full
// tick) instead of the alter + resolveDc fast path the test
// is verifying, and the assertion below races the rebuild's
// own solve completing.
{ collectBoardPinStates: () => ({ '9': { type: 'digital', v: 0 } }) },
);
startTracked(service);
await new Promise((r) => setTimeout(r, 20));
@ -334,13 +357,35 @@ describe('handleMcuEdge (Phase 1c D1)', () => {
solveDelayMs: 30,
});
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore(simpleBoardWithBoard);
// Pin 9 must be wired into the netlist so buildNetlist emits
// V_uno_9 (NetlistBuilder line 158 skips board pins whose net
// lookup returns null). Without a wire, hasSource stays false
// forever and handleMcuEdge's self-heal path would loop. Mirrors
// the wired fixture used by the alter+republish test above.
const sim = makeSimStore({
components: [
{ id: 'rb', metadataId: 'resistor', properties: { value: '1k' } },
],
wires: [
{
id: 'w1',
start: { componentId: 'uno', pinName: '9' },
end: { componentId: 'rb', pinName: '1' },
},
{
id: 'w2',
start: { componentId: 'rb', pinName: '2' },
end: { componentId: 'uno', pinName: 'GND' },
},
],
boards: [{ id: 'uno', boardKind: 'arduino-uno' }],
});
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
{ collectBoardPinStates: () => ({}) },
{ collectBoardPinStates: () => ({ '9': { type: 'digital', v: 0 } }) },
);
startTracked(service);
// While initial solve is running, fire an edge.

View File

@ -128,6 +128,11 @@ export class CircuitSimulationService {
analysisKind: 'op' | 'tran' | 'ac';
} | null = null;
/** Set by `stop()`. Once true, `tick()` and `handleMcuEdge()`
* short-circuit so a service whose owner has unsubscribed can't
* keep re-scheduling solves against a disposed scheduler. */
private stopped = false;
constructor(
private readonly simStore: SimulatorStorePort,
private readonly electricalStore: ElectricalStorePort,
@ -135,8 +140,32 @@ export class CircuitSimulationService {
private readonly options: ServiceOptions,
) {}
/**
* Permanently stop the orchestration loop. After `stop()`, the
* in-flight solve still completes (its Promise was already
* scheduled), but no further `tick()` or replay of pending
* `handleMcuEdge` will fire.
*
* Why this exists: the `tick()` finally-block recursively
* re-schedules itself when `pendingMcuEdges` is non-empty, and
* each iteration calls `scheduler.resolveDc()`. If the test
* fixture (or a future production caller) disposes the scheduler
* via `__resetMixedModeScheduler()` without telling the service,
* those re-scheduled ticks throw "call loadCircuit first", get
* caught, and the finally schedules ANOTHER tick infinite
* Promise loop in the event queue that survives until the worker
* OOMs. Calling `service.stop()` in the test's afterEach (or any
* teardown path) breaks the loop.
*/
stop(): void {
this.stopped = true;
this.pending = false;
this.pendingMcuEdges.clear();
}
/** Run one solve cycle, coalescing concurrent triggers. */
async tick(): Promise<void> {
if (this.stopped) return;
if (this.inFlight) {
this.pending = true;
return;
@ -149,13 +178,26 @@ export class CircuitSimulationService {
console.warn('[circuit-sim] solve failed:', err);
} finally {
this.inFlight = false;
if (this.stopped) return;
if (this.pending) {
this.pending = false;
void this.tick();
} else if (this.pendingMcuEdges.size > 0) {
const edges = Array.from(this.pendingMcuEdges.values());
this.pendingMcuEdges.clear();
const ctx = this.loadedContext;
for (const edge of edges) {
// If the rebuild we just completed still didn't emit a
// V-source for this pin (e.g. the pin isn't wired into
// any net), replaying via handleMcuEdge would self-heal
// again → re-tick → loop forever. Drop the edge instead;
// a future canvas change (e.g. user adds the wire) will
// pick it up via the normal subscription tick.
const expected = `v_${sanitizeSpiceId(edge.boardId)}_${sanitizeSpiceId(edge.pinName)}`.toLowerCase();
const hasSource = ctx?.voltageSources.some(
(vs) => vs.toLowerCase() === expected,
);
if (!hasSource) continue;
void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc);
}
}
@ -175,6 +217,7 @@ export class CircuitSimulationService {
* other's edges.
*/
async handleMcuEdge(boardId: string, pinName: string, state: boolean, vcc: number): Promise<void> {
if (this.stopped) return;
const pinKey = `${boardId}|${pinName}`;
if (this.inFlight) {
this.pendingMcuEdges.set(pinKey, { boardId, pinName, state, vcc });