From b9165ac4bdb5a330176b5e996652d4a23dba19db Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 26 May 2026 20:24:25 -0700 Subject: [PATCH] fix: tear down timed-out speech workers (#2880) --- src/main/speech/stt-service.test.ts | 33 +++++++++++++++++ src/main/speech/stt-service.ts | 56 ++++++++++++++++++++++++----- 2 files changed, 80 insertions(+), 9 deletions(-) diff --git a/src/main/speech/stt-service.test.ts b/src/main/speech/stt-service.test.ts index 6bd82d3c2..fb4561b1d 100644 --- a/src/main/speech/stt-service.test.ts +++ b/src/main/speech/stt-service.test.ts @@ -29,6 +29,10 @@ const { MockWorker, getCreatedWorkerCount, getLastWorker, resetWorkers } = vi.ho return this } + listenerCount(eventName: string): number { + return this.listeners.get(eventName)?.size ?? 0 + } + removeAllListeners(): this { this.listeners.clear() return this @@ -239,4 +243,33 @@ describe('SttService', () => { vi.useRealTimers() } }) + + it('does not retain or reuse a worker that timed out while stopping', async () => { + vi.useFakeTimers() + try { + const service = new SttService({ + getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }), + getModelDir: vi.fn().mockReturnValue('/tmp/model-a') + } as never) + + await service.startDictation('model-a', vi.fn(), undefined, 'desktop') + const firstWorker = getLastWorker() + expect(firstWorker).toBeDefined() + firstWorker!.emitStoppedOnStop = false + + const stopPromise = service.stopDictation('desktop') + await vi.advanceTimersByTimeAsync(60_000) + await stopPromise + + expect(firstWorker!.terminated).toBe(true) + expect(firstWorker!.listenerCount('message')).toBe(0) + + await service.startDictation('model-a', vi.fn(), undefined, 'desktop') + + expect(getCreatedWorkerCount()).toBe(2) + expect(getLastWorker()).not.toBe(firstWorker) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/speech/stt-service.ts b/src/main/speech/stt-service.ts index e31218645..e943565bf 100644 --- a/src/main/speech/stt-service.ts +++ b/src/main/speech/stt-service.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: speech worker ownership, warm reuse, and +timeout teardown must stay co-located so dictation lifecycle state cannot drift. */ import { Worker } from 'worker_threads' import { join } from 'path' import { app } from 'electron' @@ -232,26 +234,62 @@ export class SttService { const worker = this.worker worker.postMessage({ type: 'stop' }) + let forcedTeardown = false await new Promise((resolve) => { - const timeout = setTimeout(() => { - worker.terminate() + let settled = false + let timeout: ReturnType | null = null + + const cleanup = (): void => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + worker.off('message', onStopped) + } + + const finish = (): void => { + if (settled) { + return + } + settled = true + cleanup() resolve() - }, STOP_DICTATION_TIMEOUT_MS) + } const onStopped = (msg: { type: string; text?: string; error?: string }) => { if (msg.type === 'stopped') { - clearTimeout(timeout) - worker.off('message', onStopped) - resolve() + finish() } } + + timeout = setTimeout(() => { + if (settled) { + return + } + settled = true + forcedTeardown = true + cleanup() + // Why: a worker that cannot finish dictation is no longer reusable; do + // not keep it in the warm-worker slot or retain its message listeners. + worker.removeAllListeners() + void worker.terminate().finally(resolve) + }, STOP_DICTATION_TIMEOUT_MS) + worker.on('message', onStopped) }) if (this.worker === worker) { - this.activeOwner = null - this.eventSink = null - this.scheduleIdleTeardown() + if (forcedTeardown) { + this.worker = null + this.activeModelId = null + this.activeHotwordsFilePath = undefined + this.activeOwner = null + this.eventSink = null + } else { + this.activeOwner = null + this.eventSink = null + this.scheduleIdleTeardown() + } } }