diff --git a/src/main/crash-reporting/process-gone-classification.test.ts b/src/main/crash-reporting/process-gone-classification.test.ts index 78522b76617..c88b97daf2d 100644 --- a/src/main/crash-reporting/process-gone-classification.test.ts +++ b/src/main/crash-reporting/process-gone-classification.test.ts @@ -161,6 +161,16 @@ describe('shouldRecordProcessGoneCrash', () => { expectedTeardown: 'none' }) ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'gpu', + reason: 'crashed', + exitCode: -2147483645, + expectedTeardown: 'none', + gpuFallbackActive: false + }) + ).toBe(false) expect( shouldRecordProcessGoneCrash({ source: 'child', @@ -336,6 +346,60 @@ describe('shouldRecordProcessGoneCrash', () => { }) ).toBe(true) }) + + // Why: suppressing these is why an ineffective GPU fallback stayed invisible — + // 20 of 21 crashed launches on the reported machine already ran the fallback. + it('records GPU child crashes once the GPU fallback is already applied', () => { + for (const reason of ['crashed', 'abnormal-exit'] as const) { + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'GPU', + reason, + exitCode: -2147483645, + expectedTeardown: 'none', + gpuFallbackActive: true + }) + ).toBe(true) + } + }) + + it('keeps suppressing GPU exits shaped like teardown even under the fallback', () => { + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'gpu', + reason: 'killed', + exitCode: 15, + expectedTeardown: 'none', + gpuFallbackActive: true + }) + ).toBe(false) + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'gpu', + reason: 'crashed', + exitCode: -2147483645, + expectedTeardown: 'app-shutdown', + gpuFallbackActive: true + }) + ).toBe(false) + }) + + it('does not widen non-GPU child suppression when the fallback is active', () => { + expect( + shouldRecordProcessGoneCrash({ + source: 'child', + processType: 'Utility', + serviceName: 'network.mojom.NetworkService', + reason: 'crashed', + exitCode: -1, + expectedTeardown: 'none', + gpuFallbackActive: true + }) + ).toBe(false) + }) }) describe('shouldRecoverRendererAfterProcessGone', () => { diff --git a/src/main/crash-reporting/process-gone-classification.ts b/src/main/crash-reporting/process-gone-classification.ts index d3f363d1332..afd4915bf70 100644 --- a/src/main/crash-reporting/process-gone-classification.ts +++ b/src/main/crash-reporting/process-gone-classification.ts @@ -24,12 +24,16 @@ function isRecoverableChromiumChildProcess({ source, processType, serviceName, - reason + reason, + expectedTeardown, + gpuFallbackActive }: { source: ProcessGoneSource processType?: string serviceName?: string reason: string + expectedTeardown: ExpectedTeardownScope + gpuFallbackActive?: boolean }): boolean { if (source !== 'child') { return false @@ -39,7 +43,14 @@ function isRecoverableChromiumChildProcess({ } const normalizedProcessType = processType?.toLowerCase() if (normalizedProcessType && RECOVERABLE_CHILD_PROCESS_TYPES.has(normalizedProcessType)) { - return true + // Why: a GPU crash while the GPU fallback is already applied is not churn — + // the remediation failed, and suppressing it is why this was invisible. Quit + // still tears the GPU child down noisily, so app-shutdown stays suppressed. + return !( + gpuFallbackActive === true && + normalizedProcessType === 'gpu' && + expectedTeardown !== 'app-shutdown' + ) } return ( normalizedProcessType === 'utility' && @@ -54,7 +65,8 @@ export function shouldRecordProcessGoneCrash({ serviceName, reason, exitCode, - expectedTeardown + expectedTeardown, + gpuFallbackActive }: { source: ProcessGoneSource processType?: string @@ -62,10 +74,20 @@ export function shouldRecordProcessGoneCrash({ reason: string exitCode: number | null expectedTeardown: ExpectedTeardownScope + gpuFallbackActive?: boolean }): boolean { // Why: GPU, Network Service, and Audio Service exits are recoverable Chromium // child-process churn; treating them as app crashes creates noisy user prompts. - if (isRecoverableChromiumChildProcess({ source, processType, serviceName, reason })) { + if ( + isRecoverableChromiumChildProcess({ + source, + processType, + serviceName, + reason, + expectedTeardown, + gpuFallbackActive + }) + ) { return false } // Why: Electron reports intentional reload/update/quit teardown as `killed`. diff --git a/src/main/crash-reporting/process-gone-recorder.ts b/src/main/crash-reporting/process-gone-recorder.ts index 9d6c0edb1a8..b6a8abd8c9e 100644 --- a/src/main/crash-reporting/process-gone-recorder.ts +++ b/src/main/crash-reporting/process-gone-recorder.ts @@ -18,6 +18,7 @@ import { processGoneDedupe, type ProcessGoneDedupe } from './process-gone-dedupe' +import { getGpuInfoSnapshot } from './gpu-info-snapshot' import { getMainProcessLifecycleIdentity } from './main-process-lifecycle-identity' import { flushActiveSink, startSpan } from '../observability/tracer' @@ -28,6 +29,8 @@ export type ProcessGoneCrashEvent = { exitCode: number | null expectedTeardown: ExpectedTeardownScope details: Record + /** True when this launch already runs a GPU fallback tier, so GPU deaths stop counting as churn. */ + gpuFallbackActive?: boolean } type CrashReportRecorderStore = Pick @@ -65,7 +68,8 @@ export function recordProcessGoneCrash( typeof event.details.serviceName === 'string' ? event.details.serviceName : undefined, reason: event.reason, exitCode: event.exitCode, - expectedTeardown: event.expectedTeardown + expectedTeardown: event.expectedTeardown, + gpuFallbackActive: event.gpuFallbackActive }) ) { recordDurableCrashBreadcrumb('process_gone_suppressed', processGoneBreadcrumbData(event)) @@ -86,8 +90,15 @@ export function recordProcessGoneCrash( return } const mainProcessLifecycle = getMainProcessLifecycleIdentity() + // Why: GPU and renderer deaths are the ones triage needs driver identity for; + // every other child type would just pad the report. + const gpuIdentity = + event.source === 'renderer' || event.processType.toLowerCase() === 'gpu' + ? (getGpuInfoSnapshot() ?? {}) + : {} const crashDetails = buildProcessGoneCrashDetails({ ...event.details, + ...gpuIdentity, ...mainProcessLifecycle }) const breadcrumbs = getCrashBreadcrumbSnapshot() diff --git a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx index 0f263bf5310..37c28a3e870 100644 --- a/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx +++ b/src/renderer/src/components/crash-report/CrashReportDialogSurface.tsx @@ -15,6 +15,7 @@ import { Label } from '@/components/ui/label' import { useMountedRef } from '@/hooks/useMountedRef' import { formatCrashReportText, + isGpuFallbackCrashReport, isReactErrorBoundaryReport, type CrashReportDiagnosticBundle, type CrashReportRecord @@ -34,17 +35,23 @@ function formatSummary(report: CrashReportRecord): string { const surface = typeof report.details.surface === 'string' ? report.details.surface : null return surface ? `React render error in ${surface}` : 'React render error' } - return `${report.processType} ${report.reason}${ + const summary = `${report.processType} ${report.reason}${ report.exitCode === null ? '' : ` (exit ${report.exitCode})` }` + return isGpuFallbackCrashReport(report) + ? `${summary} — graphics fallback tier ${String(report.details.gpuFallbackTier)}` + : summary } function getDialogTitle(report: CrashReportRecord | null): string { if (!report) { return 'Report a crash' } - return report && isReactErrorBoundaryReport(report) - ? 'Orca hit a recoverable UI error' + if (isReactErrorBoundaryReport(report)) { + return 'Orca hit a recoverable UI error' + } + return isGpuFallbackCrashReport(report) + ? 'Orca is running with reduced graphics acceleration' : 'Orca closed unexpectedly' } @@ -52,8 +59,11 @@ function getDialogDescription(report: CrashReportRecord | null): string { if (!report) { return 'Send a privacy-safe crash report. Recent redacted diagnostic logs are included when available.' } - return report && isReactErrorBoundaryReport(report) - ? 'Send a privacy-safe diagnostic report to help us understand the failed UI surface.' + if (isReactErrorBoundaryReport(report)) { + return 'Send a privacy-safe diagnostic report to help us understand the failed UI surface.' + } + return isGpuFallbackCrashReport(report) + ? "Your graphics driver keeps crashing Orca's GPU process, so Orca disabled hardware acceleration and restarted. Sending this privacy-safe report tells us which driver is affected." : 'Send a privacy-safe diagnostic report to help us understand what happened.' } @@ -61,8 +71,11 @@ function getNotesPlaceholder(report: CrashReportRecord | null): string { if (!report) { return 'Optional: what happened?' } - return report && isReactErrorBoundaryReport(report) - ? 'Optional: what were you doing before this UI error?' + if (isReactErrorBoundaryReport(report)) { + return 'Optional: what were you doing before this UI error?' + } + return isGpuFallbackCrashReport(report) + ? 'Optional: does anything still look wrong after the restart?' : 'Optional: what were you doing before Orca closed?' } diff --git a/src/shared/crash-reporting.ts b/src/shared/crash-reporting.ts index 52afaab45b5..776aefdc150 100644 --- a/src/shared/crash-reporting.ts +++ b/src/shared/crash-reporting.ts @@ -163,6 +163,21 @@ export function isReactErrorBoundaryReport(report: CrashReportRecord): boolean { ) } +/** + * A GPU child crash that happened while a GPU fallback tier was already applied. + * + * Orca is still running here — it relaunched itself into a degraded GPU path — + * so the dialog must not claim the app closed unexpectedly. + */ +export function isGpuFallbackCrashReport(report: CrashReportRecord): boolean { + return ( + report.source === 'child' && + report.processType.toLowerCase() === 'gpu' && + typeof report.details.gpuFallbackTier === 'number' && + report.details.gpuFallbackTier > 0 + ) +} + export function sanitizeCrashReportString( value: string, maxLength = MAX_STRING_DETAIL_LENGTH