perf(desktop): scope background-throttling opt-out to live streaming

The process-wide disable-background-timer-throttling /
disable-backgrounding-occluded-windows switches plus a static
backgroundThrottling: false on every chat window pinned each renderer's
document.visibilityState to 'visible' for the life of the window. Every
visibility-gated backstop poll and clock tick in the renderer became an
always-on timer: an idle, minimized Hermes burned ~20% CPU around the
clock, on battery too.

Throttling is now a runtime dial. A small controller (stream-throttle.ts)
rides the merged hermes:active-work reports the quit guard already
receives: while any turn is in flight every chat window gets
setBackgroundThrottling(false) — a live answer keeps painting while
blurred, occluded, or minimized, exactly as before — and once all turns
settle (plus a 5s trailing window so the final flush lands at full
cadence) Chromium's default throttling returns and hidden windows go
quiet.

disable-renderer-backgrounding stays: process priority only, no timer
semantics, and it keeps hidden streaming fast.
This commit is contained in:
Brooklyn Nicholson 2026-07-30 23:56:16 -05:00
parent ce6dd1a65f
commit 8ccb4c2cee
6 changed files with 372 additions and 39 deletions

View file

@ -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<number, ActiveWork>()
// 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) => {

View file

@ -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', () => {

View file

@ -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
}
}

View file

@ -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<number, () => 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<string, () => 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])
})

View file

@ -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<ThrottleWindowLike>()
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)
}
}
}

View file

@ -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])