From 7f0f72862ce96fe9bf08221e5f898007c44d495c Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Tue, 19 May 2026 15:38:22 +0200 Subject: [PATCH] fix: regenerate components-metadata + plug vitest worker leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/public/components-metadata.json | 2 +- .../circuit-simulation-service.test.ts | 39 ++++++++++++++----- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/frontend/public/components-metadata.json b/frontend/public/components-metadata.json index 2af5c428..b58b52d5 100644 --- a/frontend/public/components-metadata.json +++ b/frontend/public/components-metadata.json @@ -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": "\n \n \n POWER-SUPPLY\n \n ", + "thumbnail": "\n \n \n 5.00V\n 1.00A\n \n \n PSU\n ", "tags": [ "power-supply", "regulated", diff --git a/frontend/src/__tests__/circuit-simulation-service.test.ts b/frontend/src/__tests__/circuit-simulation-service.test.ts index dc99dd4e..7f606246 100644 --- a/frontend/src/__tests__/circuit-simulation-service.test.ts +++ b/frontend/src/__tests__/circuit-simulation-service.test.ts @@ -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