diff --git a/frontend/src/__tests__/circuit-simulation-service.test.ts b/frontend/src/__tests__/circuit-simulation-service.test.ts index 7f606246..67572f4c 100644 --- a/frontend/src/__tests__/circuit-simulation-service.test.ts +++ b/frontend/src/__tests__/circuit-simulation-service.test.ts @@ -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. diff --git a/frontend/src/simulation/spice/CircuitSimulationService.ts b/frontend/src/simulation/spice/CircuitSimulationService.ts index 10dea897..c4096fd6 100644 --- a/frontend/src/simulation/spice/CircuitSimulationService.ts +++ b/frontend/src/simulation/spice/CircuitSimulationService.ts @@ -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 { + 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 { + if (this.stopped) return; const pinKey = `${boardId}|${pinName}`; if (this.inFlight) { this.pendingMcuEdges.set(pinKey, { boardId, pinName, state, vcc });