mirror of
https://github.com/stablyai/orca.git
synced 2026-10-19 08:03:13 +00:00
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:
+92
-11
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(',')
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user