fix(tui): bound retained state against idle OOM

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<number,string>`. Full-clear
  at 32k entries (mirrors `charCache` pattern).
This commit is contained in:
Brooklyn Nicholson 2026-04-19 19:41:25 -05:00 committed by Teknium
parent 424e9f36b0
commit 0d353ca6a8
4 changed files with 44 additions and 5 deletions

View file

@ -121,6 +121,8 @@ const YELLOW_FG_CODE: AnsiCode = {
endCode: '\x1b[39m'
}
const MAX_TRANSITION_CACHE = 32768
export class StylePool {
private ids = new Map<string, number>()
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)
}

View file

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

View file

@ -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')

View file

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