fix(speech): release dictation window listeners after stop (#28260)

Release dictation window callbacks by their owner and identity when sessions finish or are replaced. Clean superseded, rejected and coalesced starts without removing a newer session callback or changing audio handling.

Co-authored-by: Matheus Breguêz <matbrgz@gmail.com>
This commit is contained in:
Neil
2026-10-13 18:32:56 -07:00
committed by GitHub
co-authored by Matheus Breguêz
parent 4f5ffcb429
commit bda694181f
2 changed files with 147 additions and 3 deletions
@@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events'
import type * as FsPromises from 'node:fs/promises'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type { ModelManager } from '../speech/model-manager'
@@ -33,6 +34,14 @@ const environment = vi.hoisted(
})
)
const fileAccess = vi.hoisted(() => ({ write: vi.fn(), unlink: vi.fn() }))
vi.mock('node:fs/promises', async (importOriginal) => ({
...(await importOriginal<typeof FsPromises>()),
writeFile: fileAccess.write,
unlink: fileAccess.unlink
}))
vi.mock('electron', () => ({
ipcMain: {
handle: (name: string, callback: SpeechHandler) => environment.handlers.set(name, callback)
@@ -44,7 +53,10 @@ vi.mock('electron', () => ({
}
}))
vi.mock('../speech/speech-runtime-service', () => ({
getSpeechModelManager: () => ({ getModelState: async () => ({ status: 'ready' }) }),
getSpeechModelManager: () => ({
getModelState: async () => ({ status: 'ready' }),
getModelsDir: () => 'unused-models'
}),
getSpeechSttService: () => environment.service
}))
vi.mock('../speech/openai-api-key-store', () => ({
@@ -95,6 +107,7 @@ vi.mock('node:worker_threads', async () => {
import { registerSpeechHandlers } from './speech'
import { SttService as SpeechService } from '../speech/stt-service'
import { handleSttWorkerFailure } from '../speech/stt-session-stop'
import { RuntimeMobileDictationController } from '../runtime/runtime-mobile-dictation-controller'
class TestWindow extends EventEmitter {
@@ -139,6 +152,8 @@ describe('speech worker exit ownership', () => {
environment.handlers.clear()
environment.workers = []
environment.readyOnInit = true
fileAccess.write.mockReset().mockResolvedValue(undefined)
fileAccess.unlink.mockReset().mockResolvedValue(undefined)
window = new TestWindow()
environment.window = window
const modelManager: Pick<ModelManager, 'getModelState' | 'getModelDir'> = {
@@ -160,6 +175,108 @@ describe('speech worker exit ownership', () => {
await service().prepareModelForDeletion('test-model')
})
it('bounds close listeners through repeated starts and stops with no remaining worker', async () => {
const unrelated = vi.fn()
window.on('closed', unrelated)
const counts: number[] = []
for (let cycle = 0; cycle < 12; cycle += 1) {
const session = String(cycle)
await start(session)
await start(session)
await stop(session)
worker().emit('exit', 0)
expect(service().isActive()).toBe(false)
await stop(session)
counts.push(window.listenerCount('closed'))
}
expect(counts).toEqual(Array.from({ length: 12 }, () => 1))
expect(window.listeners('closed')).toEqual([unrelated])
})
it('cleans a completed stop when the service has no worker and sends no report', async () => {
await start()
handleSttWorkerFailure(service()['state'])
expect(service().isActive()).toBe(false)
expect(window.listenerCount('closed')).toBe(1)
await stop()
expect(window.listenerCount('closed')).toBe(0)
expect(
window.webContents.send.mock.calls.filter(([name]) => name === 'speech:stopped')
).toHaveLength(0)
})
it('keeps the accepted startup hook when a same-owner start is coalesced', async () => {
environment.readyOnInit = false
const pendingStart = start()
await Promise.resolve()
await start()
expect(window.listenerCount('closed')).toBe(1)
worker().emit('message', { type: 'ready' })
await pendingStart
worker().emit('exit', 0)
expect(window.listenerCount('closed')).toBe(0)
expect(
window.webContents.send.mock.calls.filter(([name]) => name === 'speech:stopped')
).toHaveLength(1)
})
it('keeps the live hook after a rejected owner and lets window close stop it', async () => {
await start('live')
await expect(start('rejected')).rejects.toThrow('dictation_already_active')
expect(window.listenerCount('closed')).toBe(1)
window.destroyed = true
window.emit('closed')
await vi.waitFor(() => expect(service()['state'].activeOwner).toBeNull())
expect(window.listenerCount('closed')).toBe(0)
expect(service()['state'].eventSink).toBeNull()
})
it('preserves the active hook when another owner cannot stop it', async () => {
await start('live')
await expect(stop('other')).rejects.toThrow('dictation_owner_mismatch')
expect(window.listenerCount('closed')).toBe(1)
await stop('live')
expect(window.listenerCount('closed')).toBe(0)
})
it('does not let a delayed old stop remove the replacement hook', async () => {
await start('old')
const { promise, resolve } = Promise.withResolvers<void>()
const originalStop = service().stopDictation.bind(service())
vi.spyOn(service(), 'stopDictation').mockImplementation(async (owner, options) => {
await originalStop(owner, options)
await promise
})
const pendingStop = stop('old')
await vi.waitFor(() => expect(window.listenerCount('closed')).toBe(0))
await start('replacement')
expect(window.listenerCount('closed')).toBe(1)
resolve()
await pendingStop
expect(window.listenerCount('closed')).toBe(1)
await stop('replacement')
expect(window.listenerCount('closed')).toBe(0)
})
it('retains hotwords until startup finishes and preserves the live hook on write failure', async () => {
environment.readyOnInit = false
const pendingStart = handler('speech:startDictation')(sender, 'test-model', ['orca'], 'one')
await vi.waitFor(() => expect(environment.workers).toHaveLength(1))
expect(fileAccess.write).toHaveBeenCalledTimes(1)
expect(fileAccess.unlink).not.toHaveBeenCalled()
worker().emit('message', { type: 'ready' })
await pendingStart
expect(fileAccess.unlink).toHaveBeenCalledTimes(1)
fileAccess.write.mockRejectedValueOnce(new Error('write failed'))
await expect(
handler('speech:startDictation')(sender, 'test-model', ['different'], 'two')
).rejects.toThrow('write failed')
expect(window.listenerCount('closed')).toBe(1)
expect(fileAccess.unlink).toHaveBeenCalledTimes(1)
await stop()
expect(window.listenerCount('closed')).toBe(0)
})
it.each([0, 1])('releases the window listener after unexpected exit code %i', async (code) => {
for (let cycle = 0; cycle < 15; cycle++) {
await start(String(cycle))
+29 -2
View File
@@ -89,6 +89,8 @@ export function registerSpeechHandlers(store: Store): void {
const getDesktopOwner = (senderId: number, sessionId: string): string =>
`desktop:${senderId}:${sessionId}`
const dictationListeners = new Map<number, { owner: string; cleanup: () => void }>()
ipcMain.handle(
'speech:startDictation',
async (event, modelId: string, hotwords?: string[], sessionId = 'desktop') => {
@@ -98,9 +100,13 @@ export function registerSpeechHandlers(store: Store): void {
}
let resolvedHotwordsPath: string | undefined
let windowClosed = false
const owner = getDesktopOwner(event.sender.id, sessionId)
const senderId = event.sender.id
const owner = getDesktopOwner(senderId, sessionId)
let sessionListenerRemoved = false
let sessionReady = false
const cleanupOnWindowClosed = (): void => {
windowClosed = true
cleanupSessionListener()
void getSpeechSttService(store)
.stopDictation(owner)
.finally(() => {
@@ -111,7 +117,11 @@ export function registerSpeechHandlers(store: Store): void {
.catch(() => {})
}
const cleanupSessionListener = (): void => {
sessionListenerRemoved = true
window.off('closed', cleanupOnWindowClosed)
if (dictationListeners.get(senderId)?.cleanup === cleanupSessionListener) {
dictationListeners.delete(senderId)
}
}
window.once('closed', cleanupOnWindowClosed)
@@ -152,6 +162,9 @@ export function registerSpeechHandlers(store: Store): void {
await getSpeechSttService(store).startDictation(
modelId,
(msg) => {
if (msg.type === 'ready') {
sessionReady = true
}
if (window.isDestroyed()) {
return
}
@@ -181,6 +194,15 @@ export function registerSpeechHandlers(store: Store): void {
resolvedHotwordsPath,
owner
)
// Coalesced starts install no sink; only an accepted start supersedes the close hook.
if (sessionReady) {
dictationListeners.get(senderId)?.cleanup()
if (!sessionListenerRemoved) {
dictationListeners.set(senderId, { owner, cleanup: cleanupSessionListener })
}
} else {
cleanupSessionListener()
}
if (resolvedHotwordsPath) {
unlink(resolvedHotwordsPath).catch(() => {})
}
@@ -209,6 +231,11 @@ export function registerSpeechHandlers(store: Store): void {
)
ipcMain.handle('speech:stopDictation', async (_event, sessionId = 'desktop') => {
await getSpeechSttService(store).stopDictation(getDesktopOwner(_event.sender.id, sessionId))
const owner = getDesktopOwner(_event.sender.id, sessionId)
const listener = dictationListeners.get(_event.sender.id)
await getSpeechSttService(store).stopDictation(owner)
if (listener?.owner === owner) {
listener.cleanup()
}
})
}