diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index f485e5a5427..41686883f71 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -183,6 +183,7 @@ import { redactSecrets, SshConnection } from './ssh-connection' +import { createStreamThrottle } from './stream-throttle' import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width' import { resolveBehindCount, shouldCountCommits } from './update-count' import { waitForUpdateClearance } from './update-gate' @@ -421,18 +422,24 @@ if (IS_WINDOWS) { ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON) -// Keep the renderer running at full speed while the window is in the background -// or occluded. The chat transcript streams to screen through a bounded timer -// flush; Chromium clamps timers for backgrounded/occluded renderers, so without -// these the live answer stalls -// whenever the window loses focus (switching to your editor mid-turn, detached -// devtools, another window covering it) and only paints on refocus or refresh. -// `backgroundThrottling: false` on the BrowserWindow covers the blurred case; -// these process-level switches additionally stop Chromium from backgrounding or -// occlusion-throttling the renderer. Must run before app `ready`. +// Keep the renderer's PROCESS priority normal while its windows are hidden — +// a deprioritized renderer streams a live answer visibly slower once the +// window is minimized. This switch only affects scheduling priority; it does +// not exempt timers from throttling and costs nothing at idle. +// +// The timer/rAF throttling story is deliberately NOT handled here anymore. +// The old process-wide `disable-background-timer-throttling` / +// `disable-backgrounding-occluded-windows` switches (plus a static +// `backgroundThrottling: false` on every chat window) pinned every renderer's +// `document.visibilityState` to 'visible' forever — which silently turned all +// the renderer's visibility-gated backstop polls and clock ticks into +// always-on timers. A completely idle, minimized Hermes burned ~20% CPU +// around the clock. Throttling is now a runtime dial scoped to streaming: +// see createStreamThrottle() — chat windows are unthrottled while any turn is +// in flight (so a live answer keeps painting while blurred, occluded, or +// minimized, exactly as before) and return to Chromium's default throttling +// once the work settles. app.commandLine.appendSwitch('disable-renderer-backgrounding') -app.commandLine.appendSwitch('disable-backgrounding-occluded-windows') -app.commandLine.appendSwitch('disable-background-timer-throttling') const SOURCE_REPO_ROOT = path.resolve(APP_ROOT, '../..') @@ -5188,6 +5195,31 @@ function sendPowerResume() { let powerResumeRegistered = false +// Mirror of powerMonitor's AC/battery state, broadcast to every window so +// renderer backstop polls can slow down on battery (see store/power.ts). +// `null` until the first powerMonitor read after app ready. +let onBatteryPower: boolean | null = null + +// Renderer-side battery gating seeds from this and stays current via the +// 'hermes:power-battery' push below. +ipcMain.handle('hermes:power-battery:get', () => onBatteryPower === true) + +function broadcastBatteryState(next: boolean) { + if (onBatteryPower === next) { + return + } + + onBatteryPower = next + + for (const win of BrowserWindow.getAllWindows()) { + const { webContents } = win + + if (webContents && !webContents.isDestroyed()) { + webContents.send('hermes:power-battery', next) + } + } +} + function registerPowerResumeListeners() { if (powerResumeRegistered) { return @@ -5200,6 +5232,9 @@ function registerPowerResumeListeners() { // full suspend. Either can drop an idle socket. powerMonitor.on('resume', sendPowerResume) powerMonitor.on('unlock-screen', sendPowerResume) + powerMonitor.on('on-battery', () => broadcastBatteryState(true)) + powerMonitor.on('on-ac', () => broadcastBatteryState(false)) + onBatteryPower = powerMonitor.isOnBatteryPower() } catch { // powerMonitor is unavailable before app 'ready' on some platforms; the // caller registers after 'ready', so this should not normally throw. @@ -8697,6 +8732,7 @@ function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch? win.on('enter-full-screen', () => sendWindowStateChanged(true)) win.on('leave-full-screen', () => sendWindowStateChanged(false)) + streamThrottle.register(win) wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) loadWindowUrl( @@ -8739,7 +8775,7 @@ function nextInstanceBounds() { } // Open a new full-chrome instance window. Mirrors createWindow()'s window -// options (shared chatWindowWebPreferences keeps backgroundThrottling:false so a +// options (shared chatWindowWebPreferences + streamThrottle registration so a // streamed answer never stalls in the background) but is a peer, not the // primary: it never overwrites the mainWindow global, doesn't start the backend // (the renderer's getConnection() joins the already-running one), and loads the @@ -8780,6 +8816,7 @@ function createInstanceWindow() { win.on('enter-full-screen', () => sendWindowStateChanged(true, win)) win.on('leave-full-screen', () => sendWindowStateChanged(false, win)) + streamThrottle.register(win) wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) win.on('closed', () => { @@ -9162,10 +9199,11 @@ function createWindow() { // material before the renderer paints the app theme. See createSessionWindow. show: false, backgroundColor: getWindowBackgroundColor(), - // Shared with the secondary session windows (chatWindowWebPreferences) so - // both keep `backgroundThrottling: false` — the chat transcript uses a - // bounded timer flush that Chromium clamps for blurred windows, stalling - // the live answer until refocus. See session-windows.ts. + // Shared with the secondary session windows (chatWindowWebPreferences); + // stream-aware throttling is applied per-window via streamThrottle so a + // live answer keeps painting while the window is blurred or minimized, + // without pinning visibilityState to 'visible' at idle. See + // session-windows.ts and stream-throttle.ts. webPreferences: chatWindowWebPreferences(PRELOAD_PATH) }) @@ -9258,6 +9296,7 @@ function createWindow() { } }) + streamThrottle.register(mainWindow) wireCommonWindowHandlers(mainWindow, zoomWiringForWindowKind('chat')) mainWindow.webContents.on('render-process-gone', (_event, details) => { @@ -10427,14 +10466,27 @@ ipcMain.handle('hermes:stopPreviewFileWatch', (_event, id) => stopPreviewFileWat // merged picture. Keyed by webContents id so a closed window stops counting. const activeWorkByWebContents = new Map() +// The same merged picture drives background throttling: chat windows run +// unthrottled while any turn is in flight (streaming must paint while hidden) +// and fall back to Chromium's default throttling at idle. See stream-throttle.ts. +const streamThrottle = createStreamThrottle() + +function updateStreamThrottleFromActiveWork() { + streamThrottle.update(mergeActiveWork(activeWorkByWebContents.values()).count > 0) +} + ipcMain.on('hermes:active-work', (event, payload) => { const id = event.sender.id if (!activeWorkByWebContents.has(id)) { - event.sender.once('destroyed', () => activeWorkByWebContents.delete(id)) + event.sender.once('destroyed', () => { + activeWorkByWebContents.delete(id) + updateStreamThrottleFromActiveWork() + }) } activeWorkByWebContents.set(id, normalizeActiveWork(payload)) + updateStreamThrottleFromActiveWork() }) ipcMain.on('hermes:titlebar-theme', (_event, payload) => { diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index a60ebb37621..a3dbfc04e4a 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -191,13 +191,16 @@ test('registry trims the session id before keying', () => { assert.equal(registry.has('s1'), true) }) -test('chatWindowWebPreferences disables background throttling so streaming paints while blurred', () => { - // Regression: secondary session windows used to omit this flag, so a streamed - // answer stalled until the window regained focus (Chromium clamps the - // transcript flush timer for backgrounded windows). +test('chatWindowWebPreferences leaves background throttling to the runtime stream dial', () => { + // Regression (both directions): a static `backgroundThrottling: false` here + // pinned document.visibilityState to 'visible' forever, turning every + // visibility-gated poll into an always-on timer (~20% CPU at idle, + // minimized). Streaming's "paint while blurred" need is served by + // stream-throttle.ts flipping setBackgroundThrottling at turn boundaries — + // so the static flag must stay absent. const prefs = chatWindowWebPreferences('/tmp/preload.cjs') - assert.equal(prefs.backgroundThrottling, false) + assert.equal('backgroundThrottling' in prefs, false) }) test('chatWindowWebPreferences passes the preload path through and keeps the hardened defaults', () => { diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index 312deb3d1e3..81736908fce 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -13,14 +13,20 @@ const SESSION_WINDOW_MIN_HEIGHT = 620 // Shared webPreferences for every window that renders the chat transcript — the // primary window AND the secondary session windows. Keeping it in one place is // the whole point: the two BrowserWindow definitions in main.ts used to be -// hand-copied, and the secondary windows silently lost `backgroundThrottling: -// false`, so a streamed answer stalled until the window regained focus. +// hand-copied, and the secondary windows silently drifted apart (a streamed +// answer stalled until the window regained focus because one of them lost the +// throttling opt-out). // -// `backgroundThrottling: false` is load-bearing: the transcript streams to the -// screen through a bounded timer flush, which Chromium clamps for blurred/ -// occluded windows. A streaming chat app must keep painting in the -// background, so every chat window opts out. The preload path is injected -// because it depends on the Electron entry's __dirname. +// Background throttling is deliberately NOT set here. It is managed at runtime +// by main.ts (`setBackgroundThrottling` driven by the merged `hermes:active-work` +// reports): while any turn is in flight every chat window is unthrottled so the +// transcript's bounded timer flush keeps painting while blurred, occluded, or +// minimized — and once all turns finish, Chromium's default throttling returns +// so an idle hidden window costs ~nothing. A static `backgroundThrottling: +// false` here would pin `document.visibilityState` to 'visible' forever, +// turning every visibility-gated poll in the renderer into an always-on timer +// (the "Hermes idles at 20% CPU while minimized" bug). The preload path is +// injected because it depends on the Electron entry's __dirname. // // `autoplayPolicy: 'no-user-gesture-required'` is load-bearing for voice: // Chromium's default autoplay policy suspends audio (HTMLAudioElement.play() @@ -39,7 +45,6 @@ function chatWindowWebPreferences(preloadPath: string) { sandbox: true, nodeIntegration: false, devTools: true, - backgroundThrottling: false, autoplayPolicy: 'no-user-gesture-required' as const } } diff --git a/apps/desktop/electron/stream-throttle.test.ts b/apps/desktop/electron/stream-throttle.test.ts new file mode 100644 index 00000000000..fc41bcf1681 --- /dev/null +++ b/apps/desktop/electron/stream-throttle.test.ts @@ -0,0 +1,152 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { createStreamThrottle, type ThrottleWindowLike } from './stream-throttle' + +function makeTimers() { + const pending = new Map void>() + let nextId = 1 + + return { + clearTimeout: (handle: unknown) => { + pending.delete(handle as number) + }, + fire() { + const jobs = [...pending.values()] + pending.clear() + + for (const job of jobs) { + job() + } + }, + get pendingCount() { + return pending.size + }, + setTimeout: (fn: () => void, _ms: number) => { + const id = nextId++ + pending.set(id, fn) + + return id + } + } +} + +function makeWindow() { + const calls: boolean[] = [] + const listeners = new Map void>() + let destroyed = false + + const win = { + calls, + close() { + destroyed = true + listeners.get('closed')?.() + }, + isDestroyed: () => destroyed, + on(event: string, fn: () => void) { + listeners.set(event, fn) + }, + webContents: { + isDestroyed: () => destroyed, + setBackgroundThrottling(allowed: boolean) { + calls.push(allowed) + } + } + } + + return win +} + +test('registering a window applies the current throttle state immediately', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const idle = makeWindow() + throttle.register(idle) + + // Idle default: throttling allowed. + assert.deepEqual(idle.calls, [true]) + + throttle.update(true) + const late = makeWindow() + throttle.register(late) + + // A window created mid-stream starts unthrottled. + assert.deepEqual(late.calls, [false]) +}) + +test('a turn in flight unthrottles every chat window; settling re-throttles after the trailing delay', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const win = makeWindow() + throttle.register(win) + + throttle.update(true) + assert.deepEqual(win.calls, [true, false]) + assert.equal(throttle.isUnthrottled(), true) + + // Turn ends: not re-throttled synchronously — the tail flush needs full + // cadence — only after the trailing timer fires. + throttle.update(false) + assert.deepEqual(win.calls, [true, false]) + assert.equal(throttle.isUnthrottled(), true) + + timers.fire() + assert.deepEqual(win.calls, [true, false, true]) + assert.equal(throttle.isUnthrottled(), false) +}) + +test('a new turn during the trailing window cancels the pending re-throttle', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const win = makeWindow() + throttle.register(win) + + throttle.update(true) + throttle.update(false) + assert.equal(timers.pendingCount, 1) + + // Busy again before the delay elapses: stay unthrottled, timer cancelled. + throttle.update(true) + assert.equal(timers.pendingCount, 0) + assert.equal(throttle.isUnthrottled(), true) + + // The cancelled timer firing late must be a no-op. + timers.fire() + assert.equal(throttle.isUnthrottled(), true) +}) + +test('repeated busy reports do not re-apply or stack timers', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const win = makeWindow() + throttle.register(win) + + throttle.update(true) + throttle.update(true) + throttle.update(true) + assert.deepEqual(win.calls, [true, false]) + + throttle.update(false) + throttle.update(false) + assert.equal(timers.pendingCount, 1) +}) + +test('closed and destroyed windows drop out without throwing', () => { + const timers = makeTimers() + const throttle = createStreamThrottle(timers) + const closedWin = makeWindow() + throttle.register(closedWin) + closedWin.close() + + const gone: ThrottleWindowLike & { on?: never } = { + isDestroyed: () => true, + webContents: null + } + + throttle.register(gone) + + throttle.update(true) + // Only the registration-time call landed; nothing after close. + assert.deepEqual(closedWin.calls, [true]) +}) diff --git a/apps/desktop/electron/stream-throttle.ts b/apps/desktop/electron/stream-throttle.ts new file mode 100644 index 00000000000..912b40b9167 --- /dev/null +++ b/apps/desktop/electron/stream-throttle.ts @@ -0,0 +1,119 @@ +// Stream-aware background throttling for chat windows. +// +// Chat windows must paint the live transcript while blurred, occluded, or +// minimized — but a static `backgroundThrottling: false` in webPreferences +// costs far more than that feature needs: it pins the renderer's +// `document.visibilityState` to 'visible' for the life of the window, which +// turns every visibility-gated poll and clock tick in the renderer into an +// always-on timer. An idle, hidden Hermes burned ~20% CPU forever. +// +// So throttling is a runtime dial instead: the renderers already report +// "which chats are mid-turn" for the quit guard (`hermes:active-work`), and +// this controller rides the merged edge of those reports. Any turn in flight → +// every registered chat window gets `setBackgroundThrottling(false)`, exactly +// the streaming behavior the static flag used to provide. All turns done → +// after a short trailing delay (so tail flushes land at full cadence) Chromium's +// default throttling returns and hidden windows go quiet. +// +// Pure and Electron-free (timers + the WebContents surface are injected) so it +// can be unit-tested, mirroring session-windows.ts. + +/** How long after the last turn ends before throttling is restored. Covers the + * stream queue's final coalesced flush and the settle writes that trail a + * turn's completion, so re-throttling never strands a visible delta. */ +const RETHROTTLE_DELAY_MS = 5_000 + +export interface ThrottleWindowLike { + isDestroyed(): boolean + webContents?: { + isDestroyed(): boolean + setBackgroundThrottling(allowed: boolean): void + } | null +} + +interface TimersLike { + clearTimeout(handle: unknown): void + setTimeout(fn: () => void, ms: number): unknown +} + +export interface StreamThrottle { + /** True while windows are currently unthrottled (streaming or trailing). */ + isUnthrottled(): boolean + /** Track a chat window; applies the current state immediately and stops + * tracking on close. */ + register(win: ThrottleWindowLike & { on?: (event: string, fn: () => void) => void }): void + /** Report whether any turn is in flight across all renderers. */ + update(busy: boolean): void +} + +export function createStreamThrottle( + timers: TimersLike = { clearTimeout: handle => clearTimeout(handle as never), setTimeout }, + delayMs: number = RETHROTTLE_DELAY_MS +): StreamThrottle { + const windows = new Set() + let unthrottled = false + let trailing: unknown = null + + function apply(win: ThrottleWindowLike) { + if (win.isDestroyed()) { + windows.delete(win) + + return + } + + const contents = win.webContents + + if (!contents || contents.isDestroyed()) { + return + } + + try { + contents.setBackgroundThrottling(!unthrottled) + } catch { + // A window mid-teardown can throw; it's about to leave the set anyway. + } + } + + function applyAll() { + for (const win of windows) { + apply(win) + } + } + + return { + isUnthrottled: () => unthrottled, + + register(win) { + windows.add(win) + win.on?.('closed', () => windows.delete(win)) + apply(win) + }, + + update(busy) { + if (busy) { + if (trailing !== null) { + timers.clearTimeout(trailing) + trailing = null + } + + if (!unthrottled) { + unthrottled = true + applyAll() + } + + return + } + + if (!unthrottled || trailing !== null) { + return + } + + // Trailing edge: keep full cadence briefly so the final flush paints. + trailing = timers.setTimeout(() => { + trailing = null + unthrottled = false + applyAll() + }, delayMs) + } + } +} diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index d5ec67d384e..7aa89a8cf14 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -273,18 +273,20 @@ export function useMessageStream({ // Always a timer, never requestAnimationFrame. Chromium pauses rAF for a // renderer it considers hidden, and "hidden" is not something this code can - // verify: `backgroundThrottling: false` plus the process-level switches in - // electron/main.ts cover the blurred and occluded cases, but they don't - // cover a minimized window, a fully off-screen one, or a renderer the - // compositor has otherwise parked. In those states an rAF-gated flush never - // runs, so a finished answer sits in this queue until some later input or - // focus event happens to wake a frame — the reply looks stalled, then - // arrives all at once on refocus. + // verify: while a turn is in flight the main process unthrottles every chat + // window (stream-throttle.ts), but that doesn't guarantee frames for a + // minimized window, a fully off-screen one, or a renderer the compositor + // has otherwise parked. In those states an rAF-gated flush never runs, so a + // finished answer sits in this queue until some later input or focus event + // happens to wake a frame — the reply looks stalled, then arrives all at + // once on refocus. // // A timer keeps the same coalescing cadence (that's what the floor above is // for) while guaranteeing delivery without user interaction. Timers are - // clamped in background renderers rather than suspended, and - // disable-background-timer-throttling already opts out of that clamp. + // clamped in background renderers rather than suspended, and the + // stream-aware unthrottle lifts even that clamp for the life of the turn; + // in the worst case (a delta arriving before the unthrottle lands) the + // clamp only stretches one flush to ~1s in a window nobody can see. flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, adaptiveFloor - sinceLast)) }, [flushQueuedDeltas])