diff --git a/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts new file mode 100644 index 00000000000..b0112b7175e --- /dev/null +++ b/src/main/crash-reporting/gpu-crash-fallback-field-sessions.test.ts @@ -0,0 +1,128 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD, + DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, + GpuCrashFallbackTracker, + isGpuFallbackCrashCandidate +} from './gpu-crash-fallback-decision' +import { shouldRecordProcessGoneCrash } from './process-gone-classification' + +/** + * Replays the win32 GPU-child deaths from the 1.4.190 'crashed' renderer cluster + * through the production decision path, so the reason no host was ever offered + * Safe Graphics Mode is pinned rather than argued about. + * + * Each session below is one crash report's `Recent activity`; a breadcrumb's + * `suppressedSinceLast=N` means N further identical GPU deaths were coalesced + * into it, so the session saw N+1 GPU crashes. + */ + +type FieldSession = { + /** Crash-report id from the field bundle. */ + report: string + /** GPU child crash times, ms since main-process start. */ + gpuCrashesMsSinceLaunch: number[] +} + +// crashed.txt: every win32 report in the cluster. Times are (breadcrumb ts - +// mainProcessStartedAt); coalesced repeats are placed inside the same second, +// which is the only interval the emitted breadcrumb pins them to. +const CRASHED_CLUSTER_SESSIONS: FieldSession[] = [ + // 23:36:54.200 - 23:36:49.931, suppressedSinceLast=1 -> 2 crashes + { report: 'db1f1ee2', gpuCrashesMsSinceLaunch: [4_269, 4_800] }, + // 02:58:25.287 - 02:58:20.749, no suppression -> 1 crash + { report: '66cc54d8', gpuCrashesMsSinceLaunch: [4_538] }, + // 21:47:05.776 - 21:46:59.584, suppressedSinceLast=1 -> 2 crashes + { report: '1f5564de', gpuCrashesMsSinceLaunch: [6_192, 6_240] }, + // 00:17:17.840 - 00:17:15.732, no suppression -> 1 crash + { report: '96d8c63b', gpuCrashesMsSinceLaunch: [2_108] } +] + +/** index.ts's `child-process-gone` listener body — the wiring these claims rest on. */ +function readChildProcessGoneListener(): string { + const source = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') + const start = source.indexOf("app.on('child-process-gone'") + expect(start).toBeGreaterThan(0) + return source.slice(start, source.indexOf('\n })', start)) +} + +function newTracker(): GpuCrashFallbackTracker { + return new GpuCrashFallbackTracker({ + windowMs: DEFAULT_GPU_CRASH_FALLBACK_WINDOW_MS, + threshold: DEFAULT_GPU_CRASH_FALLBACK_THRESHOLD + }) +} + +const FIELD_GPU_EVENT = { + source: 'child', + processType: 'GPU', + serviceName: 'GPU', + reason: 'crashed', + expectedTeardown: 'none' +} as const + +describe('1.4.190 win32 GPU-child crash cluster', () => { + it('does not let process_gone_suppressed gate the fallback candidate check', () => { + // The GPU death is suppressed as recoverable churn (no user-facing report)... + expect(shouldRecordProcessGoneCrash({ ...FIELD_GPU_EVENT, exitCode: -2147483645 })).toBe(false) + // ...but the fallback path reads the raw child-process-gone event, so the + // suppression cannot hide a broken driver from recovery. + expect( + isGpuFallbackCrashCandidate({ + platform: 'win32', + processType: 'GPU', + reason: 'crashed' + }) + ).toBe(true) + // Both assertions above still pass if the candidate check is moved behind the + // suppressed-report path, so pin that nothing branches ahead of it in index.ts. + const listener = readChildProcessGoneListener() + const guardStart = listener.indexOf('isGpuFallbackCrashCandidate(') + expect(guardStart).toBeGreaterThan(0) + expect(listener.slice(0, guardStart).match(/\bif\s*\(/g) ?? []).toHaveLength(1) + expect(listener).toMatch(/isGpuFallbackCrashCandidate\([\s\S]*?void handleGpuChildCrash\(/) + // The `if (` count alone still allows `recorded && isGpuFallbackCrashCandidate(...)`, which + // re-couples recovery to the suppression decision, so pin the guard to that check alone. + const recoveryGuard = listener.slice( + listener.lastIndexOf('if (', guardStart), + listener.indexOf('void handleGpuChildCrash(') + ) + expect(recoveryGuard).not.toMatch(/&&|\|\|/) + }) + + it('never reaches the burst threshold in any observed cluster session', () => { + const outcomes = CRASHED_CLUSTER_SESSIONS.map((session) => { + // Each launch constructs a fresh tracker (src/main/index.ts), so evidence + // does not survive the relaunch these users performed after every crash. + const tracker = newTracker() + const engaged = session.gpuCrashesMsSinceLaunch.some( + (at) => tracker.recordGpuCrash(at).shouldEngageFallback + ) + return { + report: session.report, + gpuCrashes: session.gpuCrashesMsSinceLaunch.length, + engagedSafeGraphicsPrompt: engaged + } + }) + expect(outcomes).toEqual([ + { report: 'db1f1ee2', gpuCrashes: 2, engagedSafeGraphicsPrompt: false }, + { report: '66cc54d8', gpuCrashes: 1, engagedSafeGraphicsPrompt: false }, + { report: '1f5564de', gpuCrashes: 2, engagedSafeGraphicsPrompt: false }, + { report: '96d8c63b', gpuCrashes: 1, engagedSafeGraphicsPrompt: false } + ]) + }) + + it('engages on the session that did reach three crashes (field launch 51b9e93c)', () => { + // oom.txt, win32: GPU crashed/exitCode=34 with suppressedSinceLast=2, then + // `gpu_fallback_engaged (crashesInWindow=3)` and `gpu_fallback_restart_deferred`. + const tracker = newTracker() + expect(tracker.recordGpuCrash(3_600_000).shouldEngageFallback).toBe(false) + expect(tracker.recordGpuCrash(3_601_000).shouldEngageFallback).toBe(false) + expect(tracker.recordGpuCrash(3_601_890)).toEqual({ + shouldEngageFallback: true, + crashesInWindow: 3 + }) + }) +}) diff --git a/src/main/crash-reporting/gpu-fallback-engagement.test.ts b/src/main/crash-reporting/gpu-fallback-engagement.test.ts new file mode 100644 index 00000000000..5abb07b7b2d --- /dev/null +++ b/src/main/crash-reporting/gpu-fallback-engagement.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from 'vitest' +import { + engageGpuFallbackAfterCrashBurst, + type GpuFallbackEngagement, + type GpuFallbackEngagementHandlers +} from './gpu-fallback-engagement' +import type { GpuFallbackRestartDecision } from './gpu-fallback-restart-prompt' + +const ENGAGEMENT: GpuFallbackEngagement = { + reason: 'crashed', + // 0x80000003 STATUS_BREAKPOINT — the production Windows signature. + exitCode: -2147483645, + crashesInWindow: 3, + engagedAt: 1_700_000_000_000 +} + +function createHandlers(overrides: Partial = {}): { + handlers: GpuFallbackEngagementHandlers + order: string[] +} { + const order: string[] = [] + const handlers: GpuFallbackEngagementHandlers = { + isQuitting: () => false, + persistMarker: vi.fn(() => { + order.push('persistMarker') + return true + }), + clearMarker: vi.fn(() => order.push('clearMarker')), + promptForRestart: vi.fn(async () => { + order.push('prompt') + return 'restart' as GpuFallbackRestartDecision + }), + onPromptFailed: vi.fn(), + onEngaged: vi.fn(), + onRestartDeferred: vi.fn(() => order.push('restartDeferred')), + restartIntoSafeGraphics: vi.fn(() => order.push('restart')), + ...overrides + } + return { handlers, order } +} + +describe('engageGpuFallbackAfterCrashBurst', () => { + // Repro for the Windows startup GPU cluster (v1.4.190, exit -2147483645). + // Measured on Windows 11 26200 / Electron 43.1.0: Chromium's own ladder aborts + // the whole browser ("GPU process isn't usable. Goodbye.") on the 6th GPU + // crash, 1.285s after the 3rd — the crash that trips this threshold. If the + // marker is only written after the user answers the modal, the app dies first + // and the next launch retries hardware acceleration, looping forever. + it('persists the safe-graphics marker before the restart prompt is answered', async () => { + let resolvePrompt: ((decision: GpuFallbackRestartDecision) => void) | undefined + const { handlers } = createHandlers({ + promptForRestart: vi.fn( + () => + new Promise((resolve) => { + resolvePrompt = resolve + }) + ) + }) + + const engaging = engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers) + await Promise.resolve() + + // Chromium kills the process here; whatever is on disk now is all the next launch gets. + expect(handlers.persistMarker).toHaveBeenCalledWith(ENGAGEMENT) + + resolvePrompt?.('restart') + await engaging + }) + + it('relaunches into safe graphics when the user accepts', async () => { + const { handlers, order } = createHandlers() + await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers) + expect(order).toEqual(['persistMarker', 'prompt', 'restart']) + expect(handlers.clearMarker).not.toHaveBeenCalled() + }) + + it('undoes the marker when the user chooses Keep Running', async () => { + const { handlers, order } = createHandlers({ + promptForRestart: vi.fn(async () => 'continue' as GpuFallbackRestartDecision) + }) + await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers) + expect(order).toEqual(['persistMarker', 'clearMarker', 'restartDeferred']) + expect(handlers.restartIntoSafeGraphics).not.toHaveBeenCalled() + }) + + it('keeps the marker when the prompt itself fails, so the next launch is still safe', async () => { + const error = new Error('no display') + const { handlers } = createHandlers({ + promptForRestart: vi.fn(async () => { + throw error + }) + }) + await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers) + expect(handlers.persistMarker).toHaveBeenCalledTimes(1) + expect(handlers.clearMarker).not.toHaveBeenCalled() + expect(handlers.onPromptFailed).toHaveBeenCalledWith(error) + }) + + it('does not relaunch when a quit began while the prompt was open', async () => { + const { handlers } = createHandlers({ isQuitting: () => true }) + await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers) + expect(handlers.restartIntoSafeGraphics).not.toHaveBeenCalled() + // The marker stays: a quit is not the user declining safe graphics. + expect(handlers.clearMarker).not.toHaveBeenCalled() + }) + + it('does not claim a restart when the marker could not be written', async () => { + const { handlers } = createHandlers({ persistMarker: vi.fn(() => false) }) + await engageGpuFallbackAfterCrashBurst(ENGAGEMENT, handlers) + expect(handlers.restartIntoSafeGraphics).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/crash-reporting/gpu-fallback-engagement.ts b/src/main/crash-reporting/gpu-fallback-engagement.ts new file mode 100644 index 00000000000..c8a4b4498b3 --- /dev/null +++ b/src/main/crash-reporting/gpu-fallback-engagement.ts @@ -0,0 +1,63 @@ +import type { GpuFallbackRestartDecision } from './gpu-fallback-restart-prompt' + +/** The GPU-crash burst that tripped the software-rendering fallback. */ +export type GpuFallbackEngagement = { + reason: string + exitCode: number | null + crashesInWindow: number + /** Wall-clock ms stamped into the persisted marker. */ + engagedAt: number +} + +export type GpuFallbackEngagementHandlers = { + isQuitting: () => boolean + /** Writes the build-scoped safe-graphics marker; false when it could not be persisted. */ + persistMarker: (engagement: GpuFallbackEngagement) => boolean + clearMarker: () => void + promptForRestart: () => Promise + onPromptFailed: (error: unknown) => void + onEngaged: (engagement: GpuFallbackEngagement) => void + onRestartDeferred: (engagement: GpuFallbackEngagement) => void + restartIntoSafeGraphics: (engagement: GpuFallbackEngagement) => void +} + +/** + * Drives the safe-graphics handover once a GPU crash burst trips the threshold. + * + * Why the marker is written before the prompt: measured on Windows 11 26200 / + * Electron 43.1.0, Chromium's own GPU ladder aborts the whole browser process + * (`FATAL gpu_data_manager_impl_private.cc: GPU process isn't usable. Goodbye.`) + * on the 6th GPU crash — 1.285s after the 3rd, which is the crash that trips + * this threshold. Persisting only after the user answers a modal means the app + * dies first, no marker survives, and the next launch retries hardware + * acceleration: the crash loop never ends. An explicit "Keep Running" undoes it. + */ +export async function engageGpuFallbackAfterCrashBurst( + engagement: GpuFallbackEngagement, + handlers: GpuFallbackEngagementHandlers +): Promise { + handlers.onEngaged(engagement) + const persisted = handlers.persistMarker(engagement) + let decision: GpuFallbackRestartDecision + try { + decision = await handlers.promptForRestart() + } catch (error) { + // Marker stays: the next launch is safe even though the user was never asked. + handlers.onPromptFailed(error) + return + } + if (handlers.isQuitting()) { + return + } + if (decision !== 'restart') { + if (persisted) { + handlers.clearMarker() + } + handlers.onRestartDeferred(engagement) + return + } + if (!persisted) { + return + } + handlers.restartIntoSafeGraphics(engagement) +} diff --git a/src/main/index.ts b/src/main/index.ts index 79f3f53f3a5..bb46293182f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -153,6 +153,7 @@ import { configureDevUserDataPath, configureOrcaUserDataPathEnv, disableUnsupportedChromiumFeatures, + optOutOfHiddenPageWakeUpThrottling, enableMainProcessGpuFeatures, installDevParentDisconnectQuit, installDevParentSignalQuit, @@ -169,6 +170,7 @@ import { enableRendererHeapHeadroom } from './startup/renderer-heap-headroom' import { argvRequestsServeMode, normalizeServeModeArgv } from './startup/serve-mode-argv' import { ensureVirtualDisplayForHeadlessServe } from './startup/ensure-virtual-display' import { + clearGpuFallbackMarker, readActiveGpuFallbackMarker, writeGpuFallbackMarker, type GpuFallbackEnvironment, @@ -181,10 +183,8 @@ import { GpuCrashFallbackTracker, isGpuFallbackCrashCandidate } from './crash-reporting/gpu-crash-fallback-decision' -import { - promptForGpuFallbackRestart, - type GpuFallbackRestartDecision -} from './crash-reporting/gpu-fallback-restart-prompt' +import { promptForGpuFallbackRestart } from './crash-reporting/gpu-fallback-restart-prompt' +import { engageGpuFallbackAfterCrashBurst } from './crash-reporting/gpu-fallback-engagement' import { shouldSuppressDevEducation, suppressDevEducationForStore @@ -980,6 +980,8 @@ if (hasSingleInstanceLock) { ...getMainProcessLifecycleIdentity() }) disableUnsupportedChromiumFeatures() + // Why: unconditional — a GPU-fallback launch skips enableMainProcessGpuFeatures() below. + optOutOfHiddenPageWakeUpThrottling() configureElectronNetworkCompatibility() enableRendererHeapHeadroom() maybeApplyGpuFallbackForThisLaunch() @@ -1882,54 +1884,57 @@ async function handleGpuChildCrash(reason: string, exitCode: number | null): Pro if (!result.shouldEngageFallback) { return } - recordCrashBreadcrumb('gpu_fallback_engaged', { - reason, - exitCode, - crashesInWindow: result.crashesInWindow - }) - const engagedAt = Date.now() - const window = mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined - let restartDecision: GpuFallbackRestartDecision - try { - restartDecision = await promptForGpuFallbackRestart(window) - } catch (error) { - console.warn('[gpu-fallback] failed to show restart prompt:', error) - return - } const fallbackData = { processReason: reason, exitCode, crashesInWindow: result.crashesInWindow } - if (isQuitting) { - return - } - if (restartDecision !== 'restart') { - recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData) - return - } - const environment = getWindowsGpuFallbackEnvironment() - if (!environment) { - return - } - try { - writeGpuFallbackMarker( - app.getPath('userData'), - { - engagedAt, - crashesInWindow: result.crashesInWindow + const userDataPath = app.getPath('userData') + await engageGpuFallbackAfterCrashBurst( + { reason, exitCode, crashesInWindow: result.crashesInWindow, engagedAt: Date.now() }, + { + isQuitting: () => isQuitting, + onEngaged: (engagement) => + recordCrashBreadcrumb('gpu_fallback_engaged', { + reason: engagement.reason, + exitCode: engagement.exitCode, + crashesInWindow: engagement.crashesInWindow + }), + persistMarker: (engagement) => { + const environment = getWindowsGpuFallbackEnvironment() + if (!environment) { + return false + } + try { + writeGpuFallbackMarker( + userDataPath, + { engagedAt: engagement.engagedAt, crashesInWindow: engagement.crashesInWindow }, + environment + ) + return true + } catch (error) { + console.warn('[gpu-fallback] failed to persist marker:', error) + return false + } }, - environment - ) - } catch (error) { - console.warn('[gpu-fallback] failed to persist marker:', error) - return - } - isQuitting = true - relaunchApp('gpu-fallback', fallbackData) - // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. - destroySystemTray() - app.exit(0) + clearMarker: () => clearGpuFallbackMarker(userDataPath), + promptForRestart: () => + promptForGpuFallbackRestart( + mainWindow && !mainWindow.isDestroyed() ? mainWindow : undefined + ), + onPromptFailed: (error) => + console.warn('[gpu-fallback] failed to show restart prompt:', error), + onRestartDeferred: () => + recordDurableCrashBreadcrumb('gpu_fallback_restart_deferred', fallbackData), + restartIntoSafeGraphics: () => { + isQuitting = true + relaunchApp('gpu-fallback', fallbackData) + // Why: app.exit(0) skips before-quit, so destroy the Windows tray manually to avoid a stale icon. + destroySystemTray() + app.exit(0) + } + } + ) } function recordProcessGoneCrash( diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index b089b3f1a62..232626ca9f5 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -508,20 +508,6 @@ describe('enableMainProcessGpuFeatures', () => { expect(app.commandLine.appendSwitch).not.toHaveBeenCalledWith('enable-unsafe-webgpu') }) - it('opts hidden pages out of intensive wake-up throttling', async () => { - const { app } = await import('electron') - const { enableMainProcessGpuFeatures } = await import('./configure-process') - - delete process.env.ORCA_E2E_USER_DATA_DIR - vi.mocked(app.commandLine.appendSwitch).mockClear() - enableMainProcessGpuFeatures() - - expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( - 'disable-features', - 'IntensiveWakeUpThrottling' - ) - }) - it('raises the WebGL context budget above the 16-context Blink default', async () => { const { app } = await import('electron') const { enableMainProcessGpuFeatures } = await import('./configure-process') @@ -754,3 +740,77 @@ describe('enableMainProcessGpuFeatures', () => { expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('enable-features', 'ExistingFeature') }) }) + +describe('safe graphics mode startup switches', () => { + const originalE2EUserDataDir = process.env.ORCA_E2E_USER_DATA_DIR + + afterEach(() => { + if (originalE2EUserDataDir === undefined) { + delete process.env.ORCA_E2E_USER_DATA_DIR + } else { + process.env.ORCA_E2E_USER_DATA_DIR = originalE2EUserDataDir + } + }) + + function disabledFeaturesFrom(appendSwitch: ReturnType): string[] { + return appendSwitch.mock.calls + .filter(([name]) => name === 'disable-features') + .flatMap(([, value]) => String(value ?? '').split(',')) + .filter(Boolean) + } + + it('opts hidden pages out of intensive wake-up throttling', async () => { + const { app } = await import('electron') + const { optOutOfHiddenPageWakeUpThrottling } = await import('./configure-process') + + vi.mocked(app.commandLine.appendSwitch).mockClear() + optOutOfHiddenPageWakeUpThrottling() + + expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( + 'disable-features', + 'IntensiveWakeUpThrottling' + ) + }) + + // Why: the defect was the call site, not the switch — a win32 safe-graphics launch runs + // `if (!gpuFallbackActiveThisLaunch) enableMainProcessGpuFeatures()` and skips everything + // parked inside it, so only an unconditional call site reaches the users a GPU crash already hit. + it('calls the throttling opt-out outside the GPU-fallback gate in index.ts', () => { + const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') + const gateStart = mainSource.indexOf('if (!gpuFallbackActiveThisLaunch) {') + expect(gateStart).toBeGreaterThanOrEqual(0) + const gateEnd = mainSource.indexOf('\n }', gateStart) + expect(gateEnd).toBeGreaterThan(gateStart) + + expect(mainSource.match(/\boptOutOfHiddenPageWakeUpThrottling\(\)/g)).toHaveLength(1) + expect(mainSource.slice(gateStart, gateEnd)).not.toContain('optOutOfHiddenPageWakeUpThrottling') + }) + + // Why: Chromium consumes the command line at ready, so this must stay in the pre-ready + // top-level block and never move into the whenReady callback, where appendSwitch is a silent + // no-op — the same invisible failure as parking it behind the GPU gate. + it('appends the throttling opt-out before app ready in index.ts', () => { + const mainSource = readFileSync(join(__dirname, '..', 'index.ts'), 'utf8') + const readyStart = mainSource.indexOf('void app.whenReady()') + expect(readyStart).toBeGreaterThan(0) + + const callIndex = mainSource.indexOf('optOutOfHiddenPageWakeUpThrottling()') + expect(callIndex).toBeGreaterThan(0) + expect(callIndex).toBeLessThan(readyStart) + }) + + // Why: Chromium enables IntensiveWakeUpThrottling on every desktop platform, so the opt-out + // must never become reachable only through the GPU-feature path again. + it('does not couple the throttling opt-out to GPU feature setup', async () => { + const { app } = await import('electron') + const { enableMainProcessGpuFeatures } = await import('./configure-process') + + delete process.env.ORCA_E2E_USER_DATA_DIR + vi.mocked(app.commandLine.appendSwitch).mockClear() + enableMainProcessGpuFeatures() + + expect(disabledFeaturesFrom(vi.mocked(app.commandLine.appendSwitch))).not.toContain( + 'IntensiveWakeUpThrottling' + ) + }) +}) diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index ced3704f49c..e49b7e04a03 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -71,6 +71,12 @@ export function disableUnsupportedChromiumFeatures(): void { appendDisabledChromiumFeatures([...DISABLED_CHROMIUM_FEATURES]) } +// Why: Chromium clamps hidden-page timers to 1/min after 5min on every desktop platform, +// delaying agent-done/bell notifications ~60s. Call site is unconditional (see index.ts). +export function optOutOfHiddenPageWakeUpThrottling(): void { + appendDisabledChromiumFeatures(['IntensiveWakeUpThrottling']) +} + function appendDisabledChromiumFeatures(features: string[]): void { const existingFeatures = app.commandLine .getSwitchValue('disable-features') @@ -314,8 +320,4 @@ export function enableMainProcessGpuFeatures(): void { if (features) { app.commandLine.appendSwitch('enable-features', 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. - appendDisabledChromiumFeatures(['IntensiveWakeUpThrottling']) }