From 0d353ca6a89c8c29ccee4e88bdaa9d580fcfd5eb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 19 Apr 2026 19:41:25 -0500 Subject: [PATCH] fix(tui): bound retained state against idle OOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guards four unbounded growth paths reachable at idle — the shape matches reports of the TUI hitting V8's 2GB heap limit after ~1m of idle with 0 tokens used (Mark-Compact freed ~6MB of 2045MB → pure retention). - `GatewayClient.logs` + `gateway.stderr` events: 200-line cap is bytes- uncapped; a chatty Python child emitting multi-MB lines (traceback, dumped config, unsplit JSON) retains everything. Truncate at 4KB/line. - `GatewayClient.bufferedEvents`: unbounded until `drain()` fires. Cap at 2000 so a pre-mount event storm can't pin memory indefinitely. - `useMainApp` gateway `exit` handler: didn't reset `turnController`, so a mid-stream crash left `bufRef`/`reasoningText` alive forever. - `pasteSnips` count-capped (32) but byte-uncapped. Add a 4MB total cap and clear snips in `clearIn` so submitted pastes don't linger. - `StylePool.transitionCache`: uncapped `Map`. Full-clear at 32k entries (mirrors `charCache` pattern). --- ui-tui/packages/hermes-ink/src/ink/screen.ts | 10 +++++++- ui-tui/src/app/useComposerState.ts | 25 +++++++++++++++++++- ui-tui/src/app/useMainApp.ts | 1 + ui-tui/src/gatewayClient.ts | 13 +++++++--- 4 files changed, 44 insertions(+), 5 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/screen.ts b/ui-tui/packages/hermes-ink/src/ink/screen.ts index 5a9b9df22..9dea20132 100644 --- a/ui-tui/packages/hermes-ink/src/ink/screen.ts +++ b/ui-tui/packages/hermes-ink/src/ink/screen.ts @@ -121,6 +121,8 @@ const YELLOW_FG_CODE: AnsiCode = { endCode: '\x1b[39m' } +const MAX_TRANSITION_CACHE = 32768 + export class StylePool { private ids = new Map() private styles: AnsiCode[][] = [] @@ -160,7 +162,9 @@ export class StylePool { /** * Returns the pre-serialized ANSI string to transition from one style to * another. Cached by (fromId, toId) — zero allocations after first call - * for a given pair. + * for a given pair. Full-clear at MAX_TRANSITION_CACHE guards against + * unbounded growth from ever-expanding id spaces; cache repopulates from + * the next frame's actual transitions. */ transition(fromId: number, toId: number): string { if (fromId === toId) { @@ -171,6 +175,10 @@ export class StylePool { let str = this.transitionCache.get(key) if (str === undefined) { + if (this.transitionCache.size >= MAX_TRANSITION_CACHE) { + this.transitionCache.clear() + } + str = ansiCodesToString(diffAnsiCodes(this.get(fromId), this.get(toId))) this.transitionCache.set(key, str) } diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index bebda273d..4c47b2b70 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -16,6 +16,28 @@ import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js' import type { PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js' import { $isBlocked } from './overlayStore.js' +const PASTE_SNIP_MAX_COUNT = 32 +const PASTE_SNIP_MAX_TOTAL_BYTES = 4 * 1024 * 1024 + +const trimSnips = (snips: PasteSnippet[]): PasteSnippet[] => { + let total = 0 + const out: PasteSnippet[] = [] + + for (let i = snips.length - 1; i >= 0; i--) { + const snip = snips[i]! + const size = snip.text.length + + if (out.length >= PASTE_SNIP_MAX_COUNT || total + size > PASTE_SNIP_MAX_TOTAL_BYTES) { + break + } + + total += size + out.unshift(snip) + } + + return out.length === snips.length ? snips : out +} + export function useComposerState({ gw, onClipboardPaste, submitRef }: UseComposerStateOptions): UseComposerStateResult { const [input, setInput] = useState('') const [inputBuf, setInputBuf] = useState([]) @@ -31,6 +53,7 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose const clearIn = useCallback(() => { setInput('') setInputBuf([]) + setPasteSnips([]) setQueueEdit(null) setHistoryIdx(null) historyDraftRef.current = '' @@ -68,7 +91,7 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' const insert = `${lead}${label}${tail}` - setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32)) + setPasteSnips(prev => trimSnips([...prev, { label, text: cleanedText }])) void gw .request<{ path?: string }>('paste.collapse', { text: cleanedText }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index e0c18dec6..aa27dea28 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -448,6 +448,7 @@ export function useMainApp(gw: GatewayClient) { const handler = (ev: GatewayEvent) => onEventRef.current(ev) const exitHandler = () => { + turnController.reset() patchUiState({ busy: false, sid: null, status: 'gateway exited' }) turnController.pushActivity('gateway exited · /logs to inspect', 'error') sys('error: gateway exited') diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 3d5f89eb8..a238c7638 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -7,10 +7,15 @@ import { createInterface } from 'node:readline' import type { GatewayEvent } from './gatewayTypes.js' const MAX_GATEWAY_LOG_LINES = 200 +const MAX_LOG_LINE_BYTES = 4096 +const MAX_BUFFERED_EVENTS = 2000 const MAX_LOG_PREVIEW = 240 const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_TUI_STARTUP_TIMEOUT_MS ?? '15000', 10) || 15000) const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000) +const truncateLine = (line: string) => + line.length > MAX_LOG_LINE_BYTES ? `${line.slice(0, MAX_LOG_LINE_BYTES)}… [truncated ${line.length} bytes]` : line + const resolvePython = (root: string) => { const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim() @@ -69,7 +74,9 @@ export class GatewayClient extends EventEmitter { return void this.emit('event', ev) } - this.bufferedEvents.push(ev) + if (this.bufferedEvents.push(ev) > MAX_BUFFERED_EVENTS) { + this.bufferedEvents.splice(0, this.bufferedEvents.length - MAX_BUFFERED_EVENTS) + } } start() { @@ -121,7 +128,7 @@ export class GatewayClient extends EventEmitter { this.stderrRl = createInterface({ input: this.proc.stderr! }) this.stderrRl.on('line', raw => { - const line = raw.trim() + const line = truncateLine(raw.trim()) if (!line) { return @@ -181,7 +188,7 @@ export class GatewayClient extends EventEmitter { } private pushLog(line: string) { - if (this.logs.push(line) > MAX_GATEWAY_LOG_LINES) { + if (this.logs.push(truncateLine(line)) > MAX_GATEWAY_LOG_LINES) { this.logs.splice(0, this.logs.length - MAX_GATEWAY_LOG_LINES) } }