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:
commit
4ff71765e7
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue