refactor(gpu): extract escalation resolution and budget fallback crash reports

Moves the "resume from history vs. escalate one rung" decision out of
index.ts into a pure resolveGpuFallbackEscalation, so the post-update
resume path is testable without Electron.

Also bounds GPU crash reports to 3 per launch while the fallback is
active. Un-suppressing these crashes is the point, but an unbounded loop
would rewrite crash-reports.json every dedupe window for the rest of the
session; the first few carry all the triage signal.
This commit is contained in:
Jinjing
2026-07-25 14:05:36 -07:00
parent d4e4f7fc1e
commit ce261fcd6b
5 changed files with 242 additions and 12 deletions
@@ -9,7 +9,11 @@ vi.mock('electron', () => ({
import { clearCrashBreadcrumbsForTest, getCrashBreadcrumbSnapshot } from './crash-breadcrumb-store'
import { ProcessGoneDedupe } from './process-gone-dedupe'
import { recordProcessGoneCrash, type ProcessGoneCrashEvent } from './process-gone-recorder'
import {
recordProcessGoneCrash,
resetGpuFallbackCrashReportBudgetForTesting,
type ProcessGoneCrashEvent
} from './process-gone-recorder'
import { _resetTracerForTests, setActiveSink, type TracerSink } from '../observability/tracer'
type CapturingSink = TracerSink & { records: unknown[]; flushMock: ReturnType<typeof vi.fn> }
@@ -44,6 +48,7 @@ beforeEach(() => {
sink = capturingSink()
setActiveSink(sink)
clearCrashBreadcrumbsForTest()
resetGpuFallbackCrashReportBudgetForTesting()
})
afterEach(() => {
@@ -206,6 +211,129 @@ describe('recordProcessGoneCrash', () => {
)
})
// Why: a GPU crash loop under the fallback must not rewrite crash-reports.json
// every dedupe window for the rest of the session.
it('caps GPU-under-fallback reports per launch and durably records the overflow', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report' })
const dedupe = new ProcessGoneDedupe()
const gpuCrash = (exitCode: number): ProcessGoneCrashEvent =>
event({
source: 'child',
processType: 'GPU',
reason: 'crashed',
exitCode,
gpuFallbackActive: true,
details: { type: 'GPU' }
})
for (const exitCode of [1, 2, 3]) {
recordProcessGoneCrash({ record } as never, gpuCrash(exitCode), dedupe)
}
await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(3))
recordProcessGoneCrash({ record } as never, gpuCrash(4), dedupe)
expect(record).toHaveBeenCalledTimes(3)
expect(getCrashBreadcrumbSnapshot()).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'process_gone_suppressed',
data: expect.objectContaining({ suppressedBy: 'gpu_fallback_report_budget' })
})
])
)
})
it('does not spend GPU-fallback budget on deduped repeats', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report' })
const dedupe = new ProcessGoneDedupe()
const gpuCrash = (exitCode: number): ProcessGoneCrashEvent =>
event({
source: 'child',
processType: 'GPU',
reason: 'crashed',
exitCode,
gpuFallbackActive: true,
details: { type: 'GPU' }
})
recordProcessGoneCrash({ record } as never, gpuCrash(1), dedupe)
recordProcessGoneCrash({ record } as never, gpuCrash(2), dedupe)
recordProcessGoneCrash({ record } as never, gpuCrash(2), dedupe)
recordProcessGoneCrash({ record } as never, gpuCrash(3), dedupe)
await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(3))
expect(getCrashBreadcrumbSnapshot()).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({ suppressedBy: 'gpu_fallback_report_budget' })
})
])
)
})
it('releases GPU-fallback budget when persistence fails', async () => {
const record = vi
.fn()
.mockRejectedValueOnce(new Error('disk unavailable'))
.mockResolvedValue({ id: 'report' })
const dedupe = new ProcessGoneDedupe()
vi.spyOn(console, 'error').mockImplementation(() => {})
const gpuCrash = (exitCode: number): ProcessGoneCrashEvent =>
event({
source: 'child',
processType: 'GPU',
reason: 'crashed',
exitCode,
gpuFallbackActive: true,
details: { type: 'GPU' }
})
recordProcessGoneCrash({ record } as never, gpuCrash(1), dedupe)
await vi.waitFor(() =>
expect(getCrashBreadcrumbSnapshot()).toEqual(
expect.arrayContaining([expect.objectContaining({ name: 'crash_report_persist_failed' })])
)
)
for (const exitCode of [2, 3, 4]) {
recordProcessGoneCrash({ record } as never, gpuCrash(exitCode), dedupe)
}
await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(4))
expect(getCrashBreadcrumbSnapshot()).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
data: expect.objectContaining({ suppressedBy: 'gpu_fallback_report_budget' })
})
])
)
})
it('still records renderer crashes after the GPU-fallback budget is spent', async () => {
const record = vi.fn().mockResolvedValue({ id: 'report' })
const dedupe = new ProcessGoneDedupe()
for (const exitCode of [1, 2, 3]) {
recordProcessGoneCrash(
{ record } as never,
event({
source: 'child',
processType: 'GPU',
reason: 'crashed',
exitCode,
gpuFallbackActive: true,
details: { type: 'GPU' }
}),
dedupe
)
}
await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(3))
recordProcessGoneCrash({ record } as never, event({ gpuFallbackActive: true }), dedupe)
await vi.waitFor(() => expect(record).toHaveBeenCalledTimes(4))
})
it('allows the same renderer crash to retry after persistence fails', async () => {
const record = vi
.fn()
@@ -35,6 +35,24 @@ export type ProcessGoneCrashEvent = {
type CrashReportRecorderStore = Pick<CrashReportStore, 'record'>
// Why: a GPU crash loop under the fallback would rewrite crash-reports.json every
// dedupe window for the rest of the session; the first few reports carry all the
// triage signal (driver identity, tier), the rest are pure disk churn.
const MAX_GPU_FALLBACK_CRASH_REPORTS_PER_LAUNCH = 3
let gpuFallbackCrashReportsThisLaunch = 0
export function resetGpuFallbackCrashReportBudgetForTesting(): void {
gpuFallbackCrashReportsThisLaunch = 0
}
function countsAgainstGpuFallbackReportBudget(event: ProcessGoneCrashEvent): boolean {
return (
event.gpuFallbackActive === true &&
event.source === 'child' &&
event.processType.toLowerCase() === 'gpu'
)
}
function processGoneBreadcrumbData(event: ProcessGoneCrashEvent) {
return buildSuppressedProcessGoneBreadcrumbData(event)
}
@@ -84,11 +102,26 @@ export function recordProcessGoneCrash(
return
}
const gpuFallbackBudgeted = countsAgainstGpuFallbackReportBudget(event)
if (
gpuFallbackBudgeted &&
gpuFallbackCrashReportsThisLaunch >= MAX_GPU_FALLBACK_CRASH_REPORTS_PER_LAUNCH
) {
recordDurableCrashBreadcrumb('process_gone_suppressed', {
...processGoneBreadcrumbData(event),
suppressedBy: 'gpu_fallback_report_budget'
})
return
}
const key = getProcessGoneDedupeKey(event.source, event.processType, event.reason, event.exitCode)
const claim = dedupe.tryClaim(key)
if (!claim) {
return
}
if (gpuFallbackBudgeted) {
gpuFallbackCrashReportsThisLaunch += 1
}
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.
@@ -145,6 +178,9 @@ export function recordProcessGoneCrash(
})
.catch((error) => {
dedupe.release(claim)
if (gpuFallbackBudgeted) {
gpuFallbackCrashReportsThisLaunch -= 1
}
console.error('[crash-reporting] Failed to persist crash report:', error)
const data = persistFailureData(event, error)
recordDurableCrashBreadcrumb(
+14 -10
View File
@@ -100,7 +100,7 @@ import {
import {
NO_GPU_FALLBACK_TIER,
getGpuFallbackTierSwitches,
getNextGpuFallbackTier,
resolveGpuFallbackEscalation,
type GpuFallbackTier
} from './startup/gpu-fallback-tiers'
import { purgeGpuCaches } from './startup/gpu-cache-purge'
@@ -1443,9 +1443,9 @@ function handleGpuChildCrash(reason: string, exitCode: number | null): void {
// Why: after an update the build-scoped marker is gone but the machine is not.
// Resuming at the tier it already needed is the difference between one relaunch
// per update and re-walking the whole ladder every time.
const resumeTier =
currentTier === NO_GPU_FALLBACK_TIER ? getResumeGpuFallbackTier(userDataPath) : null
const nextTier = resumeTier ?? getNextGpuFallbackTier(currentTier)
const { nextTier, resumedFromHistory } = resolveGpuFallbackEscalation(currentTier, () =>
getResumeGpuFallbackTier(userDataPath)
)
if (currentTier !== NO_GPU_FALLBACK_TIER) {
// Why: 20 of 21 crashed launches on the reported machine already carried
// gpu_fallback_applied. Without this breadcrumb the failure looked like a fix.
@@ -1473,7 +1473,7 @@ function handleGpuChildCrash(reason: string, exitCode: number | null): void {
exitCode,
tier: nextTier,
previousTier: currentTier,
resumedFromHistory: resumeTier !== null,
resumedFromHistory,
crashesInWindow: result.crashesInWindow
})
const engagedAt = Date.now()
@@ -1869,11 +1869,15 @@ app.whenReady().then(async () => {
logStartupMilestone('app-ready')
// Why: driver identity for crash details. Fire-and-forget and bounded — on the
// machines this exists for, the GPU process never initializes and this never resolves.
void captureGpuInfoSnapshot(() => app.getGPUInfo('complete'), GPU_INFO_CAPTURE_TIMEOUT_MS).then(
(snapshot) => {
recordCrashBreadcrumb('gpu_info_captured', snapshot)
}
)
// Skipped in headless serve: no crash dialog to feed, and 'complete' can provoke a
// GPU child that Xvfb environments have no reason to spawn.
if (!isServeMode) {
void captureGpuInfoSnapshot(() => app.getGPUInfo('complete'), GPU_INFO_CAPTURE_TIMEOUT_MS).then(
(snapshot) => {
recordCrashBreadcrumb('gpu_info_captured', snapshot)
}
)
}
// Why: install certificate decisions before any webview or headless window issues its first TLS request.
app.on(
'certificate-error',
+41 -1
View File
@@ -4,7 +4,8 @@ import {
clampGpuFallbackTier,
getGpuFallbackTierSwitches,
getNextGpuFallbackTier,
isGpuFallbackTier
isGpuFallbackTier,
resolveGpuFallbackEscalation
} from './gpu-fallback-tiers'
describe('gpu-fallback-tiers', () => {
@@ -63,3 +64,42 @@ describe('gpu-fallback-tiers', () => {
}
})
})
describe('resolveGpuFallbackEscalation', () => {
it('starts a machine with no history at the bottom of the ladder', () => {
expect(resolveGpuFallbackEscalation(0, () => null)).toEqual({
nextTier: 1,
resumedFromHistory: false
})
})
// Why: this is what turns "unusable after every update" into one relaunch per update.
it('resumes a hardware launch at the tier the machine already needed', () => {
expect(resolveGpuFallbackEscalation(0, () => 2)).toEqual({
nextTier: 2,
resumedFromHistory: true
})
expect(resolveGpuFallbackEscalation(0, () => 1)).toEqual({
nextTier: 1,
resumedFromHistory: true
})
})
it('ignores history once a tier is applied and escalates one rung', () => {
let historyReads = 0
expect(
resolveGpuFallbackEscalation(1, () => {
historyReads += 1
return 2
})
).toEqual({ nextTier: 2, resumedFromHistory: false })
expect(historyReads).toBe(0)
})
it('reports an exhausted ladder at the top rung', () => {
expect(resolveGpuFallbackEscalation(2, () => 2)).toEqual({
nextTier: null,
resumedFromHistory: false
})
})
})
+22
View File
@@ -72,3 +72,25 @@ export function getNextGpuFallbackTier(currentTier: number): GpuFallbackTier | n
const next = Math.trunc(currentTier) + 1
return isGpuFallbackTier(next) ? next : null
}
export type GpuFallbackEscalation = {
nextTier: GpuFallbackTier | null
resumedFromHistory: boolean
}
/**
* Tier for the relaunch after a crash burst. History (the strongest tier this
* machine ever needed) is consulted only when no tier is applied this launch —
* i.e. the post-update hardware probe just failed. Once on the ladder,
* escalation is strictly one rung at a time.
*/
export function resolveGpuFallbackEscalation(
currentTier: number,
readRequiredTierHistory: () => GpuFallbackTier | null
): GpuFallbackEscalation {
const resumeTier = currentTier === NO_GPU_FALLBACK_TIER ? readRequiredTierHistory() : null
return {
nextTier: resumeTier ?? getNextGpuFallbackTier(currentTier),
resumedFromHistory: resumeTier !== null
}
}