mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
ce6dd1a65f
commit
8ccb4c2cee
6 changed files with 372 additions and 39 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
152
apps/desktop/electron/stream-throttle.test.ts
Normal file
152
apps/desktop/electron/stream-throttle.test.ts
Normal 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])
|
||||
})
|
||||
119
apps/desktop/electron/stream-throttle.ts
Normal file
119
apps/desktop/electron/stream-throttle.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue