diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 72a545a39..08cd8139e 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -297,6 +297,20 @@ 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') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index e92c53e4b..e033e4e94 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -344,4 +344,16 @@ export function enableMainProcessGpuFeatures(): void { if (features) { app.commandLine.appendSwitch('enable-features', features) } + + const existingDisabledFeatures = app.commandLine.getSwitchValue('disable-features') + // Why: with main-window background throttling on, Chromium's intensive mode + // clamps hidden-page timers to 1/min after 5 minutes, delaying agent-done and + // bell notifications by up to 60s. Keep the normal 1s hidden clamp (rAF and + // rendering still stop) but opt out of the 1/min tier. Callers skip this + // function under GPU fallback (win32-only today); if throttling ever extends + // to Windows, this opt-out must move out of the GPU-gated path. + const disabledFeatures = ['IntensiveWakeUpThrottling', existingDisabledFeatures] + .filter(Boolean) + .join(',') + app.commandLine.appendSwitch('disable-features', disabledFeatures) } diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 8899ba81b..3b69b9c12 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -299,6 +299,80 @@ describe('createMainWindow', () => { } }) + it('keeps main-window background throttling enabled while repainting macOS visibility transitions', () => { + vi.useFakeTimers() + const windowHandlers = new Map void)[]>() + let windowSize: [number, number] = [1200, 800] + const webContents = { + on: vi.fn(), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + isDestroyed: vi.fn(() => false), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDevToolsOpened: vi.fn(), + openDevTools: vi.fn(), + closeDevTools: vi.fn() + } + const browserWindowInstance = { + webContents, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + const handlers = windowHandlers.get(event) ?? [] + handlers.push(handler) + windowHandlers.set(event, handlers) + }), + isDestroyed: vi.fn(() => false), + isMaximized: vi.fn(() => false), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => windowSize), + setSize: vi.fn((width: number, height: number) => { + windowSize = [width, height] + }), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return browserWindowInstance + }) + + withPlatform('darwin', () => createMainWindow(null)) + + // Why: throttling-off pins visibilityState 'visible' and renders occluded + // windows at full rate; this guards against reintroducing it. + expect(webContents.setBackgroundThrottling).not.toHaveBeenCalledWith(false) + + expect(webContents.setBackgroundThrottling).toHaveBeenCalledWith(true) + expect(windowHandlers.get('restore')).toHaveLength(1) + expect(windowHandlers.get('show')).toHaveLength(1) + expect(windowHandlers.get('focus')).toHaveLength(1) + + windowHandlers.get('show')?.[0]?.() + windowHandlers.get('restore')?.[0]?.() + + expect(webContents.invalidate).toHaveBeenCalledTimes(2) + expect(browserWindowInstance.setSize).toHaveBeenNthCalledWith(1, 1201, 800) + expect(browserWindowInstance.setSize).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(32) + expect(browserWindowInstance.setSize).toHaveBeenNthCalledWith(2, 1200, 800) + + vi.advanceTimersByTime(217) + expect(webContents.invalidate).toHaveBeenCalledTimes(2) + + vi.advanceTimersByTime(1) + expect(webContents.invalidate).toHaveBeenCalledTimes(3) + + // Why: focus covers occlusion-uncover with invalidate only — no setSize + // jiggle that would resize terminals on every window focus. + const setSizeCalls = browserWindowInstance.setSize.mock.calls.length + windowHandlers.get('focus')?.[0]?.() + expect(webContents.invalidate).toHaveBeenCalledTimes(4) + expect(browserWindowInstance.setSize).toHaveBeenCalledTimes(setSizeCalls) + }) + it('supports all minus key variants for terminal zoom out', () => { const windowHandlers: Record void> = {} const webContents = { diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 52c84ec60..b12583377 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -50,23 +50,65 @@ import { buildEditableContextMenuTemplate } from './editable-context-menu' import { clearTrustedUIRendererWebContentsId, setTrustedUIRendererWebContentsId } from '../ipc/ui' import { resolveWindowCloseAction } from './window-close-decision' +// Why: show/restore/resume can overlap before the size nudge resets; never +// capture the temporary width as the next repaint's baseline. +const activeRepaintJiggles = new WeakSet() + function forceRepaint(window: BrowserWindow): void { - if (window.isDestroyed()) { + // Why: webContents can be destroyed a beat before the BrowserWindow during + // close, and this runs from timers/focus events that can land in that gap. + if (window.isDestroyed() || window.webContents.isDestroyed()) { return } window.webContents.invalidate() - if (window.isMaximized() || window.isFullScreen()) { + if (window.isMaximized() || window.isFullScreen() || activeRepaintJiggles.has(window)) { return } + activeRepaintJiggles.add(window) const [width, height] = window.getSize() window.setSize(width + 1, height) setTimeout(() => { if (!window.isDestroyed()) { window.setSize(width, height) } + activeRepaintJiggles.delete(window) }, 32) } +function installMacosVisibilityRepaint(window: BrowserWindow): void { + let delayedRepaintTimer: ReturnType | null = null + const repaintAfterVisibilityTransition = (): void => { + forceRepaint(window) + if (delayedRepaintTimer) { + clearTimeout(delayedRepaintTimer) + } + // Why: macOS can finish restoring webview compositor layers after Electron's + // show/restore event, so a second paint catches late black-surface recovery. + delayedRepaintTimer = setTimeout(() => { + delayedRepaintTimer = null + forceRepaint(window) + }, 250) + } + const clearDelayedRepaint = (): void => { + if (delayedRepaintTimer) { + clearTimeout(delayedRepaintTimer) + delayedRepaintTimer = null + } + } + + window.on('restore', repaintAfterVisibilityTransition) + window.on('show', repaintAfterVisibilityTransition) + // Why: occlusion-uncover fires neither restore nor show; focus is the only + // signal. Invalidate only — the setSize jiggle would SIGWINCH every terminal + // on each Cmd+Tab. + window.on('focus', () => { + if (!window.isDestroyed() && !window.webContents.isDestroyed()) { + window.webContents.invalidate() + } + }) + window.on('closed', clearDelayedRepaint) +} + function isMacAppPasteInput(input: Electron.Input): boolean { return ( process.platform === 'darwin' && @@ -293,18 +335,13 @@ export function createMainWindow( setTrustedUIRendererWebContentsId(rendererWebContentsId) if (process.platform === 'darwin') { - // Why: persistent browser webviews use separate compositor layers, and on - // recent macOS releases those layers can fail to repaint after occlusion or - // restore. Disabling main-window throttling and forcing a repaint on - // visibility transitions hardens Orca against black-surface failures during - // browser-tab restore and tab switching. - mainWindow.webContents.setBackgroundThrottling(false) - mainWindow.on('restore', () => { - forceRepaint(mainWindow) - }) - mainWindow.on('show', () => { - forceRepaint(mainWindow) - }) + // Why: browser-guest surfaces are kept alive by their own per-guest + // unthrottle (browser-manager attach), so the main window can throttle + // normally while hidden/occluded instead of rendering at full rate. + // Toggling must happen only while visible: flipping it on a hidden window + // desyncs Chromium's frame evictor and blanks the surface (electron#42378). + mainWindow.webContents.setBackgroundThrottling(true) + installMacosVisibilityRepaint(mainWindow) } // Why: a focus-preserving system/display wake fires no window focus or