hermes-agent/apps/desktop/electron/stream-throttle.ts
Brooklyn Nicholson 8ccb4c2cee 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.
2026-07-31 00:38:11 -05:00

119 lines
3.6 KiB
TypeScript

// 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)
}
}
}