diff --git a/src/main/index.ts b/src/main/index.ts index 61c7d799f94b..5f9855bfbdc7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -129,12 +129,21 @@ import { import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom' import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display' import { + markGpuFallbackUserNotified, readActiveGpuFallbackMarker, writeGpuFallbackMarker, type GpuFallbackEnvironment, type WindowsGpuFallbackEnvironment } from './startup/gpu-fallback-marker' import { applyGpuFallbackCommandLineSwitches } from './startup/gpu-fallback-switches' +import { + applyWindowsSoftwareGpuFallback, + buildSoftwareGpuFallbackNotice, + isSoftwareGpuEnvRequested, + shouldPresentSoftwareGpuFallbackNotice, + WINDOWS_SOFTWARE_GPU_SWITCHES, + type SoftwareGpuFallbackSource +} from './startup/windows-software-gpu' import { DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, @@ -376,6 +385,9 @@ const gpuCrashFallbackTracker = new GpuCrashFallbackTracker({ }) let gpuFallbackActiveThisLaunch = false let gpuFeatureStatus: Electron.GPUFeatureStatus | null = null +let gpuFallbackSourceThisLaunch: SoftwareGpuFallbackSource | null = null +let gpuFallbackMarkerAlreadyNotified = false +let gpuFallbackNoticePresentedThisSession = false let localPtyStartupReady: Promise = Promise.resolve() let localPtyProviderStartupReady: Promise = Promise.resolve() const AGENT_STATE_CRASH_BREADCRUMB_MIN_INTERVAL_MS = 30_000 @@ -1293,6 +1305,16 @@ function openMainWindow(): BrowserWindow { window.once('ready-to-show', () => { logStartupMilestone('ready-to-show') setImmediate(createSystemTrayDeferred) + // Why: inform once after first paint so software-GPU recovery is not silent (#10097 review). + setImmediate(() => { + void maybePresentSoftwareGpuFallbackNotice(window) + }) + }) + // Why: ready-to-show can stall on broken GPUs; the reveal fallback still emits show. + window.once('show', () => { + setImmediate(() => { + void maybePresentSoftwareGpuFallbackNotice(window) + }) }) const trayCreateFallback = setTimeout(createSystemTrayDeferred, TRAY_CREATE_FALLBACK_MS) trayCreateFallback.unref?.() @@ -1570,26 +1592,102 @@ function getWindowsGpuFallbackEnvironment(): WindowsGpuFallbackEnvironment | nul return { ...environment, platform: 'win32' } } -// Why: read the GPU-fallback marker before app.whenReady() so app.disableHardwareAcceleration() takes effect. Windows desktop only. +// Why: software GPU flags must be set before app.whenReady(); marker is sticky for this build, ORCA_SOFTWARE_GPU opts in without a crash burst. function maybeApplyGpuFallbackForThisLaunch(): void { if (isServeMode || process.platform !== 'win32') { return } - const marker = readActiveGpuFallbackMarker(app.getPath('userData'), getGpuFallbackEnvironment()) - if (!marker) { + const envRequested = isSoftwareGpuEnvRequested() + const marker = envRequested + ? null + : readActiveGpuFallbackMarker(app.getPath('userData'), getGpuFallbackEnvironment()) + if (!envRequested && !marker) { return } - app.disableHardwareAcceleration() - const appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) + let appliedSwitches: readonly string[] = [] + if (envRequested) { + // Why: #10093 — operator first-launch opt-in for virtual displays (ORCA_SOFTWARE_GPU). + // Post-crash marker path keeps main's non-SwiftShader switches (security: in-process + no untrusted WebGL parse). + applyWindowsSoftwareGpuFallback(app) + appliedSwitches = WINDOWS_SOFTWARE_GPU_SWITCHES.map((flag) => + 'value' in flag && flag.value !== undefined ? `${flag.name}=${flag.value}` : flag.name + ) + } else { + app.disableHardwareAcceleration() + appliedSwitches = applyGpuFallbackCommandLineSwitches(app.commandLine, process.platform) + } gpuFallbackActiveThisLaunch = true + gpuFallbackSourceThisLaunch = envRequested ? 'env' : 'marker' + gpuFallbackMarkerAlreadyNotified = + typeof marker?.userNotifiedAt === 'number' && Number.isFinite(marker.userNotifiedAt) // Why: with no GPU child left, child-process-gone can't report a GPU fault, so // name the applied switches in the trail any later crash report carries. recordCrashBreadcrumb('gpu_fallback_applied', { - crashesInWindow: marker.crashesInWindow, + crashesInWindow: marker?.crashesInWindow ?? 0, + source: gpuFallbackSourceThisLaunch, switches: appliedSwitches.join(',') }) } +// Why: software GPU is a silent recovery path unless we tell the user once (marker sticky / env per session). +async function maybePresentSoftwareGpuFallbackNotice( + targetWindow?: BrowserWindow | null +): Promise { + if ( + !shouldPresentSoftwareGpuFallbackNotice({ + active: gpuFallbackActiveThisLaunch, + source: gpuFallbackSourceThisLaunch, + alreadyPresentedThisSession: gpuFallbackNoticePresentedThisSession, + markerAlreadyNotified: gpuFallbackMarkerAlreadyNotified + }) || + isQuitting + ) { + return + } + const source = gpuFallbackSourceThisLaunch + if (!source) { + return + } + gpuFallbackNoticePresentedThisSession = true + const notice = buildSoftwareGpuFallbackNotice(source) + const options = { + type: 'info' as const, + buttons: ['OK'], + defaultId: 0, + title: notice.title, + message: notice.message, + detail: notice.detail + } + try { + const window = + targetWindow && !targetWindow.isDestroyed() + ? targetWindow + : mainWindow && !mainWindow.isDestroyed() + ? mainWindow + : undefined + if (window) { + await dialog.showMessageBox(window, options) + } else { + await dialog.showMessageBox(options) + } + } catch (error) { + console.warn('[gpu-fallback] failed to present software GPU notice:', error) + // Why: allow a later show/ready-to-show retry if the first dialog attempt failed. + gpuFallbackNoticePresentedThisSession = false + return + } + if (source === 'marker') { + try { + if (markGpuFallbackUserNotified(app.getPath('userData'))) { + gpuFallbackMarkerAlreadyNotified = true + } + } catch (error) { + console.warn('[gpu-fallback] failed to persist notice acknowledgment:', error) + } + } + recordCrashBreadcrumb('gpu_fallback_notice_presented', { source }) +} + // Why: a burst of GPU child crashes means HW acceleration is unusable — persist a build-scoped marker and offer software rendering. async function handleGpuChildCrash(reason: string, exitCode: number | null): Promise { // Software rendering already active or shutting down: nothing more to do. diff --git a/src/main/startup/gpu-fallback-marker.test.ts b/src/main/startup/gpu-fallback-marker.test.ts index 6aeacf8ec3ed..96705051d842 100644 --- a/src/main/startup/gpu-fallback-marker.test.ts +++ b/src/main/startup/gpu-fallback-marker.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { GPU_FALLBACK_MARKER_FILE, clearGpuFallbackMarker, + markGpuFallbackUserNotified, readActiveGpuFallbackMarker, readGpuFallbackMarker, writeGpuFallbackMarker @@ -114,4 +115,23 @@ describe('gpu-fallback-marker', () => { clearGpuFallbackMarker(userDataPath) expect(readGpuFallbackMarker(userDataPath)).toBeNull() }) + + it('round-trips optional userNotifiedAt and is idempotent', () => { + writeGpuFallbackMarker(userDataPath, { engagedAt: 10, crashesInWindow: 3 }, environment) + expect(readGpuFallbackMarker(userDataPath)?.userNotifiedAt).toBeUndefined() + + expect(markGpuFallbackUserNotified(userDataPath, 999)).toBe(true) + expect(readGpuFallbackMarker(userDataPath)).toMatchObject({ + engagedAt: 10, + crashesInWindow: 3, + userNotifiedAt: 999 + }) + + expect(markGpuFallbackUserNotified(userDataPath, 1_000_000)).toBe(true) + expect(readGpuFallbackMarker(userDataPath)?.userNotifiedAt).toBe(999) + }) + + it('returns false when marking notify without a marker', () => { + expect(markGpuFallbackUserNotified(userDataPath)).toBe(false) + }) }) diff --git a/src/main/startup/gpu-fallback-marker.ts b/src/main/startup/gpu-fallback-marker.ts index e94e654b3ec0..246fea7e4dbe 100644 --- a/src/main/startup/gpu-fallback-marker.ts +++ b/src/main/startup/gpu-fallback-marker.ts @@ -28,12 +28,18 @@ export type GpuFallbackMarker = { appVersion: string electronVersion: string platform: 'win32' + /** When set, the user was already told software GPU is active for this marker. */ + userNotifiedAt?: number } function markerPath(userDataPath: string): string { return join(userDataPath, GPU_FALLBACK_MARKER_FILE) } +function readOptionalFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker | null { try { const parsed = JSON.parse(readFileSync(markerPath(userDataPath), 'utf-8')) as Partial< @@ -53,13 +59,15 @@ export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker | ) { return null } + const userNotifiedAt = readOptionalFiniteNumber(parsed.userNotifiedAt) return { schemeVersion: GPU_FALLBACK_SCHEME_VERSION, engagedAt: parsed.engagedAt, crashesInWindow: parsed.crashesInWindow, appVersion: parsed.appVersion, electronVersion: parsed.electronVersion, - platform: parsed.platform + platform: parsed.platform, + ...(userNotifiedAt !== undefined ? { userNotifiedAt } : {}) } } catch { // missing or corrupt means no fallback requested @@ -69,20 +77,50 @@ export function readGpuFallbackMarker(userDataPath: string): GpuFallbackMarker | export function writeGpuFallbackMarker( userDataPath: string, - info: { engagedAt: number; crashesInWindow: number }, + info: { engagedAt: number; crashesInWindow: number; userNotifiedAt?: number }, environment: WindowsGpuFallbackEnvironment ): void { + const userNotifiedAt = readOptionalFiniteNumber(info.userNotifiedAt) const marker: GpuFallbackMarker = { schemeVersion: GPU_FALLBACK_SCHEME_VERSION, engagedAt: info.engagedAt, crashesInWindow: info.crashesInWindow, appVersion: environment.appVersion, electronVersion: environment.electronVersion, - platform: 'win32' + platform: 'win32', + ...(userNotifiedAt !== undefined ? { userNotifiedAt } : {}) } writeFileSync(markerPath(userDataPath), JSON.stringify(marker)) } +/** Persist that the software-GPU notice was shown for the active sticky marker. */ +export function markGpuFallbackUserNotified( + userDataPath: string, + notifiedAt: number = Date.now() +): boolean { + const marker = readGpuFallbackMarker(userDataPath) + if (!marker) { + return false + } + if (readOptionalFiniteNumber(marker.userNotifiedAt) !== undefined) { + return true + } + writeGpuFallbackMarker( + userDataPath, + { + engagedAt: marker.engagedAt, + crashesInWindow: marker.crashesInWindow, + userNotifiedAt: notifiedAt + }, + { + appVersion: marker.appVersion, + electronVersion: marker.electronVersion, + platform: 'win32' + } + ) + return true +} + export function clearGpuFallbackMarker(userDataPath: string): void { try { rmSync(markerPath(userDataPath), { force: true }) diff --git a/src/main/startup/windows-software-gpu.test.ts b/src/main/startup/windows-software-gpu.test.ts new file mode 100644 index 000000000000..984203b3a4b2 --- /dev/null +++ b/src/main/startup/windows-software-gpu.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { + WINDOWS_SOFTWARE_GPU_SWITCHES, + applyWindowsSoftwareGpuFallback, + buildSoftwareGpuFallbackNotice, + isSoftwareGpuEnvRequested, + shouldPresentSoftwareGpuFallbackNotice +} from './windows-software-gpu' + +function createAppMock() { + return { + disableHardwareAcceleration: vi.fn(), + commandLine: { + appendSwitch: vi.fn() + } + } +} + +describe('isSoftwareGpuEnvRequested', () => { + it('accepts common truthy ORCA_SOFTWARE_GPU values', () => { + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: '1' })).toBe(true) + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: 'true' })).toBe(true) + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: 'YES' })).toBe(true) + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: ' 1 ' })).toBe(true) + }) + + it('rejects missing or non-truthy values', () => { + expect(isSoftwareGpuEnvRequested({})).toBe(false) + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: '0' })).toBe(false) + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: 'false' })).toBe(false) + expect(isSoftwareGpuEnvRequested({ ORCA_SOFTWARE_GPU: '' })).toBe(false) + }) +}) + +describe('applyWindowsSoftwareGpuFallback', () => { + it('disables hardware acceleration and applies the #10093 software combo', () => { + const app = createAppMock() + applyWindowsSoftwareGpuFallback(app) + + expect(app.disableHardwareAcceleration).toHaveBeenCalledTimes(1) + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('disable-gpu') + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('in-process-gpu') + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('use-angle', 'swiftshader') + }) + + it('covers every documented software switch exactly once', () => { + const app = createAppMock() + applyWindowsSoftwareGpuFallback(app) + + expect(WINDOWS_SOFTWARE_GPU_SWITCHES.map((s) => s.name)).toEqual([ + 'disable-gpu', + 'in-process-gpu', + 'use-angle' + ]) + expect(app.commandLine.appendSwitch).toHaveBeenCalledTimes(WINDOWS_SOFTWARE_GPU_SWITCHES.length) + }) +}) + +describe('shouldPresentSoftwareGpuFallbackNotice', () => { + it('presents for env source once per session', () => { + expect( + shouldPresentSoftwareGpuFallbackNotice({ + active: true, + source: 'env', + alreadyPresentedThisSession: false, + markerAlreadyNotified: false + }) + ).toBe(true) + expect( + shouldPresentSoftwareGpuFallbackNotice({ + active: true, + source: 'env', + alreadyPresentedThisSession: true, + markerAlreadyNotified: false + }) + ).toBe(false) + }) + + it('presents marker fallback only on first activation', () => { + expect( + shouldPresentSoftwareGpuFallbackNotice({ + active: true, + source: 'marker', + alreadyPresentedThisSession: false, + markerAlreadyNotified: false + }) + ).toBe(true) + expect( + shouldPresentSoftwareGpuFallbackNotice({ + active: true, + source: 'marker', + alreadyPresentedThisSession: false, + markerAlreadyNotified: true + }) + ).toBe(false) + }) + + it('skips when fallback is inactive or source is missing', () => { + expect( + shouldPresentSoftwareGpuFallbackNotice({ + active: false, + source: 'marker', + alreadyPresentedThisSession: false, + markerAlreadyNotified: false + }) + ).toBe(false) + expect( + shouldPresentSoftwareGpuFallbackNotice({ + active: true, + source: null, + alreadyPresentedThisSession: false, + markerAlreadyNotified: false + }) + ).toBe(false) + }) +}) + +describe('buildSoftwareGpuFallbackNotice', () => { + it('explains env opt-in and crash-marker recovery distinctly', () => { + const envNotice = buildSoftwareGpuFallbackNotice('env') + expect(envNotice.title).toMatch(/software gpu/i) + expect(envNotice.detail).toContain('ORCA_SOFTWARE_GPU') + + const markerNotice = buildSoftwareGpuFallbackNotice('marker') + expect(markerNotice.message).toMatch(/unstable hardware gpu/i) + expect(markerNotice.detail).toMatch(/this app version/i) + }) +}) diff --git a/src/main/startup/windows-software-gpu.ts b/src/main/startup/windows-software-gpu.ts new file mode 100644 index 000000000000..7c8aae093a8a --- /dev/null +++ b/src/main/startup/windows-software-gpu.ts @@ -0,0 +1,91 @@ +/** + * Windows software-GPU command-line path used when hardware acceleration is + * unusable (broken drivers, headless virtual displays / Sunshine Zako, etc.). + * + * Why more than disable-gpu: on some virtual adapters Chromium still forks a + * GPU child that STATUS_BREAKPOINT-crashes even under --disable-gpu. The only + * combo known to reach ready is in-process GPU + ANGLE SwiftShader (#10093). + */ + +export type SoftwareGpuCommandLineApp = { + disableHardwareAcceleration: () => void + commandLine: { + appendSwitch: (switchName: string, value?: string) => void + } +} + +/** How software GPU was selected for this process. */ +export type SoftwareGpuFallbackSource = 'env' | 'marker' + +export type SoftwareGpuFallbackNotice = { + title: string + message: string + detail: string +} + +/** Chromium switches that form the proven software-render recovery path. */ +export const WINDOWS_SOFTWARE_GPU_SWITCHES = [ + { name: 'disable-gpu' }, + { name: 'in-process-gpu' }, + { name: 'use-angle', value: 'swiftshader' } +] as const + +/** + * True when the operator forces software GPU before any crash burst + * (headless / virtual-display hosts that never survive the first launch). + */ +export function isSoftwareGpuEnvRequested(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = (env.ORCA_SOFTWARE_GPU ?? '').trim().toLowerCase() + return raw === '1' || raw === 'true' || raw === 'yes' +} + +export function applyWindowsSoftwareGpuFallback(app: SoftwareGpuCommandLineApp): void { + app.disableHardwareAcceleration() + for (const flag of WINDOWS_SOFTWARE_GPU_SWITCHES) { + if ('value' in flag && flag.value !== undefined) { + app.commandLine.appendSwitch(flag.name, flag.value) + } else { + app.commandLine.appendSwitch(flag.name) + } + } +} + +/** + * Whether to surface the one-shot software-GPU notice. + * Marker path: first activation only (sticky until build change). + * Env path: once per process — operators opted in explicitly. + */ +export function shouldPresentSoftwareGpuFallbackNotice(args: { + active: boolean + source: SoftwareGpuFallbackSource | null + alreadyPresentedThisSession: boolean + /** Set when the sticky crash-marker already recorded a prior notice. */ + markerAlreadyNotified: boolean +}): boolean { + if (!args.active || args.source === null || args.alreadyPresentedThisSession) { + return false + } + if (args.source === 'marker' && args.markerAlreadyNotified) { + return false + } + return true +} + +export function buildSoftwareGpuFallbackNotice( + source: SoftwareGpuFallbackSource +): SoftwareGpuFallbackNotice { + if (source === 'env') { + return { + title: 'Software GPU is active', + message: 'Orca is using software graphics for stability.', + detail: + 'ORCA_SOFTWARE_GPU is set, so Orca is rendering with ANGLE SwiftShader instead of the hardware GPU. This is slower but more reliable on virtual or remote displays. Unset the variable and restart to try hardware graphics again.' + } + } + return { + title: 'Software GPU is active', + message: 'Orca switched to software graphics after unstable hardware GPU crashes.', + detail: + 'Rendering uses ANGLE SwiftShader so the app can stay usable on this display. It may feel slower. This mode stays on for this app version; updating Orca will try hardware graphics again automatically.' + } +}