fix: regenerate components-metadata + plug vitest worker leak
Two CI failures landed together on master after PR #194 merged: 1. **components-metadata.json stale.** The `power-supply` thumbnail in scripts/component-overrides.json was updated (grey placeholder → branded PSU SVG with voltage/current labels) but the generated JSON wasn't regenerated. The pre-merge check `git diff --quiet frontend/public/components-metadata.json` now fails on master. Fix: `cd frontend && npm run generate:metadata`, commit the result. 2. **Frontend Tests > test (20/22): vitest worker hang.** `circuit-simulation-service.test.ts` had been calling `service.start()` in ~10 tests without storing the returned unsubscribe handle. Each call subscribes the service to the simStore; the listener captures the service + scheduler in its closure. After all tests complete, vitest's forks pool tries to terminate the worker but the still-active listeners keep the event loop pinned, producing: "Worker exited unexpectedly / Timeout terminating forks worker" All assertions actually pass — only the worker shutdown hangs. Fix: introduce a `startTracked(service)` helper that records the unsubscribe in a module-level array, plus an `afterEach` that drains the array. `__resetMixedModeScheduler()` still runs after to dispose the scheduler singleton. Replaced all 9 raw `service.start()` callsites. Both are independent of any production code change. The fix is test/scaffolding only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2e0e92f4f6
commit
7f0f72862c
|
|
@ -518,7 +518,7 @@
|
|||
"description": "Single-channel optocoupler. CTR 80–600% (typ. 100%). Higher drive than 4N25, commonly used for MCU-to-mains isolation."
|
||||
},
|
||||
{
|
||||
"thumbnail": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\">\n <rect width=\"64\" height=\"64\" fill=\"#e0e0e0\" rx=\"4\"/>\n <text x=\"50%\" y=\"50%\" text-anchor=\"middle\" dy=\".3em\" font-size=\"10\" fill=\"#666\">\n POWER-SUPPLY\n </text>\n </svg>",
|
||||
"thumbnail": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\">\n <rect width=\"64\" height=\"64\" fill=\"#1a2332\" rx=\"4\"/>\n <rect x=\"10\" y=\"14\" width=\"44\" height=\"30\" fill=\"#2a3548\" rx=\"3\" stroke=\"#4a5878\"/>\n <text x=\"32\" y=\"30\" text-anchor=\"middle\" font-size=\"9\" font-family=\"monospace\" fill=\"#4ade80\" font-weight=\"bold\">5.00V</text>\n <text x=\"32\" y=\"40\" text-anchor=\"middle\" font-size=\"7\" font-family=\"monospace\" fill=\"#fbbf24\">1.00A</text>\n <circle cx=\"18\" cy=\"52\" r=\"3\" fill=\"#dc2626\"/>\n <circle cx=\"46\" cy=\"52\" r=\"3\" fill=\"#0f172a\" stroke=\"#64748b\"/>\n <text x=\"32\" y=\"60\" text-anchor=\"middle\" font-size=\"6\" fill=\"#9d9d9d\">PSU</text>\n </svg>",
|
||||
"tags": [
|
||||
"power-supply",
|
||||
"regulated",
|
||||
|
|
|
|||
|
|
@ -20,7 +20,26 @@ 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.
|
||||
const _activeUnsubs: Array<() => void> = [];
|
||||
|
||||
function startTracked(service: { start: () => () => void }): () => void {
|
||||
const unsub = service.start();
|
||||
_activeUnsubs.push(unsub);
|
||||
return unsub;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const unsub of _activeUnsubs.splice(0)) {
|
||||
try { unsub(); } catch { /* ignore */ }
|
||||
}
|
||||
__resetMixedModeScheduler();
|
||||
});
|
||||
|
||||
|
|
@ -98,7 +117,7 @@ describe('CircuitSimulationService — orchestration', () => {
|
|||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) },
|
||||
);
|
||||
service.start();
|
||||
startTracked(service);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
expect(fake.calls.loadCircuit.length).toBe(1);
|
||||
|
|
@ -121,7 +140,7 @@ describe('CircuitSimulationService — orchestration', () => {
|
|||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) },
|
||||
);
|
||||
service.start();
|
||||
startTracked(service);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const snap = elec.snapshots[0];
|
||||
|
|
@ -139,7 +158,7 @@ describe('CircuitSimulationService — orchestration', () => {
|
|||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({ '5V': { type: 'digital', v: 5 } }) },
|
||||
);
|
||||
service.start();
|
||||
startTracked(service);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(fake.calls.solve.length).toBe(1);
|
||||
|
||||
|
|
@ -159,7 +178,7 @@ describe('CircuitSimulationService — orchestration', () => {
|
|||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({}) },
|
||||
);
|
||||
service.start();
|
||||
startTracked(service);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
sim.set({}); // same arrays — should NOT trigger a re-solve
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
|
|
@ -180,7 +199,7 @@ describe('CircuitSimulationService — orchestration', () => {
|
|||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({}) },
|
||||
);
|
||||
service.start();
|
||||
startTracked(service);
|
||||
// While initial solve runs, fire 3 store changes — should coalesce
|
||||
// into 1 trailing solve.
|
||||
sim.set({ components: [{ id: 'a', metadataId: 'resistor', properties: {} }] });
|
||||
|
|
@ -218,7 +237,7 @@ describe('CircuitSimulationService — orchestration', () => {
|
|||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({}) },
|
||||
);
|
||||
service.start();
|
||||
startTracked(service);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
const snap = elec.snapshots[0];
|
||||
|
|
@ -241,7 +260,7 @@ describe('CircuitSimulationService — orchestration', () => {
|
|||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({}) },
|
||||
);
|
||||
service.start();
|
||||
startTracked(service);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
// The FakeSolverAdapter returns empty warnings, so the snapshot
|
||||
// also has empty warnings — but the field exists.
|
||||
|
|
@ -294,7 +313,7 @@ describe('handleMcuEdge (Phase 1c D1)', () => {
|
|||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({}) },
|
||||
);
|
||||
service.start();
|
||||
startTracked(service);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const initialSolves = fake.calls.solve.length;
|
||||
const initialSnapshots = elec.snapshots.length;
|
||||
|
|
@ -323,7 +342,7 @@ describe('handleMcuEdge (Phase 1c D1)', () => {
|
|||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({}) },
|
||||
);
|
||||
service.start();
|
||||
startTracked(service);
|
||||
// While initial solve is running, fire an edge.
|
||||
void service.handleMcuEdge('uno', '9', true, 5);
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
|
@ -371,7 +390,7 @@ describe('CircuitSimulationService — error handling', () => {
|
|||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({}) },
|
||||
);
|
||||
service.start();
|
||||
startTracked(service);
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
expect(warn).toHaveBeenCalled();
|
||||
expect(elec.snapshots.length).toBe(0); // no publish on failure
|
||||
|
|
|
|||
Loading…
Reference in New Issue