Files
hermes-agent/ui-tui/src/lib/memoryMonitor.ts

89 lines
3.2 KiB
TypeScript
Raw Normal View History

fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
import { type HeapDumpResult, performHeapDump } from './memory.js'
export type MemoryLevel = 'critical' | 'high' | 'normal'
export interface MemorySnapshot {
heapUsed: number
level: MemoryLevel
rss: number
}
export interface MemoryMonitorOptions {
criticalBytes?: number
highBytes?: number
intervalMs?: number
onCritical?: (snap: MemorySnapshot, dump: HeapDumpResult | null) => void
onHigh?: (snap: MemorySnapshot, dump: HeapDumpResult | null) => void
}
const GB = 1024 ** 3
perf(tui): shave ~190ms off `hermes --tui` cold start Two targeted fixes on the critical path from `hermes --tui` launch to `gateway.ready`: 1. **Defer `@hermes/ink` import in memoryMonitor.ts.** The static top-level import dragged the full ~414KB Ink bundle (React + renderer + all components/hooks) onto the critical path *before* `gw.start()` could spawn the Python gateway — serialising ~155ms of Node work in front of it on every launch. `evictInkCaches` only runs inside the 10-second tick under heap pressure, so it moves to a lazy dynamic import. First tick hits the ESM cache because the app entry has long since imported `@hermes/ink`. 2. **Gate `tools.mcp_tool` import on config in tui_gateway/entry.py.** Importing the module transitively pulls the MCP SDK + pydantic + httpx + jsonschema + starlette formparsers (~200ms). The overwhelming majority of users have no `mcp_servers` configured, so this runs for nothing. A cheap `load_config()` check (~25ms) skips the 200ms import when no servers are declared, with a conservative fallback to the old behaviour if the config probe itself fails. ## Measurements (macOS Terminal.app, Apple Silicon, n=12) | Metric | Before (p50) | After (p50) | Δ | |----------------------------|--------------|-------------|----------| | Python gateway boot alone | 252–365ms | 105–151ms | −180ms | | `hermes --tui` banner paint | 686ms | 665ms | −21ms | | `hermes --tui` → ready | **1843ms** | **1655ms** | **−188ms (−10.2%)** | | `hermes --tui` → ready p90 | 1932ms | 1778ms | −154ms | | stdev (ready) | 126ms | 83ms | also more consistent | ## Tests - `scripts/run_tests.sh tests/tui_gateway/ tests/tools/test_mcp_tool.py`: 195 passed. (The one pre-existing failure in `test_session_resume_returns_hydrated_messages` reproduces on main — unrelated, it's a mock-DB kwarg mismatch.) - `ui-tui` vitest: 430 tests, all pass. - `npm run type-check` in ui-tui: clean. ## Notes - Node-side first paint ("banner") didn't move meaningfully because that latency is dominated by Ink's render pipeline + React mount, not by which imports load first. - The win shows up entirely in the time from banner to `gateway.ready` — exactly where we expected it, since both fixes shorten the Python gateway's boot path or let it overlap more with Node startup. - No user-visible behaviour change. Memory monitoring still fires every 10s; MCP still works when `mcp_servers` is configured.
2026-04-28 19:42:31 -05:00
// Deferred @hermes/ink import: loading `@hermes/ink` at module top-level
// pulls the full ~414KB Ink bundle (React, renderer, components, hooks) onto
// the critical path before the Python gateway can even be spawned. That
// serialised roughly 150ms of Node work in front of gw.start() on every
// cold `hermes --tui` launch.
//
// evictInkCaches only runs inside `tick()`, which fires on a 10s timer and
// only when heap pressure crosses the high-water mark — by then Ink has
// long since been loaded by the app entry. This dynamic import is a no-op
// on the hot path (module is already in the ESM cache); when a startup
// spike somehow trips the threshold before the app registers its own Ink
// import, we pay the load cost exactly once, inside the tick that needs it.
let _evictInkCaches: ((level: 'all' | 'half') => unknown) | null = null
async function _ensureEvictInkCaches(): Promise<(level: 'all' | 'half') => unknown> {
if (_evictInkCaches) return _evictInkCaches
const mod = await import('@hermes/ink')
_evictInkCaches = mod.evictInkCaches as (level: 'all' | 'half') => unknown
return _evictInkCaches
}
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
export function startMemoryMonitor({
criticalBytes = 2.5 * GB,
highBytes = 1.5 * GB,
intervalMs = 10_000,
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
onCritical,
onHigh
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
}: MemoryMonitorOptions = {}): () => void {
const dumped = new Set<Exclude<MemoryLevel, 'normal'>>()
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
const tick = async () => {
const { heapUsed, rss } = process.memoryUsage()
const level: MemoryLevel = heapUsed >= criticalBytes ? 'critical' : heapUsed >= highBytes ? 'high' : 'normal'
if (level === 'normal') {
return void dumped.clear()
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
}
if (dumped.has(level)) {
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
return
}
// Prune Ink content caches before dump/exit — half on 'high' (recoverable),
// full on 'critical' (post-dump RSS reduction, keeps user running).
perf(tui): shave ~190ms off `hermes --tui` cold start Two targeted fixes on the critical path from `hermes --tui` launch to `gateway.ready`: 1. **Defer `@hermes/ink` import in memoryMonitor.ts.** The static top-level import dragged the full ~414KB Ink bundle (React + renderer + all components/hooks) onto the critical path *before* `gw.start()` could spawn the Python gateway — serialising ~155ms of Node work in front of it on every launch. `evictInkCaches` only runs inside the 10-second tick under heap pressure, so it moves to a lazy dynamic import. First tick hits the ESM cache because the app entry has long since imported `@hermes/ink`. 2. **Gate `tools.mcp_tool` import on config in tui_gateway/entry.py.** Importing the module transitively pulls the MCP SDK + pydantic + httpx + jsonschema + starlette formparsers (~200ms). The overwhelming majority of users have no `mcp_servers` configured, so this runs for nothing. A cheap `load_config()` check (~25ms) skips the 200ms import when no servers are declared, with a conservative fallback to the old behaviour if the config probe itself fails. ## Measurements (macOS Terminal.app, Apple Silicon, n=12) | Metric | Before (p50) | After (p50) | Δ | |----------------------------|--------------|-------------|----------| | Python gateway boot alone | 252–365ms | 105–151ms | −180ms | | `hermes --tui` banner paint | 686ms | 665ms | −21ms | | `hermes --tui` → ready | **1843ms** | **1655ms** | **−188ms (−10.2%)** | | `hermes --tui` → ready p90 | 1932ms | 1778ms | −154ms | | stdev (ready) | 126ms | 83ms | also more consistent | ## Tests - `scripts/run_tests.sh tests/tui_gateway/ tests/tools/test_mcp_tool.py`: 195 passed. (The one pre-existing failure in `test_session_resume_returns_hydrated_messages` reproduces on main — unrelated, it's a mock-DB kwarg mismatch.) - `ui-tui` vitest: 430 tests, all pass. - `npm run type-check` in ui-tui: clean. ## Notes - Node-side first paint ("banner") didn't move meaningfully because that latency is dominated by Ink's render pipeline + React mount, not by which imports load first. - The win shows up entirely in the time from banner to `gateway.ready` — exactly where we expected it, since both fixes shorten the Python gateway's boot path or let it overlap more with Node startup. - No user-visible behaviour change. Memory monitoring still fires every 10s; MCP still works when `mcp_servers` is configured.
2026-04-28 19:42:31 -05:00
// Deferred import keeps `@hermes/ink` off the cold-start critical path;
// by the time a tick fires 10s after launch the app has already loaded
// the same module, so this resolves instantly from the ESM cache.
try {
const evictInkCaches = await _ensureEvictInkCaches()
evictInkCaches(level === 'critical' ? 'all' : 'half')
} catch {
// Best-effort: if the dynamic import fails for any reason we still
// continue to the heap dump below so the user gets diagnostics.
}
dumped.add(level)
const dump = await performHeapDump(level === 'critical' ? 'auto-critical' : 'auto-high').catch(() => null)
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
const snap: MemorySnapshot = { heapUsed, level, rss }
;(level === 'critical' ? onCritical : onHigh)?.(snap, dump)
fix(tui): harden against Node V8 OOM + GatewayClient memory leaks Long TUI sessions were crashing Node via V8 fatal-OOM once transcripts + reasoning blobs crossed the default 1.5–4GB heap cap. This adds defense in depth: a bigger heap, leak-proofing the RPC hot path, bounded diagnostic buffers, automatic heap dumps at high-water marks, and graceful signal / uncaught handlers. ## Changes ### Heap budget - hermes_cli/main.py: `_launch_tui` now injects `NODE_OPTIONS= --max-old-space-size=8192 --expose-gc` (appended — does not clobber user-supplied NODE_OPTIONS). Covers both `node dist/entry.js` and `tsx src/entry.tsx` launch paths. - ui-tui/src/entry.tsx: shebang rewritten to `#!/usr/bin/env -S node --max-old-space-size=8192 --expose-gc` as a fallback when the binary is invoked directly. ### GatewayClient (ui-tui/src/gatewayClient.ts) - `setMaxListeners(0)` — silences spurious warnings from React hook subscribers. - `logs` and `bufferedEvents` replaced with fixed-capacity CircularBuffer — O(1) push, no splice(0, …) copies under load. - RPC timeout refactor: `setTimeout(this.onTimeout.bind(this), …, id)` replaces the inline arrow closure that captured `method`/`params`/ `resolve`/`reject` for the full 120 s request timeout. Each Pending record now stores its own timeout handle, `.unref()`'d so stuck timers never keep the event loop alive, and `rejectPending()` clears them (previously leaked the timer itself). ### Memory diagnostics (new) - ui-tui/src/lib/memory.ts: `performHeapDump()` + `captureMemoryDiagnostics()`. Writes heap snapshot + JSON diag sidecar to `~/.hermes/heapdumps/` (override via `HERMES_HEAPDUMP_DIR`). Diagnostics are written first so we still get useful data if the snapshot crashes on very large heaps. Captures: detached V8 contexts (closure-leak signal), active handles/requests (`process._getActiveHandles/_getActiveRequests`), Linux `/proc/self/fd` count + `/proc/self/smaps_rollup`, heap growth rate (MB/hr), and auto-classifies likely leak sources. - ui-tui/src/lib/memoryMonitor.ts: 10 s interval polling heapUsed. At 1.5 GB writes an auto heap dump (trigger=`auto-high`); at 2.5 GB writes a final dump and exits 137 before V8 fatal-OOMs so the user can restart cleanly. Handle is `.unref()`'d so it never holds the process open. ### Graceful exit (new) - ui-tui/src/lib/gracefulExit.ts: SIGINT/SIGTERM/SIGHUP run registered cleanups through a 4 s failsafe `setTimeout` that hard-exits if cleanup hangs. `uncaughtException` / `unhandledRejection` are logged to stderr instead of crashing — a transient TUI render error should not kill an in-flight agent turn. ### Slash commands (new) - ui-tui/src/app/slash/commands/debug.ts: - `/heapdump` — manual snapshot + diagnostics. - `/mem` — live heap / rss / external / array-buffer / uptime panel. - Registered in `ui-tui/src/app/slash/registry.ts`. ### Utility (new) - ui-tui/src/lib/circularBuffer.ts: small fixed-capacity ring buffer with `push` / `tail(n)` / `drain()` / `clear()`. Replaces the ad-hoc `array.splice(0, len - MAX)` pattern. ## Validation - tsc `--noEmit` clean - `vitest run`: 15 files, 102 tests passing - eslint clean on all touched/new files - build produces executable `dist/entry.js` with preserved shebang - smoke-tested: `HERMES_HEAPDUMP_DIR=… performHeapDump('manual')` writes both a valid `.heapsnapshot` and a `.diagnostics.json` containing detached-contexts, active-handles, smaps_rollup. ## Env knobs - `HERMES_HEAPDUMP_DIR` — override snapshot output dir - `HERMES_HEAPDUMP_ON_START=1` — dump once at boot - existing `NODE_OPTIONS` is respected and appended, not replaced
2026-04-20 18:35:18 -05:00
}
const handle = setInterval(() => void tick(), intervalMs)
handle.unref?.()
return () => clearInterval(handle)
}