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 <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-25 12:15:19 -07:00
co-authored by Orca
parent b16f7a74c7
commit 97498926da
5 changed files with 337 additions and 67 deletions
+92 -11
View File
@@ -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<void> = Promise.resolve()
let localPtyProviderStartupReady: Promise<void> = 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({
+16 -1
View File
@@ -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'
+10 -2
View File
@@ -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(',')
+107 -16
View File
@@ -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()
})
+112 -37
View File
@@ -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<keyof GpuFallbackMarker, unknown>
>
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<Record<keyof GpuFallbackMarker, unknown>>
try {
parsed = JSON.parse(contents) as Partial<Record<keyof GpuFallbackMarker, unknown>>
} 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 }
}