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:
alt-glitch 2026-06-14 14:17:50 +05:30
parent 6e3c393ef9
commit a70f7f3b7b
3 changed files with 223 additions and 0 deletions

View 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 }
}

View file

@ -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()
}

View 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()
})
})