fix: time out stt worker startup (#3848)

This commit is contained in:
Neil 2026-05-30 12:19:16 -07:00 committed by GitHub
parent f5d00526e8
commit c4e931e806
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 54 additions and 16 deletions

View File

@ -98,7 +98,7 @@ vi.mock('./model-catalog', () => ({
})
}))
import { IDLE_WORKER_TEARDOWN_MS, SttService } from './stt-service'
import { IDLE_WORKER_TEARDOWN_MS, START_DICTATION_TIMEOUT_MS, SttService } from './stt-service'
describe('SttService', () => {
beforeEach(() => {
@ -205,6 +205,36 @@ describe('SttService', () => {
)
})
it('times out startup when the worker never reports ready', 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)
MockWorker.emitReadyOnInit = false
const startPromise = service.startDictation('model-a', vi.fn(), undefined, 'desktop').then(
() => 'resolved',
(error) => (error instanceof Error ? error.message : String(error))
)
await Promise.resolve()
const worker = getLastWorker()
expect(worker).toBeDefined()
await vi.advanceTimersByTimeAsync(START_DICTATION_TIMEOUT_MS)
const outcome = await Promise.race([startPromise, Promise.resolve('pending')])
expect(outcome).toBe('Speech worker timed out while starting.')
expect(worker!.terminated).toBe(true)
expect(worker!.listenerCount('message')).toBe(0)
expect(worker!.listenerCount('error')).toBe(0)
expect(worker!.listenerCount('exit')).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('does not treat internal warm-worker replacement as startup cancellation', async () => {
const service = new SttService({
getModelState: vi.fn().mockResolvedValue({ id: 'model-a', status: 'ready' }),

View File

@ -6,6 +6,7 @@ import { app } from 'electron'
import { getCatalogModel } from './model-catalog'
import type { ModelManager } from './model-manager'
export const START_DICTATION_TIMEOUT_MS = 60_000
const STOP_DICTATION_TIMEOUT_MS = 60_000
export const IDLE_WORKER_TEARDOWN_MS = 60 * 60 * 1000
@ -112,11 +113,24 @@ export class SttService {
const readyPromise = new Promise<void>((resolve, reject) => {
let settled = false
let startupTimeout: ReturnType<typeof setTimeout> | null = null
const cleanup = () => {
if (startupTimeout) {
clearTimeout(startupTimeout)
startupTimeout = null
}
worker.off('message', onReadyOrError)
worker.off('error', onStartupError)
worker.off('exit', onStartupExit)
}
const failStartup = (error: Error): void => {
if (settled) {
return
}
settled = true
cleanup()
reject(error)
}
const onReadyOrError = (msg: { type: string; text?: string; error?: string }) => {
if (settled) {
return
@ -126,30 +140,24 @@ export class SttService {
cleanup()
resolve()
} else if (msg.type === 'error') {
settled = true
cleanup()
reject(new Error(msg.error ?? 'Speech worker failed to initialize'))
failStartup(new Error(msg.error ?? 'Speech worker failed to initialize'))
}
}
const onStartupError = (err: Error) => {
if (settled) {
return
}
settled = true
cleanup()
reject(err)
failStartup(err)
}
const onStartupExit = (code: number) => {
if (settled) {
return
}
settled = true
cleanup()
reject(new Error(`Speech worker exited before ready: ${code}`))
failStartup(new Error(`Speech worker exited before ready: ${code}`))
}
worker.on('message', onReadyOrError)
worker.on('error', onStartupError)
worker.on('exit', onStartupExit)
// Why: a native STT worker can wedge while loading model bindings without
// emitting ready/error/exit; startup must leave the UI's Starting state.
startupTimeout = setTimeout(() => {
failStartup(new Error('Speech worker timed out while starting.'))
}, START_DICTATION_TIMEOUT_MS)
startupTimeout.unref?.()
})
worker.on('message', (msg: SttEvent) => {