mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
@@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user