mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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.
119 lines
3.6 KiB
TypeScript
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)
|
|
}
|
|
}
|
|
}
|