Memory exhaustion and OOM crashes in TUI and Desktop
Cluster 12682. Data as of
Nothing open on GitHub. Fix merged in #12888 on 20 Apr 2026. Check it covers your case.
The short answer
- Known?
- Yes. 4 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #12888. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #12888. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple reports and fixes address JavaScript heap out-of-memory conditions and related crashes in both the TUI and Electron desktop environments, including unbounded state retention, idle queue issues, and tool output accumulation.
Open issues0
Open PRs0
Fixes on record
-
#12754 fix(tui): bound retained state against idle OOM
files:
ui-tui/packages/hermes-ink/src/ink/screen.ts,ui-tui/src/app/useComposerState.ts,ui-tui/src/app/useMainApp.ts,ui-tui/src/gatewayClient.tsShow the diff
-
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<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) } -
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<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 }) -
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') -
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) } }
-
-
#12888 fix(tui): guard empty idle queue drain to stop idle OOM
files:
scripts/release.py,ui-tui/src/app/useMainApp.ts
1 PR closed without merging
From the close comments: merged via another change 1
-
#12822 fix(tui): guard empty idle queue drain to stop idle OOM · merged via another change →
#12888,#12754
Closed issues · 1
- Closed#12682TUI mode crashes with "JavaScript heap out of memory" after prolonged use
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.50: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab