mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
perf(tui): unified Ink cache eviction on memory pressure + session reset
Adds an `evictInkCaches(level)` API that prunes the four hot module-level caches (`widthCache`, `wrapCache`, `sliceCache`, `lineWidthCache`) with either a half-keep LRU pass or a full clear. Wired into: - memoryMonitor: half-prune on 'high', full drop on 'critical', before the heap dump / auto-restart path. Gives long sessions a shot at recovering RSS instead of hard-exiting. - useSessionLifecycle.resetSession: half-prune so a /new session starts with a half-warm pool and the prior session can resume cheaply. Also: lineWidthCache now uses LRU half-eviction on overflow instead of a full `cache.clear()`, matching the other three caches. Comparison vs claude-code: both forks now share the same `prevScreen` blit + dirty-cascade machinery in render-node-to-output. Their smoothness came from sibling-memo discipline (every chrome pane memo'd so dirty cascade doesn't disable transcript blit) — already in place in our appLayout.tsx (TranscriptPane / ComposerPane / StatusRulePane all memo'd). Alt-screen is not the cause; both use it. The remaining gap was per-row CPU on width/wrap/slice, which the previous commit closed.
This commit is contained in:
parent
c370e2e1e5
commit
25767513f2
10 changed files with 147 additions and 4 deletions
2
ui-tui/packages/hermes-ink/index.d.ts
vendored
2
ui-tui/packages/hermes-ink/index.d.ts
vendored
|
|
@ -4,6 +4,8 @@ export type { StderrHandle } from './src/hooks/use-stderr.ts'
|
||||||
export { default as useStdout } from './src/hooks/use-stdout.ts'
|
export { default as useStdout } from './src/hooks/use-stdout.ts'
|
||||||
export type { StdoutHandle } from './src/hooks/use-stdout.ts'
|
export type { StdoutHandle } from './src/hooks/use-stdout.ts'
|
||||||
export { Ansi } from './src/ink/Ansi.tsx'
|
export { Ansi } from './src/ink/Ansi.tsx'
|
||||||
|
export { evictInkCaches, inkCacheSizes } from './src/ink/cache-eviction.ts'
|
||||||
|
export type { EvictLevel, InkCacheSizes } from './src/ink/cache-eviction.ts'
|
||||||
export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx'
|
export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx'
|
||||||
export { default as Box } from './src/ink/components/Box.tsx'
|
export { default as Box } from './src/ink/components/Box.tsx'
|
||||||
export type { Props as BoxProps } from './src/ink/components/Box.tsx'
|
export type { Props as BoxProps } from './src/ink/components/Box.tsx'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
export { default as useStderr } from './hooks/use-stderr.js'
|
export { default as useStderr } from './hooks/use-stderr.js'
|
||||||
export { default as useStdout } from './hooks/use-stdout.js'
|
export { default as useStdout } from './hooks/use-stdout.js'
|
||||||
export { Ansi } from './ink/Ansi.js'
|
export { Ansi } from './ink/Ansi.js'
|
||||||
|
export {
|
||||||
|
evictInkCaches,
|
||||||
|
type EvictLevel,
|
||||||
|
type InkCacheSizes,
|
||||||
|
inkCacheSizes
|
||||||
|
} from './ink/cache-eviction.js'
|
||||||
export { AlternateScreen } from './ink/components/AlternateScreen.js'
|
export { AlternateScreen } from './ink/components/AlternateScreen.js'
|
||||||
export { default as Box } from './ink/components/Box.js'
|
export { default as Box } from './ink/components/Box.js'
|
||||||
export { default as Link } from './ink/components/Link.js'
|
export { default as Link } from './ink/components/Link.js'
|
||||||
|
|
|
||||||
46
ui-tui/packages/hermes-ink/src/ink/cache-eviction.ts
Normal file
46
ui-tui/packages/hermes-ink/src/ink/cache-eviction.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// Unified cache eviction for the four hot Ink module-level caches:
|
||||||
|
// - widthCache (stringWidth.ts)
|
||||||
|
// - wrapCache (wrap-text.ts)
|
||||||
|
// - sliceCache (sliceAnsi.ts)
|
||||||
|
// - lineWidthCache (line-width-cache.ts)
|
||||||
|
//
|
||||||
|
// Used by the host (TUI) under memory pressure or on session swap to drop
|
||||||
|
// content-keyed entries that won't recur. All caches are content-keyed
|
||||||
|
// (not session-keyed), so cross-session sharing is normally beneficial —
|
||||||
|
// only evict when memory tightens or when the user explicitly resets.
|
||||||
|
|
||||||
|
import { evictLineWidthCache, lineWidthCacheSize } from './line-width-cache.js'
|
||||||
|
import { evictWidthCache, widthCacheSize } from './stringWidth.js'
|
||||||
|
import { evictWrapCache, wrapCacheSize } from './wrap-text.js'
|
||||||
|
|
||||||
|
import { evictSliceCache, sliceCacheSize } from '../utils/sliceAnsi.js'
|
||||||
|
|
||||||
|
export interface InkCacheSizes {
|
||||||
|
lineWidth: number
|
||||||
|
slice: number
|
||||||
|
width: number
|
||||||
|
wrap: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inkCacheSizes(): InkCacheSizes {
|
||||||
|
return {
|
||||||
|
lineWidth: lineWidthCacheSize(),
|
||||||
|
slice: sliceCacheSize(),
|
||||||
|
width: widthCacheSize(),
|
||||||
|
wrap: wrapCacheSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EvictLevel = 'all' | 'half'
|
||||||
|
|
||||||
|
export function evictInkCaches(level: EvictLevel = 'half'): InkCacheSizes {
|
||||||
|
const before = inkCacheSizes()
|
||||||
|
const keep = level === 'half' ? 0.5 : 0
|
||||||
|
|
||||||
|
evictWidthCache(keep)
|
||||||
|
evictWrapCache(keep)
|
||||||
|
evictSliceCache(keep)
|
||||||
|
evictLineWidthCache(keep)
|
||||||
|
|
||||||
|
return before
|
||||||
|
}
|
||||||
|
|
@ -11,18 +11,35 @@ export function lineWidth(line: string): number {
|
||||||
const cached = cache.get(line)
|
const cached = cache.get(line)
|
||||||
|
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
|
cache.delete(line)
|
||||||
|
cache.set(line, cached)
|
||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
const width = stringWidth(line)
|
const width = stringWidth(line)
|
||||||
|
|
||||||
// Evict when cache grows too large (e.g. after many different responses).
|
|
||||||
// Simple full-clear is fine — the cache repopulates in one frame.
|
|
||||||
if (cache.size >= MAX_CACHE_SIZE) {
|
if (cache.size >= MAX_CACHE_SIZE) {
|
||||||
cache.clear()
|
cache.delete(cache.keys().next().value!)
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.set(line, width)
|
cache.set(line, width)
|
||||||
|
|
||||||
return width
|
return width
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function lineWidthCacheSize(): number {
|
||||||
|
return cache.size
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evictLineWidthCache(keepRatio = 0): void {
|
||||||
|
if (keepRatio <= 0) {
|
||||||
|
cache.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = Math.floor(cache.size * keepRatio)
|
||||||
|
|
||||||
|
while (cache.size > target) {
|
||||||
|
cache.delete(cache.keys().next().value!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -325,3 +325,20 @@ export const stringWidth: (str: string) => number = str => {
|
||||||
|
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function widthCacheSize(): number {
|
||||||
|
return widthCache.size
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evictWidthCache(keepRatio = 0): void {
|
||||||
|
if (keepRatio <= 0) {
|
||||||
|
widthCache.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = Math.floor(widthCache.size * keepRatio)
|
||||||
|
|
||||||
|
while (widthCache.size > target) {
|
||||||
|
widthCache.delete(widthCache.keys().next().value!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,3 +107,20 @@ export default function wrapText(text: string, maxWidth: number, wrapType: Style
|
||||||
|
|
||||||
return memoizedWrap(text, maxWidth, wrapType)
|
return memoizedWrap(text, maxWidth, wrapType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function wrapCacheSize(): number {
|
||||||
|
return wrapCache.size
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evictWrapCache(keepRatio = 0): void {
|
||||||
|
if (keepRatio <= 0) {
|
||||||
|
wrapCache.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = Math.floor(wrapCache.size * keepRatio)
|
||||||
|
|
||||||
|
while (wrapCache.size > target) {
|
||||||
|
wrapCache.delete(wrapCache.keys().next().value!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,23 @@ export default function sliceAnsi(str: string, start: number, end?: number): str
|
||||||
return computeSlice(str, start, end)
|
return computeSlice(str, start, end)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sliceCacheSize(): number {
|
||||||
|
return sliceCache.size
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evictSliceCache(keepRatio = 0): void {
|
||||||
|
if (keepRatio <= 0) {
|
||||||
|
sliceCache.clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = Math.floor(sliceCache.size * keepRatio)
|
||||||
|
|
||||||
|
while (sliceCache.size > target) {
|
||||||
|
sliceCache.delete(sliceCache.keys().next().value!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function computeSlice(str: string, start: number, end?: number): string {
|
function computeSlice(str: string, start: number, end?: number): string {
|
||||||
const tokens = tokenize(str)
|
const tokens = tokenize(str)
|
||||||
let activeCodes: AnsiCode[] = []
|
let activeCodes: AnsiCode[] = []
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { writeFileSync } from 'node:fs'
|
import { writeFileSync } from 'node:fs'
|
||||||
|
|
||||||
import type { ScrollBoxHandle } from '@hermes/ink'
|
import { evictInkCaches, type ScrollBoxHandle } from '@hermes/ink'
|
||||||
import { type RefObject, useCallback } from 'react'
|
import { type RefObject, useCallback } from 'react'
|
||||||
|
|
||||||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||||
|
|
@ -98,6 +98,9 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
||||||
setLastUserMsg('')
|
setLastUserMsg('')
|
||||||
setStickyPrompt('')
|
setStickyPrompt('')
|
||||||
composerActions.setPasteSnips([])
|
composerActions.setPasteSnips([])
|
||||||
|
// Half-prune Ink content caches: new session has new keys, but a partial
|
||||||
|
// warm pool helps if the user resumes back to the prior session.
|
||||||
|
evictInkCaches('half')
|
||||||
}, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording])
|
}, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording])
|
||||||
|
|
||||||
const resetVisibleHistory = useCallback(
|
const resetVisibleHistory = useCallback(
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { evictInkCaches } from '@hermes/ink'
|
||||||
|
|
||||||
import { type HeapDumpResult, performHeapDump } from './memory.js'
|
import { type HeapDumpResult, performHeapDump } from './memory.js'
|
||||||
|
|
||||||
export type MemoryLevel = 'critical' | 'high' | 'normal'
|
export type MemoryLevel = 'critical' | 'high' | 'normal'
|
||||||
|
|
@ -39,6 +41,12 @@ export function startMemoryMonitor({
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Defensive eviction: prune Ink content caches before dumping/exiting.
|
||||||
|
// 'high' = half-prune (still warm enough to recover quickly);
|
||||||
|
// 'critical' = full drop. Reduces post-dump RSS and gives the user a
|
||||||
|
// chance to keep running rather than auto-restart.
|
||||||
|
evictInkCaches(level === 'critical' ? 'all' : 'half')
|
||||||
|
|
||||||
dumped.add(level)
|
dumped.add(level)
|
||||||
const dump = await performHeapDump(level === 'critical' ? 'auto-critical' : 'auto-high').catch(() => null)
|
const dump = await performHeapDump(level === 'critical' ? 'auto-critical' : 'auto-high').catch(() => null)
|
||||||
|
|
||||||
|
|
|
||||||
10
ui-tui/src/types/hermes-ink.d.ts
vendored
10
ui-tui/src/types/hermes-ink.d.ts
vendored
|
|
@ -124,6 +124,16 @@ declare module '@hermes/ink' {
|
||||||
export const scrollFastPathStats: ScrollFastPathStats
|
export const scrollFastPathStats: ScrollFastPathStats
|
||||||
export function resetScrollFastPathStats(): void
|
export function resetScrollFastPathStats(): void
|
||||||
|
|
||||||
|
export type EvictLevel = 'all' | 'half'
|
||||||
|
export type InkCacheSizes = {
|
||||||
|
readonly lineWidth: number
|
||||||
|
readonly slice: number
|
||||||
|
readonly width: number
|
||||||
|
readonly wrap: number
|
||||||
|
}
|
||||||
|
export function evictInkCaches(level?: EvictLevel): InkCacheSizes
|
||||||
|
export function inkCacheSizes(): InkCacheSizes
|
||||||
|
|
||||||
export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance
|
export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance
|
||||||
|
|
||||||
export function useApp(): { readonly exit: (error?: Error) => void }
|
export function useApp(): { readonly exit: (error?: Error) => void }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue