mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-11 13:41:53 +00:00
opentui(v6): proactive idle GC, gated on the low-mem heap knob (W2)
OpenTUI-only by design (verified: Ink never calls global.gc proactively — it only exposes it for heapdumps; spec D5 sanctions the divergence since this is opt-in). Default / unconstrained sessions do NOTHING. boundary/proactiveGc.ts: a low-frequency watcher that calls global.gc() only when ALL hold: (a) the low-mem opt-in is active — HERMES_TUI_HEAP_MB set at or below 4096 (the same W1 knob; HERMES_TUI_PROACTIVE_GC can force on/off), AND global.gc is exposed (W1's --expose-gc — else a silent no-op); (b) a turn is NOT streaming (reads the store's info.running) so it never collects mid-reply; (c) a full idle window has elapsed since the last activity. Eagerness: once RSS crosses 400MB the idle window tightens (8s→3s) but STILL waits for idle — never a mid-stream pause (the jank the campaign fought). Reuses process.memoryUsage().rss (same read as memlog); the timer is unref'd and every failure path disables silently. Wired in entry/main.tsx next to startMemlog, scoped acquire→release. Tests: gating (low-cap-on / high-cap-off / no-gc-off / explicit on|off) + timing (fires after the idle window, never while streaming, stop() halts). proactiveGc.test.ts 10 passed. npm run check OK (785 tests). Verified live that the gate enables under HERMES_TUI_HEAP_MB=256 and global.gc is callable.
This commit is contained in:
parent
6e3c393ef9
commit
a70f7f3b7b
3 changed files with 223 additions and 0 deletions
95
ui-opentui/src/boundary/proactiveGc.ts
Normal file
95
ui-opentui/src/boundary/proactiveGc.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/**
|
||||
* proactiveGc — opt-in, idle-gated `global.gc()` for the low-mem path (W2).
|
||||
*
|
||||
* GATED on the low-mem opt-in: only runs when the user set a LOW
|
||||
* `HERMES_TUI_HEAP_MB` (the same knob W1 threads into `--max-old-space-size`).
|
||||
* Default / unconstrained sessions do NOTHING — no proactive GC, no divergence
|
||||
* from Ink on the default path (Ink never calls gc proactively; it only exposes
|
||||
* it for heapdumps, so W2 is OpenTUI-only by design — spec D5).
|
||||
*
|
||||
* TRIGGER MODEL — idle-gated, never mid-stream:
|
||||
* - A low-frequency timer ticks every IDLE_MS. On each tick it calls
|
||||
* `global.gc()` ONLY when (a) a turn is NOT streaming (`isStreaming()` false
|
||||
* — so we never pause mid-render/mid-reply) and (b) at least one full idle
|
||||
* window has passed since the last activity (stream end / explicit touch).
|
||||
* - If RSS crosses RSS_EAGER_KB (>400MB) the cadence tightens (EAGER_MS) but
|
||||
* it STILL waits for idle — eagerness shortens the window, never bypasses it.
|
||||
* - `--expose-gc` (W1) makes `global.gc` real; without it this is a silent
|
||||
* no-op (we detect and disable). The timer is unref'd — it never keeps the
|
||||
* process alive — and every failure path disables silently (a GC helper must
|
||||
* never break the TUI).
|
||||
*
|
||||
* Reuses `process.memoryUsage().rss` (same read as memlog) for the >400MB check.
|
||||
*/
|
||||
import { envFlag } from '../logic/env.ts'
|
||||
|
||||
/** Below this heap cap (MB) we treat the session as low-mem opt-in. 8192 is the
|
||||
* default; anyone who set a cap materially under it wants tight memory. */
|
||||
const LOW_MEM_HEAP_MB = 4096
|
||||
/** Idle window (ms): time since last activity before a GC is allowed. */
|
||||
const IDLE_MS = 8000
|
||||
/** Tightened idle window once RSS is high. */
|
||||
const EAGER_MS = 3000
|
||||
/** RSS (KB) above which GC becomes eager (still idle-gated). 400MB. */
|
||||
const RSS_EAGER_KB = 400 * 1024
|
||||
|
||||
/** The configured heap cap in MB from the W1 knob, or null when unset/garbage.
|
||||
* The Python launcher reads the same env; the child inherits it, so the Node
|
||||
* side can read it directly to know whether low-mem mode is active. */
|
||||
function configuredHeapMb(): number | null {
|
||||
const v = (process.env.HERMES_TUI_HEAP_MB ?? '').trim()
|
||||
if (!/^\d+$/.test(v)) return null
|
||||
const n = Number.parseInt(v, 10)
|
||||
return n > 0 ? n : null
|
||||
}
|
||||
|
||||
/** Whether proactive GC should run: a low heap cap is set AND gc is exposed.
|
||||
* `HERMES_TUI_PROACTIVE_GC` can force it on/off, but defaults to the low-mem
|
||||
* signal so the knob composes (spec D9: independent knobs). */
|
||||
export function proactiveGcEnabled(heapMb: number | null = configuredHeapMb()): boolean {
|
||||
const lowMem = heapMb !== null && heapMb <= LOW_MEM_HEAP_MB
|
||||
return envFlag(process.env.HERMES_TUI_PROACTIVE_GC, lowMem) && typeof global.gc === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the idle-gated proactive GC watcher. `isStreaming` reports whether a
|
||||
* turn is mid-flight (read from the store's `info.running`). Returns a stop
|
||||
* function and a `touch()` to mark fresh activity (e.g. on keypress / stream
|
||||
* start) so the idle clock resets. No-op (returns inert handles) when disabled.
|
||||
*/
|
||||
export function startProactiveGc(isStreaming: () => boolean): { stop: () => void; touch: () => void } {
|
||||
if (!proactiveGcEnabled()) return { stop: () => {}, touch: () => {} }
|
||||
const gc = global.gc
|
||||
if (typeof gc !== 'function') return { stop: () => {}, touch: () => {} }
|
||||
|
||||
let lastActivity = Date.now()
|
||||
const touch = () => {
|
||||
lastActivity = Date.now()
|
||||
}
|
||||
|
||||
// Tick at the eager cadence; the idle-window check (below) does the real
|
||||
// gating, so a high-RSS session reacts within EAGER_MS while a calm one still
|
||||
// waits the full IDLE_MS. One cheap rss read + compare per tick.
|
||||
const timer = setInterval(() => {
|
||||
try {
|
||||
if (isStreaming()) {
|
||||
// mid-stream: defer entirely and keep the clock fresh so a GC can't fire
|
||||
// the instant the stream ends — it waits a full idle window after.
|
||||
lastActivity = Date.now()
|
||||
return
|
||||
}
|
||||
const rssKb = Math.floor(process.memoryUsage().rss / 1024)
|
||||
const window = rssKb > RSS_EAGER_KB ? EAGER_MS : IDLE_MS
|
||||
if (Date.now() - lastActivity < window) return
|
||||
gc()
|
||||
// After a collection, reset the clock so we don't GC every tick — the next
|
||||
// one waits another full idle window.
|
||||
lastActivity = Date.now()
|
||||
} catch {
|
||||
clearInterval(timer) // a failing GC helper must not retry forever
|
||||
}
|
||||
}, EAGER_MS)
|
||||
timer.unref?.()
|
||||
|
||||
return { stop: () => clearInterval(timer), touch }
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ import { GatewayService, type GatewayServiceShape } from '../boundary/gateway/Ga
|
|||
import { liveGatewayLayer } from '../boundary/gateway/liveGateway.ts'
|
||||
import { getLog } from '../boundary/log.ts'
|
||||
import { startMemlog } from '../boundary/memlog.ts'
|
||||
import { startProactiveGc } from '../boundary/proactiveGc.ts'
|
||||
import { registerVendoredParsers } from '../boundary/parsers.ts'
|
||||
import { acquireRenderer } from '../boundary/renderer.ts'
|
||||
import { makeAppLayer } from '../boundary/runtime.ts'
|
||||
|
|
@ -420,6 +421,11 @@ export const run = Effect.fn('Tui.run')(function* (input: TuiInput) {
|
|||
// switch — boundary/memlog.ts). Scoped acquire→release like the renderer.
|
||||
const stopMemlog = startMemlog()
|
||||
yield* Effect.addFinalizer(() => Effect.sync(stopMemlog))
|
||||
// Proactive idle GC (W2) — opt-in via a low HERMES_TUI_HEAP_MB (no-op on
|
||||
// the default path). Idle-gated on the store's streaming flag so it never
|
||||
// collects mid-reply. Scoped release like memlog.
|
||||
const proactiveGc = startProactiveGc(() => store.state.info.running === true)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(proactiveGc.stop))
|
||||
doQuit = () => {
|
||||
if (!renderer.isDestroyed) renderer.destroy()
|
||||
}
|
||||
|
|
|
|||
122
ui-opentui/src/test/proactiveGc.test.ts
Normal file
122
ui-opentui/src/test/proactiveGc.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
/**
|
||||
* proactiveGc tests (W2) — the opt-in idle-gated GC gating + timing.
|
||||
*
|
||||
* Pins: enabled ONLY under a low heap cap + exposed gc; never fires mid-stream;
|
||||
* fires after a full idle window; tightens to the eager window once RSS is high;
|
||||
* and is a no-op (inert handles) on the default path.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
||||
|
||||
import { proactiveGcEnabled, startProactiveGc } from '../boundary/proactiveGc.ts'
|
||||
|
||||
const ENV_KEYS = ['HERMES_TUI_HEAP_MB', 'HERMES_TUI_PROACTIVE_GC'] as const
|
||||
const saved: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of ENV_KEYS) saved[k] = process.env[k]
|
||||
})
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (saved[k] === undefined) delete process.env[k]
|
||||
else process.env[k] = saved[k]
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** Stub a callable global.gc and return the spy. */
|
||||
function stubGc() {
|
||||
const spy = vi.fn()
|
||||
// global.gc is optional in the type — assign through a loosened view.
|
||||
;(globalThis as { gc?: () => void }).gc = spy
|
||||
return spy
|
||||
}
|
||||
|
||||
describe('proactiveGcEnabled — gating', () => {
|
||||
test('OFF on the default path (no heap cap), even with gc exposed', () => {
|
||||
delete process.env.HERMES_TUI_HEAP_MB
|
||||
delete process.env.HERMES_TUI_PROACTIVE_GC
|
||||
stubGc()
|
||||
expect(proactiveGcEnabled()).toBe(false)
|
||||
})
|
||||
|
||||
test('ON under a low heap cap with gc exposed', () => {
|
||||
delete process.env.HERMES_TUI_PROACTIVE_GC
|
||||
stubGc()
|
||||
expect(proactiveGcEnabled(256)).toBe(true)
|
||||
})
|
||||
|
||||
test('OFF under a high heap cap (not the low-mem signal)', () => {
|
||||
delete process.env.HERMES_TUI_PROACTIVE_GC
|
||||
stubGc()
|
||||
expect(proactiveGcEnabled(8192)).toBe(false)
|
||||
})
|
||||
|
||||
test('OFF when gc is not exposed, even under a low cap', () => {
|
||||
delete process.env.HERMES_TUI_PROACTIVE_GC
|
||||
delete (globalThis as { gc?: () => void }).gc
|
||||
expect(proactiveGcEnabled(256)).toBe(false)
|
||||
})
|
||||
|
||||
test('explicit HERMES_TUI_PROACTIVE_GC=off overrides the low-cap default', () => {
|
||||
process.env.HERMES_TUI_PROACTIVE_GC = 'off'
|
||||
stubGc()
|
||||
expect(proactiveGcEnabled(256)).toBe(false)
|
||||
})
|
||||
|
||||
test('explicit HERMES_TUI_PROACTIVE_GC=on forces it on under a high cap', () => {
|
||||
process.env.HERMES_TUI_PROACTIVE_GC = 'on'
|
||||
stubGc()
|
||||
expect(proactiveGcEnabled(8192)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('startProactiveGc — idle gating', () => {
|
||||
test('no-op inert handles on the default path', () => {
|
||||
delete process.env.HERMES_TUI_HEAP_MB
|
||||
delete process.env.HERMES_TUI_PROACTIVE_GC
|
||||
const gc = stubGc()
|
||||
vi.useFakeTimers()
|
||||
const h = startProactiveGc(() => false)
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(gc).not.toHaveBeenCalled()
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('fires after a full idle window when not streaming', () => {
|
||||
process.env.HERMES_TUI_HEAP_MB = '256'
|
||||
delete process.env.HERMES_TUI_PROACTIVE_GC
|
||||
const gc = stubGc()
|
||||
vi.useFakeTimers()
|
||||
const h = startProactiveGc(() => false)
|
||||
// before the idle window elapses: no GC
|
||||
vi.advanceTimersByTime(3000)
|
||||
expect(gc).not.toHaveBeenCalled()
|
||||
// past the 8s idle window: GC fires
|
||||
vi.advanceTimersByTime(9000)
|
||||
expect(gc).toHaveBeenCalled()
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('never fires while streaming, no matter how long', () => {
|
||||
process.env.HERMES_TUI_HEAP_MB = '256'
|
||||
delete process.env.HERMES_TUI_PROACTIVE_GC
|
||||
const gc = stubGc()
|
||||
vi.useFakeTimers()
|
||||
const h = startProactiveGc(() => true) // always streaming
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(gc).not.toHaveBeenCalled()
|
||||
h.stop()
|
||||
})
|
||||
|
||||
test('stop() halts the watcher', () => {
|
||||
process.env.HERMES_TUI_HEAP_MB = '256'
|
||||
delete process.env.HERMES_TUI_PROACTIVE_GC
|
||||
const gc = stubGc()
|
||||
vi.useFakeTimers()
|
||||
const h = startProactiveGc(() => false)
|
||||
h.stop()
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(gc).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue