fix(crash-reporting): stop showing "Orca closed unexpectedly" after the speech engine crashes (#39430)

* fix(crash-reporting): don't report a speech process exit as an Orca crash

* fix(crash-reporting): keep every speech process exit out of crash reports, including OOM
This commit is contained in:
Kelvin Amoaba
2026-11-07 09:32:52 +00:00
committed by GitHub
parent 5adbcac511
commit fc18a6be45
5 changed files with 73 additions and 8 deletions
@@ -1,3 +1,5 @@
import { STT_UTILITY_PROCESS_NAME } from '../speech/stt-worker-process'
export type ProcessGoneSource = 'renderer' | 'child'
export type ExpectedTeardownScope = 'none' | 'renderer-reload' | 'app-shutdown'
@@ -11,9 +13,22 @@ const RECOVERABLE_UTILITY_SERVICE_NAMES = new Set([
// Screen-capture service exits are recoverable Chromium churn.
'video_capture.mojom.VideoCaptureService'
])
// Orca's own utility processes whose exits the app already handles and reports in place.
const RECOVERABLE_ORCA_UTILITY_NAMES = new Set([STT_UTILITY_PROCESS_NAME])
const RECOVERABLE_CHILD_PROCESS_REASONS = new Set(['abnormal-exit', 'crashed', 'killed'])
const NON_RECOVERABLE_RENDERER_REASONS = new Set(['integrity-failure'])
/** Electron's child-process-gone names: a utility's mojo service, and its fork serviceName. */
export function childProcessNames(details: Record<string, unknown>): {
serviceName?: string
name?: string
} {
return {
serviceName: typeof details.serviceName === 'string' ? details.serviceName : undefined,
name: typeof details.name === 'string' ? details.name : undefined
}
}
function isWindowsControlTerminationExitCode(exitCode: number | null): boolean {
if (exitCode === null) {
return false
@@ -21,24 +36,34 @@ function isWindowsControlTerminationExitCode(exitCode: number | null): boolean {
return WINDOWS_CONTROL_TERMINATION_EXIT_CODES.has(exitCode >>> 0)
}
function isRecoverableChromiumChildProcess({
function isRecoverableChildProcess({
source,
processType,
serviceName,
name,
reason
}: {
source: ProcessGoneSource
processType?: string
serviceName?: string
name?: string
reason: string
}): boolean {
if (source !== 'child') {
return false
}
const normalizedProcessType = processType?.toLowerCase()
// Orca handles every exit of its own utilities, including OOM and failed launches.
if (
normalizedProcessType === 'utility' &&
name !== undefined &&
RECOVERABLE_ORCA_UTILITY_NAMES.has(name)
) {
return true
}
if (!RECOVERABLE_CHILD_PROCESS_REASONS.has(reason)) {
return false
}
const normalizedProcessType = processType?.toLowerCase()
if (normalizedProcessType && RECOVERABLE_CHILD_PROCESS_TYPES.has(normalizedProcessType)) {
return true
}
@@ -54,6 +79,7 @@ export function shouldRecordProcessGoneCrash({
source,
processType,
serviceName,
name,
reason,
exitCode,
expectedTeardown
@@ -62,11 +88,12 @@ export function shouldRecordProcessGoneCrash({
source: ProcessGoneSource
processType?: string
serviceName?: string
name?: string
reason: string
exitCode: number | null
expectedTeardown: ExpectedTeardownScope
}): boolean {
if (isRecoverableChromiumChildProcess({ source, processType, serviceName, reason })) {
if (isRecoverableChildProcess({ source, processType, serviceName, name, reason })) {
return false
}
// Intentional teardown uses killed; explicit crash and OOM reasons remain reportable.
@@ -64,6 +64,40 @@ afterEach(() => {
})
describe('recordProcessGoneCrash', () => {
it('reports no crash when the speech process dies, since dictation already shows the error', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
const utility = (name: string, reason: string, exitCode: number) =>
event({
source: 'child',
processType: 'Utility',
reason,
exitCode,
details: { name, serviceName: 'node.mojom.NodeService', type: 'Utility' }
})
for (const [reason, exitCode] of [
['killed', 9],
['crashed', 11],
['oom', 1]
] as const) {
recordProcessGoneCrash(
{ record, attachDetails } as never,
utility('Orca Speech', reason, exitCode),
new ProcessGoneDedupe(),
noMinidump
)
}
recordProcessGoneCrash(
{ record, attachDetails } as never,
utility('Some Other Service', 'crashed', 11),
new ProcessGoneDedupe(),
noMinidump
)
await vi.waitFor(() => expect(record).toHaveBeenCalledOnce())
expect(record.mock.calls[0][0].details).toMatchObject({ name: 'Some Other Service' })
})
it('still persists the report when the forced trace flush fails', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report-1' })
sink.flushMock.mockImplementation(() => {
@@ -19,6 +19,7 @@ import {
trackRendererSiblingAttribution
} from './process-gone-sibling-attribution'
import {
childProcessNames,
shouldRecordProcessGoneCrash,
type ExpectedTeardownScope,
type ProcessGoneSource
@@ -163,13 +164,12 @@ export function recordProcessGoneCrash(
return
}
const goneAt = Date.now()
const serviceName =
typeof event.details.serviceName === 'string' ? event.details.serviceName : undefined
const names = childProcessNames(event.details)
if (event.source === 'child') {
correlateChildProcessDeath({
at: goneAt,
processType: event.processType,
...(serviceName ? { serviceName } : {}),
...(names.serviceName ? { serviceName: names.serviceName } : {}),
reason: event.reason,
exitCode: event.exitCode
})
@@ -181,7 +181,7 @@ export function recordProcessGoneCrash(
platform: process.platform,
source: event.source,
processType: event.processType,
serviceName,
...names,
reason: event.reason,
exitCode: event.exitCode,
expectedTeardown: event.expectedTeardown
+3
View File
@@ -3,6 +3,9 @@ import type { MessagePortMain } from 'electron'
import type { WorkerRequestTransport } from '../worker-threads/worker-thread-transport'
import type { RecognizerConfigInput } from './stt-worker-model-config'
/** Electron reports it as the utility process's name when it exits. */
export const STT_UTILITY_PROCESS_NAME = 'Orca Speech'
export const STT_WORKER_EXITED_ERROR = 'The speech engine stopped unexpectedly. Try again.'
export const STT_WORKER_HUNG_ERROR = 'The speech engine stopped responding. Try again.'
@@ -2,6 +2,7 @@ import { utilityProcess } from 'electron'
import { getSttWorkerPath } from './stt-worker-paths'
import {
logSttWorkerStderr,
STT_UTILITY_PROCESS_NAME,
SttWorkerChildProcess,
type SttWorkerProcess
} from './stt-worker-process'
@@ -9,7 +10,7 @@ import {
/** A native crash or hang in the recognizer stays out of the app's main process. */
export function spawnSttUtilityProcess(): SttWorkerProcess {
const child = utilityProcess.fork(getSttWorkerPath(), [], {
serviceName: 'Orca Speech',
serviceName: STT_UTILITY_PROCESS_NAME,
stdio: ['ignore', 'ignore', 'pipe']
})
const worker = new SttWorkerChildProcess({