Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/desktop/electron/link-title-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ export function linkTitleWindowOptions(partitionSession) {
width: 1280,
height: 800,
webPreferences: {
backgroundThrottling: false,
// Deliberately throttled: this hidden window loads arbitrary user-linked
// pages, and an unthrottled heavy page burns full CPU for the window's
// whole lifetime. Title resolution rides load events
// (page-title-updated / did-finish-load) plus main-process timers, none
// of which the renderer clamp touches — hidden-page throttling only
// slows the page's own timer-driven JS, and the grace window already
// absorbs that.
contextIsolation: true,
javascript: true,
nodeIntegration: false,
Expand Down
86 changes: 69 additions & 17 deletions apps/desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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, '../..')

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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)
})

Expand Down Expand Up @@ -9258,6 +9296,7 @@ function createWindow() {
}
})

streamThrottle.register(mainWindow)
wireCommonWindowHandlers(mainWindow, zoomWiringForWindowKind('chat'))

mainWindow.webContents.on('render-process-gone', (_event, details) => {
Expand Down Expand Up @@ -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) => {
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,14 @@ contextBridge.exposeInMainWorld('hermesDesktop', {

return () => ipcRenderer.removeListener('hermes:power-resume', listener)
},
// AC ↔ battery transitions; renderers slow their backstop polls on battery.
getOnBattery: () => ipcRenderer.invoke('hermes:power-battery:get'),
onBatteryChanged: callback => {
const listener = (_event, onBattery) => callback(Boolean(onBattery))
ipcRenderer.on('hermes:power-battery', listener)

return () => ipcRenderer.removeListener('hermes:power-battery', listener)
},
onBootProgress: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:boot-progress', listener)
Expand Down
13 changes: 8 additions & 5 deletions apps/desktop/electron/session-windows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
21 changes: 13 additions & 8 deletions apps/desktop/electron/session-windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -39,7 +45,6 @@ function chatWindowWebPreferences(preloadPath: string) {
sandbox: true,
nodeIntegration: false,
devTools: true,
backgroundThrottling: false,
autoplayPolicy: 'no-user-gesture-required' as const
}
}
Expand Down
Loading
Loading