mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
424e9f36b0
commit
0d353ca6a8
4 changed files with 44 additions and 5 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue