diff --git a/src/main/ipc/speech-worker-exit-lifetime.test.ts b/src/main/ipc/speech-worker-exit-lifetime.test.ts index dc0cfb4a210..63d0e16b402 100644 --- a/src/main/ipc/speech-worker-exit-lifetime.test.ts +++ b/src/main/ipc/speech-worker-exit-lifetime.test.ts @@ -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()), + 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 = { @@ -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() + 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)) diff --git a/src/main/ipc/speech.ts b/src/main/ipc/speech.ts index bd0ea494ea0..185a90047ae 100644 --- a/src/main/ipc/speech.ts +++ b/src/main/ipc/speech.ts @@ -89,6 +89,8 @@ export function registerSpeechHandlers(store: Store): void { const getDesktopOwner = (senderId: number, sessionId: string): string => `desktop:${senderId}:${sessionId}` + const dictationListeners = new Map 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() + } }) }