mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #75218 from NousResearch/bb/idle-cpu
This commit is contained in:
commit
f3cda0ceb1
12 changed files with 434 additions and 42 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { useEffect } from 'react'
|
|||
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $changeEventsAvailable, $cronChangeTick, $sessionsChangeTick } from '@/store/live-sync'
|
||||
import { $onBattery, batteryPollInterval } from '@/store/power'
|
||||
import { refreshActiveProfile } from '@/store/profile'
|
||||
import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session'
|
||||
import {
|
||||
|
|
@ -180,7 +181,10 @@ interface BackgroundSyncParams {
|
|||
}
|
||||
|
||||
/** Poll a callback while the tab is visible, on `intervalMs`; re-checks on tab
|
||||
* re-focus. Returns nothing — meant to live inside an effect. */
|
||||
* re-focus. On battery the cadence stretches (see store/power) — these are
|
||||
* safety-net refreshes, not the live path, so they're the right thing to slow
|
||||
* when the machine is spending its charge. Returns nothing — meant to live
|
||||
* inside an effect. */
|
||||
function visiblePoll(intervalMs: number, tick: () => void): () => void {
|
||||
const run = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
|
|
@ -188,10 +192,17 @@ function visiblePoll(intervalMs: number, tick: () => void): () => void {
|
|||
}
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(run, intervalMs)
|
||||
let intervalId = window.setInterval(run, batteryPollInterval(intervalMs, $onBattery.get()))
|
||||
|
||||
const unsubscribeBattery = $onBattery.listen(onBattery => {
|
||||
window.clearInterval(intervalId)
|
||||
intervalId = window.setInterval(run, batteryPollInterval(intervalMs, onBattery))
|
||||
})
|
||||
|
||||
document.addEventListener('visibilitychange', run)
|
||||
|
||||
return () => {
|
||||
unsubscribeBattery()
|
||||
window.clearInterval(intervalId)
|
||||
document.removeEventListener('visibilitychange', run)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
2
apps/desktop/src/global.d.ts
vendored
2
apps/desktop/src/global.d.ts
vendored
|
|
@ -256,6 +256,8 @@ declare global {
|
|||
// reload. Wipe session lists (skeletons) and re-dial.
|
||||
onConnectionApplied?: (callback: () => void) => () => void
|
||||
onPowerResume?: (callback: () => void) => () => void
|
||||
getOnBattery?: () => Promise<boolean>
|
||||
onBatteryChanged?: (callback: (onBattery: boolean) => void) => () => void
|
||||
onBootProgress: (callback: (payload: DesktopBootProgress) => void) => () => void
|
||||
getBootstrapState: () => Promise<DesktopBootstrapState>
|
||||
continueBootstrapLocal: () => Promise<{ ok: boolean }>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import './styles.css'
|
||||
// Side-effect: reports in-flight turns to the main process for the quit guard.
|
||||
import './store/active-work'
|
||||
// Side-effect: mirrors the machine's AC/battery state for poll demotion.
|
||||
import './store/power'
|
||||
// Side-effect: applies the persisted window translucency on load.
|
||||
import './store/translucency'
|
||||
// Dev-only render/state churn counters. MUST precede the `react-dom` import
|
||||
|
|
|
|||
30
apps/desktop/src/store/power.ts
Normal file
30
apps/desktop/src/store/power.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* AC ↔ battery mirror of the main process's powerMonitor.
|
||||
*
|
||||
* Backstop polls are already event-demoted and visibility-gated; battery is
|
||||
* the third gate — on battery their cadence stretches (×4) so a laptop
|
||||
* running Hermes in the background isn't spending its charge on safety-net
|
||||
* refreshes. Live streaming and event-driven refreshes are untouched: this
|
||||
* only slows timers whose job is catching what a degraded socket missed.
|
||||
*
|
||||
* Imported for its side effect from `main.tsx` (same pattern as
|
||||
* store/active-work).
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
export const $onBattery = atom<boolean>(false)
|
||||
|
||||
/** Multiply a backstop poll interval by this on battery. */
|
||||
export const BATTERY_POLL_MULTIPLIER = 4
|
||||
|
||||
export function batteryPollInterval(intervalMs: number, onBattery: boolean): number {
|
||||
return onBattery ? intervalMs * BATTERY_POLL_MULTIPLIER : intervalMs
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
void desktop?.getOnBattery?.().then(onBattery => $onBattery.set(onBattery))
|
||||
desktop?.onBatteryChanged?.(onBattery => $onBattery.set(onBattery))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue