From 97498926daed1f6f8d4a2ddcf301db64f2c1a4c9 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sat, 25 Jul 2026 12:15:19 -0700 Subject: [PATCH] fix(gpu): escalate the GPU fallback instead of latching on a tier that keeps crashing handleGpuChildCrash returned immediately whenever the fallback was already active, so a machine that crashes with --disable-gpu had no path forward: Orca relaunched into the same broken configuration, 21 of 25 launches never reached the renderer, and nothing said the remediation was not working. - climb one ladder rung per relaunch, bounded per build; at the top emit gpu_fallback_exhausted and keep running degraded rather than loop - emit gpu_fallback_ineffective when a crash burst arrives under an active tier - purge GPU/shader caches before relaunching into the new tier - capture app.getGPUInfo('complete') once, bounded, into crash details Marker hardening: an unreadable marker (EPERM/EBUSY/EISDIR) is now preserved rather than deleted, which is what silently re-armed hardware acceleration mid-crash-loop; only a parseable-but-obsolete marker is cleared, every clear breadcrumbs its reason, and writes go through a temp file plus rename so a marker written during the crash burst cannot be read back torn. Read and write both use getCanonicalUserDataPath(), since the read runs pre-whenReady and the write runs after app.setName() changes how userData resolves. IntensiveWakeUpThrottling moves out of the GPU-gated path - fallback users were also losing timely agent-done/bell notifications for an unrelated reason. Co-authored-by: Orca --- src/main/index.ts | 103 +++++++++++-- src/main/startup/configure-process.test.ts | 17 ++- src/main/startup/configure-process.ts | 12 +- src/main/startup/gpu-fallback-marker.test.ts | 123 +++++++++++++-- src/main/startup/gpu-fallback-marker.ts | 149 ++++++++++++++----- 5 files changed, 337 insertions(+), 67 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index d3d2a29a5c9..02af86af296 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -76,6 +76,7 @@ import { configureElectronNetworkCompatibility, configureDevUserDataPath, configureOrcaUserDataPathEnv, + disableIntensiveWakeUpThrottling, enableMainProcessGpuFeatures, installDevParentDisconnectQuit, installDevParentSignalQuit, @@ -96,6 +97,14 @@ import { type GpuFallbackEnvironment, type WindowsGpuFallbackEnvironment } from './startup/gpu-fallback-marker' +import { + NO_GPU_FALLBACK_TIER, + getGpuFallbackTierSwitches, + getNextGpuFallbackTier, + type GpuFallbackTier +} from './startup/gpu-fallback-tiers' +import { purgeGpuCaches } from './startup/gpu-cache-purge' +import { captureGpuInfoSnapshot } from './crash-reporting/gpu-info-snapshot' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, @@ -296,7 +305,9 @@ const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({ windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD }) -let gpuFallbackActiveThisLaunch = false +// Why: 0 = hardware path. A non-zero tier is the rung of the fallback ladder this launch is running. +let gpuFallbackTierThisLaunch: GpuFallbackTier | typeof NO_GPU_FALLBACK_TIER = NO_GPU_FALLBACK_TIER +const GPU_INFO_CAPTURE_TIMEOUT_MS = 10_000 let localPtyStartupReady: Promise = Promise.resolve() let localPtyProviderStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 @@ -663,9 +674,11 @@ if (hasSingleInstanceLock) { configureElectronNetworkCompatibility() enableRendererHeapHeadroom() maybeApplyGpuFallbackForThisLaunch() - if (!gpuFallbackActiveThisLaunch) { + if (gpuFallbackTierThisLaunch === NO_GPU_FALLBACK_TIER) { enableMainProcessGpuFeatures() } + // Why: timer throttling is unrelated to the GPU; the fallback path used to drop it too. + disableIntensiveWakeUpThrottling() // Why: headless serve's offscreen BrowserWindows need an X display (Xvfb) on Linux; the result gates whether the offscreen backend is installed. headlessBrowserDisplayAvailable = ensureVirtualDisplayForHeadlessServe({ isServeMode }) } @@ -1364,6 +1377,12 @@ function getGpuFallbackEnvironment(): GpuFallbackEnvironment { } } +// Why: the marker is read pre-whenReady and written post-app.setName('Orca'), and a late +// app.getPath('userData') resolves differently — the two must agree or the marker "vanishes". +function getGpuFallbackUserDataPath(): string { + return getCanonicalUserDataPath() +} + function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | null { const environment = getGpuFallbackEnvironment() if (environment.platform !== 'win32') { @@ -1377,31 +1396,73 @@ function maybeApplyGpuFallbackForThisLaunch(): void { if (isServeMode || process.platform !== 'win32') { return } - const marker = readActiveGpuFallbackMarker(app.getPath('userData'), getGpuFallbackEnvironment()) + const { marker, cleared, unreadableErrorCode } = readActiveGpuFallbackMarker( + getGpuFallbackUserDataPath(), + getGpuFallbackEnvironment() + ) + if (cleared) { + // Why: a vanished marker silently re-arms hardware acceleration; that must be visible in triage. + recordCrashBreadcrumb('gpu_fallback_marker_cleared', { reason: cleared }) + } + if (unreadableErrorCode) { + recordCrashBreadcrumb('gpu_fallback_marker_unreadable', { errorCode: unreadableErrorCode }) + } if (!marker) { return } app.disableHardwareAcceleration() - app.commandLine.appendSwitch('disable-gpu') - gpuFallbackActiveThisLaunch = true + for (const { name, value } of getGpuFallbackTierSwitches(marker.tier)) { + if (value === undefined) { + app.commandLine.appendSwitch(name) + } else { + app.commandLine.appendSwitch(name, value) + } + } + gpuFallbackTierThisLaunch = marker.tier recordCrashBreadcrumb('gpu_fallback_applied', { + tier: marker.tier, crashesInWindow: marker.crashesInWindow }) } -// Why: a burst of GPU child crashes right after launch means HW acceleration is unusable — persist a build-scoped marker and relaunch into software rendering. +// Why: a burst of GPU child crashes right after launch means the current GPU path is unusable — persist a build-scoped tier and relaunch one rung higher. function handleGpuChildCrash(reason: string, exitCode: number | null): void { - // Software rendering already active or shutting down: nothing more to do. - if (gpuFallbackActiveThisLaunch || isQuitting || isServeMode) { + if (isQuitting || isServeMode) { return } const result = gpuCrashFallbackTracker.recordGpuCrash(Date.now() - gpuLaunchTimeMs) if (!result.shouldEngageFallback) { return } + const currentTier = gpuFallbackTierThisLaunch + const nextTier = getNextGpuFallbackTier(currentTier) + 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. + recordCrashBreadcrumb('gpu_fallback_ineffective', { + reason, + exitCode, + tier: currentTier, + nextTier: nextTier ?? null, + crashesInWindow: result.crashesInWindow + }) + } + if (nextTier === null) { + // Why: the ladder is bounded per build — keep running degraded rather than + // relaunch into a tier that already failed. + recordCrashBreadcrumb('gpu_fallback_exhausted', { + reason, + exitCode, + tier: currentTier, + crashesInWindow: result.crashesInWindow + }) + return + } recordCrashBreadcrumb('gpu_fallback_engaged', { reason, exitCode, + tier: nextTier, + previousTier: currentTier, crashesInWindow: result.crashesInWindow }) const engagedAt = Date.now() @@ -1409,12 +1470,14 @@ function handleGpuChildCrash(reason: string, exitCode: number | null): void { if (!environment) { return } + const userDataPath = getGpuFallbackUserDataPath() try { writeGpuFallbackMarker( - app.getPath('userData'), + userDataPath, { engagedAt, - crashesInWindow: result.crashesInWindow + crashesInWindow: result.crashesInWindow, + tier: nextTier }, environment ) @@ -1422,10 +1485,19 @@ function handleGpuChildCrash(reason: string, exitCode: number | null): void { console.warn('[gpu-fallback] failed to persist marker:', error) return } + // Why: a shader cache written by the driver that just crashed replays the same + // crash on the next GPU init, defeating the tier we just escalated to. + const purge = purgeGpuCaches(userDataPath) + recordCrashBreadcrumb('gpu_cache_purged', { + removed: purge.removed.join(','), + failed: purge.failed.join(',') + }) isQuitting = true relaunchApp('gpu-fallback', { processReason: reason, exitCode, + tier: nextTier, + previousTier: currentTier, crashesInWindow: result.crashesInWindow }) app.exit(0) @@ -1444,6 +1516,7 @@ function recordProcessGoneCrash( processType, reason, exitCode, + gpuFallbackActive: gpuFallbackTierThisLaunch !== NO_GPU_FALLBACK_TIER, expectedTeardown: getExpectedTeardownScope(webContentsId), details }) @@ -1778,6 +1851,13 @@ function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { 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) + } + ) // Why: install certificate decisions before any webview or headless window issues its first TLS request. app.on( 'certificate-error', @@ -2197,7 +2277,8 @@ app.whenReady().then(async () => { recordProcessGoneCrash('child', details.type, details.reason, details.exitCode ?? null, { name: details.name, serviceName: details.serviceName, - type: details.type + type: details.type, + gpuFallbackTier: gpuFallbackTierThisLaunch }) if ( isGpuFallbackCrashCandidate({ diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 7d582880f28..4bf695c5c0d 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -373,7 +373,9 @@ describe('enableMainProcessGpuFeatures', () => { expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-unsafe-webgpu') }) - it('opts hidden pages out of intensive wake-up throttling', async () => { + // Why: throttling is unrelated to the GPU; it must not ride the GPU-gated path, + // or the win32 GPU fallback silently costs users timely agent-done notifications. + it('does not opt out of wake-up throttling from the GPU path', async () => { const { app } = await import('electron') const { enableMainProcessGpuFeatures } = await import('./configure-process') @@ -381,6 +383,19 @@ describe('enableMainProcessGpuFeatures', () => { vi.mocked(app.commandLine.appendSwitch).mockClear() enableMainProcessGpuFeatures() + expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith( + 'disable-features', + expect.stringContaining('IntensiveWakeUpThrottling') + ) + }) + + it('opts hidden pages out of intensive wake-up throttling independently', async () => { + const { app } = await import('electron') + const { disableIntensiveWakeUpThrottling } = await import('./configure-process') + + vi.mocked(app.commandLine.appendSwitch).mockClear() + disableIntensiveWakeUpThrottling() + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( 'disable-features', 'IntensiveWakeUpThrottling' diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 40ec5e2efbe..35fff995efc 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -293,10 +293,18 @@ export function enableMainProcessGpuFeatures(): void { if (features) { app.commandLine.appendSwitch('enable-features', features) } +} +/** + * Timer behavior, deliberately separate from GPU setup. + * + * IntensiveWakeUpThrottling clamps hidden-page timers to 1/min after 5min, + * delaying agent-done/bell notifications ~60s. This used to live inside + * enableMainProcessGpuFeatures(), so machines on the GPU fallback path lost + * timely notifications for a reason that has nothing to do with the GPU. + */ +export function disableIntensiveWakeUpThrottling(): void { const existingDisabledFeatures = app.commandLine.getSwitchValue('disable-features') - // Why: IntensiveWakeUpThrottling clamps hidden-page timers to 1/min after 5min, delaying agent-done/bell notifications ~60s. - // This opt-out is skipped under GPU fallback (win32-only today); if throttling ever reaches Windows it must move out of this path. const disabledFeatures = ['IntensiveWakeUpThrottling', existingDisabledFeatures] .filter(Boolean) .join(',') diff --git a/src/main/startup/gpu-fallback-marker.test.ts b/src/main/startup/gpu-fallback-marker.test.ts index c52fc3c1f11..3d7c42d9913 100644 --- a/src/main/startup/gpu-fallback-marker.test.ts +++ b/src/main/startup/gpu-fallback-marker.test.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -7,6 +7,7 @@ import { clearGpuFallbackMarker, readActiveGpuFallbackMarker, readGpuFallbackMarker, + readGpuFallbackMarkerResult, writeGpuFallbackMarker } from './gpu-fallback-marker' @@ -27,62 +28,118 @@ describe('gpu-fallback-marker', () => { }) it('round-trips a written marker', () => { - writeGpuFallbackMarker(userDataPath, { engagedAt: 123, crashesInWindow: 3 }, environment) + writeGpuFallbackMarker( + userDataPath, + { engagedAt: 123, crashesInWindow: 3, tier: 2 }, + environment + ) expect(readGpuFallbackMarker(userDataPath)).toEqual({ - schemeVersion: 2, + schemeVersion: 3, engagedAt: 123, crashesInWindow: 3, + tier: 2, appVersion: '1.2.3', electronVersion: '42.3.3', platform: 'win32' }) }) + it('leaves no temp file behind after an atomic write', () => { + writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 3, tier: 1 }, environment) + expect(existsSync(join(userDataPath, `${GPU_FALLBACK_MARKER_FILE}.tmp`))).toBe(false) + }) + + it('overwrites an existing marker when escalating tiers', () => { + writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 3, tier: 1 }, environment) + writeGpuFallbackMarker(userDataPath, { engagedAt: 2, crashesInWindow: 4, tier: 3 }, environment) + expect(readGpuFallbackMarker(userDataPath)?.tier).toBe(3) + }) + it('returns null when no marker exists', () => { expect(readGpuFallbackMarker(userDataPath)).toBeNull() - expect(readActiveGpuFallbackMarker(userDataPath, environment)).toBeNull() + expect(readGpuFallbackMarkerResult(userDataPath)).toEqual({ status: 'absent' }) + expect(readActiveGpuFallbackMarker(userDataPath, environment)).toEqual({ + marker: null, + cleared: null, + unreadableErrorCode: null + }) }) it('keeps an active marker for repeated launches on the same build', () => { - writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment) + writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4, tier: 2 }, environment) expect(existsSync(join(userDataPath, GPU_FALLBACK_MARKER_FILE))).toBe(true) const firstRead = readActiveGpuFallbackMarker(userDataPath, environment) - expect(firstRead?.crashesInWindow).toBe(4) + expect(firstRead.marker?.crashesInWindow).toBe(4) + expect(firstRead.marker?.tier).toBe(2) expect(existsSync(join(userDataPath, GPU_FALLBACK_MARKER_FILE))).toBe(true) - const secondRead = readActiveGpuFallbackMarker(userDataPath, environment) - expect(secondRead?.crashesInWindow).toBe(4) + expect(readActiveGpuFallbackMarker(userDataPath, environment).marker?.tier).toBe(2) + }) + + it('clamps an out-of-range persisted tier onto the ladder', () => { + writeFileSync( + join(userDataPath, GPU_FALLBACK_MARKER_FILE), + JSON.stringify({ + schemeVersion: 3, + engagedAt: 1, + crashesInWindow: 3, + tier: 99, + appVersion: '1.2.3', + electronVersion: '42.3.3', + platform: 'win32' + }) + ) + expect(readGpuFallbackMarker(userDataPath)?.tier).toBe(3) + }) + + it('defaults a marker with no tier to the first rung', () => { + writeFileSync( + join(userDataPath, GPU_FALLBACK_MARKER_FILE), + JSON.stringify({ + schemeVersion: 3, + engagedAt: 1, + crashesInWindow: 3, + appVersion: '1.2.3', + electronVersion: '42.3.3', + platform: 'win32' + }) + ) + expect(readGpuFallbackMarker(userDataPath)?.tier).toBe(1) }) it('clears an active marker when the app build changes', () => { - writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment) + writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4, tier: 1 }, environment) expect( readActiveGpuFallbackMarker(userDataPath, { ...environment, appVersion: '1.2.4' }) - ).toBeNull() + ).toEqual({ marker: null, cleared: 'stale-build', unreadableErrorCode: null }) expect(existsSync(join(userDataPath, GPU_FALLBACK_MARKER_FILE))).toBe(false) }) it('clears an active marker outside Windows', () => { - writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment) + writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4, tier: 1 }, environment) expect( readActiveGpuFallbackMarker(userDataPath, { ...environment, platform: 'linux' }) - ).toBeNull() + ).toEqual({ marker: null, cleared: 'non-windows', unreadableErrorCode: null }) expect(existsSync(join(userDataPath, GPU_FALLBACK_MARKER_FILE))).toBe(false) }) it('clears a corrupt or wrong-version marker', () => { writeFileSync(join(userDataPath, GPU_FALLBACK_MARKER_FILE), '{ not json') - expect(readGpuFallbackMarker(userDataPath)).toBeNull() - expect(readActiveGpuFallbackMarker(userDataPath, environment)).toBeNull() + expect(readGpuFallbackMarkerResult(userDataPath)).toEqual({ status: 'invalid' }) + expect(readActiveGpuFallbackMarker(userDataPath, environment)).toEqual({ + marker: null, + cleared: 'invalid', + unreadableErrorCode: null + }) expect(existsSync(join(userDataPath, GPU_FALLBACK_MARKER_FILE))).toBe(false) writeFileSync( @@ -90,12 +147,46 @@ describe('gpu-fallback-marker', () => { JSON.stringify({ schemeVersion: 999, engagedAt: 1, crashesInWindow: 1 }) ) expect(readGpuFallbackMarker(userDataPath)).toBeNull() - expect(readActiveGpuFallbackMarker(userDataPath, environment)).toBeNull() + expect(readActiveGpuFallbackMarker(userDataPath, environment).cleared).toBe('invalid') expect(existsSync(join(userDataPath, GPU_FALLBACK_MARKER_FILE))).toBe(false) }) + // Why: deleting an unreadable marker silently re-armed hardware acceleration on + // machines that crash on GPU init — the crash loop this whole path exists for. + it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'preserves a present-but-unreadable marker', + () => { + const markerFile = join(userDataPath, GPU_FALLBACK_MARKER_FILE) + writeGpuFallbackMarker( + userDataPath, + { engagedAt: 1, crashesInWindow: 4, tier: 2 }, + environment + ) + chmodSync(markerFile, 0o000) + try { + const result = readActiveGpuFallbackMarker(userDataPath, environment) + expect(result.marker).toBeNull() + expect(result.cleared).toBeNull() + expect(result.unreadableErrorCode).toBe('EACCES') + expect(existsSync(markerFile)).toBe(true) + } finally { + chmodSync(markerFile, 0o600) + } + } + ) + + it('reports a directory-shaped marker as unreadable rather than deleting it', () => { + const markerFile = join(userDataPath, GPU_FALLBACK_MARKER_FILE) + mkdirSync(markerFile) + const result = readActiveGpuFallbackMarker(userDataPath, environment) + expect(result.marker).toBeNull() + expect(result.cleared).toBeNull() + expect(result.unreadableErrorCode).toBe('EISDIR') + expect(existsSync(markerFile)).toBe(true) + }) + it('can explicitly clear the marker', () => { - writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4 }, environment) + writeGpuFallbackMarker(userDataPath, { engagedAt: 1, crashesInWindow: 4, tier: 1 }, environment) clearGpuFallbackMarker(userDataPath) expect(readGpuFallbackMarker(userDataPath)).toBeNull() }) diff --git a/src/main/startup/gpu-fallback-marker.ts b/src/main/startup/gpu-fallback-marker.ts index e94e654b3ec..79733fc92f2 100644 --- a/src/main/startup/gpu-fallback-marker.ts +++ b/src/main/startup/gpu-fallback-marker.ts @@ -1,17 +1,23 @@ -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' +import { clampGpuFallbackTier, type GpuFallbackTier } from './gpu-fallback-tiers' /** - * Persisted "disable hardware acceleration for this build" marker. + * Persisted "degrade the GPU path for this build" marker. * * Why a standalone file (not the Store): app.disableHardwareAcceleration() must * be called before app.whenReady() resolves, but the settings Store is only * constructed inside whenReady. A tiny JSON marker in userData can be read * synchronously during early startup, mirroring windows-user-data-acl.ts. + * + * The marker is only ever removed when it is provably obsolete. A marker that + * exists but cannot be read (EPERM/EBUSY/EACCES) is preserved: deleting it + * silently re-armed hardware acceleration on machines that crash on GPU init. */ export const GPU_FALLBACK_MARKER_FILE = 'gpu-fallback.json' -export const GPU_FALLBACK_SCHEME_VERSION = 2 +const GPU_FALLBACK_MARKER_TEMP_FILE = 'gpu-fallback.json.tmp' +export const GPU_FALLBACK_SCHEME_VERSION = 3 export type GpuFallbackEnvironment = { appVersion: string @@ -25,62 +31,122 @@ export type GpuFallbackMarker = { schemeVersion: number engagedAt: number crashesInWindow: number + tier: GpuFallbackTier appVersion: string electronVersion: string platform: 'win32' } +export type GpuFallbackMarkerReadResult = + | { status: 'ok'; marker: GpuFallbackMarker } + | { status: 'absent' } + | { status: 'invalid' } + | { status: 'unreadable'; errorCode: string } + +/** Why the marker did not apply, so callers can breadcrumb the clear instead of losing it silently. */ +export type GpuFallbackMarkerClearedReason = 'invalid' | 'stale-build' | 'non-windows' + +export type ActiveGpuFallbackMarkerResult = { + marker: GpuFallbackMarker | null + cleared: GpuFallbackMarkerClearedReason | null + /** Set when the marker exists but could not be read; the file is kept on disk. */ + unreadableErrorCode: string | null +} + +const ABSENT_ERROR_CODES = new Set(['ENOENT', 'ENOTDIR']) + function markerPath(userDataPath: string): string { return join(userDataPath, GPU_FALLBACK_MARKER_FILE) } -export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker | null { +function errorCode(error: unknown): string | undefined { + return typeof error === 'object' && error !== null && 'code' in error + ? String((error as { code: unknown }).code) + : undefined +} + +export function readGpuFallbackMarkerResult(userDataPath: string): GpuFallbackMarkerReadResult { + let contents: string try { - const parsed = JSON.parse(readFileSync(markerPath(userDataPath), 'utf-8')) as Partial< - Record - > - if (parsed.schemeVersion !== GPU_FALLBACK_SCHEME_VERSION) { - return null + contents = readFileSync(markerPath(userDataPath), 'utf-8') + } catch (error) { + const code = errorCode(error) + if (code !== undefined && ABSENT_ERROR_CODES.has(code)) { + return { status: 'absent' } } - if ( - typeof parsed.engagedAt !== 'number' || - !Number.isFinite(parsed.engagedAt) || - typeof parsed.crashesInWindow !== 'number' || - !Number.isFinite(parsed.crashesInWindow) || - typeof parsed.appVersion !== 'string' || - typeof parsed.electronVersion !== 'string' || - parsed.platform !== 'win32' - ) { - return null - } - return { + return { status: 'unreadable', errorCode: code ?? 'UNKNOWN' } + } + + let parsed: Partial> + try { + parsed = JSON.parse(contents) as Partial> + } catch { + return { status: 'invalid' } + } + if (parsed === null || typeof parsed !== 'object') { + return { status: 'invalid' } + } + if (parsed.schemeVersion !== GPU_FALLBACK_SCHEME_VERSION) { + return { status: 'invalid' } + } + if ( + typeof parsed.engagedAt !== 'number' || + !Number.isFinite(parsed.engagedAt) || + typeof parsed.crashesInWindow !== 'number' || + !Number.isFinite(parsed.crashesInWindow) || + typeof parsed.appVersion !== 'string' || + typeof parsed.electronVersion !== 'string' || + parsed.platform !== 'win32' + ) { + return { status: 'invalid' } + } + return { + status: 'ok', + marker: { schemeVersion: GPU_FALLBACK_SCHEME_VERSION, engagedAt: parsed.engagedAt, crashesInWindow: parsed.crashesInWindow, + tier: clampGpuFallbackTier(parsed.tier), appVersion: parsed.appVersion, electronVersion: parsed.electronVersion, platform: parsed.platform } - } catch { - // missing or corrupt means no fallback requested } - return null +} + +export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker | null { + const result = readGpuFallbackMarkerResult(userDataPath) + return result.status === 'ok' ? result.marker : null } export function writeGpuFallbackMarker( userDataPath: string, - info: { engagedAt: number; crashesInWindow: number }, + info: { engagedAt: number; crashesInWindow: number; tier: GpuFallbackTier }, environment: WindowsGpuFallbackEnvironment ): void { const marker: GpuFallbackMarker = { schemeVersion: GPU_FALLBACK_SCHEME_VERSION, engagedAt: info.engagedAt, crashesInWindow: info.crashesInWindow, + tier: info.tier, appVersion: environment.appVersion, electronVersion: environment.electronVersion, platform: 'win32' } - writeFileSync(markerPath(userDataPath), JSON.stringify(marker)) + // Why: this write races the crash burst that triggered it — a torn marker + // reads as invalid and silently re-arms hardware acceleration next launch. + const tempPath = join(userDataPath, GPU_FALLBACK_MARKER_TEMP_FILE) + try { + writeFileSync(tempPath, JSON.stringify(marker)) + renameSync(tempPath, markerPath(userDataPath)) + } catch (error) { + try { + rmSync(tempPath, { force: true }) + } catch { + // best effort; a stray temp file is inert + } + throw error + } } export function clearGpuFallbackMarker(userDataPath: string): void { @@ -94,24 +160,33 @@ export function clearGpuFallbackMarker(userDataPath: string): void { export function readActiveGpuFallbackMarker( userDataPath: string, environment: GpuFallbackEnvironment -): GpuFallbackMarker | null { - const marker = readGpuFallbackMarker(userDataPath) - if (!marker) { - if (existsSync(markerPath(userDataPath))) { - clearGpuFallbackMarker(userDataPath) - } - return null +): ActiveGpuFallbackMarkerResult { + const result = readGpuFallbackMarkerResult(userDataPath) + if (result.status === 'absent') { + return { marker: null, cleared: null, unreadableErrorCode: null } + } + if (result.status === 'unreadable') { + // Why: keep the file. A transient EPERM/EBUSY must not be read as "this + // machine is healthy" — that is what turned the fallback into a crash loop. + return { marker: null, cleared: null, unreadableErrorCode: result.errorCode } + } + if (result.status === 'invalid') { + clearGpuFallbackMarker(userDataPath) + return { marker: null, cleared: 'invalid', unreadableErrorCode: null } + } + const { marker } = result + if (environment.platform !== 'win32' || marker.platform !== environment.platform) { + clearGpuFallbackMarker(userDataPath) + return { marker: null, cleared: 'non-windows', unreadableErrorCode: null } } if ( - environment.platform !== 'win32' || - marker.platform !== environment.platform || marker.appVersion !== environment.appVersion || marker.electronVersion !== environment.electronVersion ) { // Why: the marker is sticky only for the build that observed the driver // crash burst; updates get one fresh hardware attempt automatically. clearGpuFallbackMarker(userDataPath) - return null + return { marker: null, cleared: 'stale-build', unreadableErrorCode: null } } - return marker + return { marker, cleared: null, unreadableErrorCode: null } }