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:
Brooklyn Nicholson 2026-04-26 19:41:53 -05:00
parent c370e2e1e5
commit 25767513f2
10 changed files with 147 additions and 4 deletions

View file

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

View file

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

View 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
}

View file

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

View file

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

View file

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

View file

@ -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[] = []

View file

@ -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(

View file

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

View file

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