fix: tear down timed-out speech workers (#2880)

This commit is contained in:
Neil 2026-05-26 20:24:25 -07:00 committed by GitHub
parent 03e43ef165
commit b9165ac4bd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 80 additions and 9 deletions

View File

@ -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()
}
})
})

View File

@ -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<void>((resolve) => {
const timeout = setTimeout(() => {
worker.terminate()
let settled = false
let timeout: ReturnType<typeof setTimeout> | 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()
}
}
}