TUI heap dump disk-fill risk and memory guards
Cluster 21767 · hermes cli. Data as of
Nothing open on GitHub. Fix merged in #41948 on 8 Jun 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 #41948. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #41948. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and pull requests address automatic heap dumps in the TUI filling disk space, proposing or implementing opt-in controls and retention limits, alongside a related change to bound memory pressure during live rendering.
Open issues0
Open PRs0
Fixes on record
-
#41948 fix(tui): guard automatic heap dumps against disk fill (#21767)
files:
scripts/release.py,ui-tui/src/entry.tsx,ui-tui/src/lib/memory.test.ts,ui-tui/src/lib/memory.ts,ui-tui/src/lib/memoryMonitor.tsShow the diff
-
scripts/release.py@@ -65,6 +65,7 @@ "[email protected]": "HeLLGURD", "[email protected]": "synapsesx", "[email protected]": "dirtyren", + "[redacted:email]": "alarcritty", "[email protected]": "islam666", "[email protected]": "lsaether", "[email protected]": "JimStenstrom", -
ui-tui/src/entry.tsx@@ -37,7 +37,7 @@ const gw = new GatewayClient() gw.start() const dumpNotice = (snap: MemorySnapshot, dump: HeapDumpResult | null) => - `hermes-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? '(failed)'}\n` + `hermes-tui: ${snap.level} memory (${formatBytes(snap.heapUsed)}) — auto heap dump → ${dump?.heapPath ?? dump?.diagPath ?? '(failed)'}\n` setupGracefulExit({ cleanups: [ -
ui-tui/src/lib/memory.test.ts@@ -0,0 +1,162 @@ +import { mkdtempSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { performHeapDump } from './memory.js' + +const ENV_KEYS = ['HERMES_AUTO_HEAPDUMP', 'HERMES_HEAPDUMP_DIR', 'HERMES_HEAPDUMP_MAX_BYTES'] as const + +describe('performHeapDump auto opt-in gate (#21767)', () => { + let saved: Record<string, string | undefined> + let dir: string + + beforeEach(() => { + saved = {} + + for (const k of ENV_KEYS) { + saved[k] = process.env[k] + delete process.env[k] + } + + dir = mkdtempSync(join(tmpdir(), 'hermes-heapdump-test-')) + process.env.HERMES_HEAPDUMP_DIR = dir + }) + + afterEach(() => { + for (const k of ENV_KEYS) { + if (saved[k] === undefined) { + delete process.env[k] + } else { + process.env[k] = saved[k] + } + } + + rmSync(dir, { force: true, recursive: true }) + }) + + it('writes diagnostics only for auto-high without HERMES_AUTO_HEAPDUMP', async () => { + const result = await performHeapDump('auto-high') + + expect(result.success).toBe(true) + expect(result.suppressed).toBe(true) + expect(result.diagPath).toBeDefined() + expect(result.heapPath).toBeUndefined() + + const files = readdirSync(dir) + expect(files.some(f => f.endsWith('.diagnostics.json'))).toBe(true) + expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(false) + }) + + it('writes diagnostics only for auto-critical without HERMES_AUTO_HEAPDUMP', async () => { + const result = await performHeapDump('auto-critical') + + expect(result.success).toBe(true) + expect(result.suppressed).toBe(true) + expect(result.heapPath).toBeUndefined() + + const files = readdirSync(dir) + expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(false) + }) + + it('writes both diagnostics and snapshot for auto-high when HERMES_AUTO_HEAPDUMP=1', async () => { + process.env.HERMES_AUTO_HEAPDUMP = '1' + + const result = await performHeapDump('auto-high') + + expect(result.success).toBe(true) + expect(result.suppressed).toBeUndefined() + expect(result.diagPath).toBeDefined() + expect(result.heapPath).toBeDefined() + + const files = readdirSync(dir) + expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(true) + }) + + it('accepts truthy spellings (true|yes|on, case-insensitive) as opt-in', async () => { + for (const value of ['true', 'YES', 'On']) { + process.env.HERMES_AUTO_HEAPDUMP = value + const result = await performHeapDump('auto-high') + + expect(result.success).toBe(true) + expect(result.heapPath).toBeDefined() + } + }) + + it('treats other values (0, off, garbage) as opt-out for auto triggers', async () => { + for (const value of ['0', 'off', 'nope']) { + process.env.HERMES_AUTO_HEAPDUMP = value + const result = await performHeapDump('auto-high') + + expect(result.success).toBe(true) + expect(result.suppressed).toBe(true) + expect(result.heapPath).toBeUndefined() + } + }) + + it('writes both for manual triggers regardless of HERMES_AUTO_HEAPDUMP', async () => { + const result = await performHeapDump('manual') + + expect(result.success).toBe(true) + expect(result.suppressed).toBeUndefined() + expect(result.heapPath).toBeDefined() + + const files = readdirSync(dir) + expect(files.some(f => f.endsWith('.heapsnapshot'))).toBe(true) + }) +}) + +describe('heapdump retention guard (#21767)', () => { + let savedDir: string | undefined + let savedMax: string | undefined + let dir: string + + beforeEach(() => { + savedDir = process.env.HERMES_HEAPDUMP_DIR + savedMax = process.env.HERMES_HEAPDUMP_MAX_BYTES + delete process.env.HERMES_AUTO_HEAPDUMP + dir = mkdtempSync(join(tmpdir(), 'hermes-heapdump-prune-')) + process.env.HERMES_HEAPDUMP_DIR = dir + }) + + afterEach(() => { + if (savedDir === undefined) {delete process.env.HERMES_HEAPDUMP_DIR} + else {process.env.HERMES_HEAPDUMP_DIR = savedDir} + + if (savedMax === undefined) {delete process.env.HERMES_HEAPDUMP_MAX_BYTES} + else {process.env.HERMES_HEAPDUMP_MAX_BYTES = savedMax} + + rmSync(dir, { force: true, recursive: true }) + }) + + it('evicts oldest files when total bytes exceed the cap, retaining the newest', async () => { + // 4 pre-existing dumps, 1KB each, with ascending mtimes (oldest first). + const blob = 'x'.repeat(1024) + const now = Date.now() + + for (let i = 0; i < 4; i++) { + const p = join(dir, `old-${i}.heapsnapshot`) + writeFileSync(p, blob) + const t = (now - (4 - i) * 60_000) / 1000 + utimesSync(p, t, t) + } + + // Cap at 2KB → a fresh diagnostics write should trigger a prune down to ~cap. + process.env.HERMES_HEAPDUMP_MAX_BYTES = String(2 * 1024) + + const result = await performHeapDump('auto-high') + expect(result.success).toBe(true) + + const remaining = readdirSync(dir) + const totalBytes = remaining.reduce((acc, f) => acc + statSync(join(dir, f)).size, 0) + // Contract: prune evicts oldest-first until total <= cap, but always keeps + // the single newest file even if it alone exceeds the cap. So either the + // total is under cap, or exactly one (newest) file remains. + expect(totalBytes <= 2 * 1024 || remaining.length === 1).toBe(true) + // The old 1KB dumps must have been pruned down from the original four. + expect(remaining.length).toBeLessThan(5) + // The brand-new diagnostics sidecar must survive the prune. + expect(remaining.some(f => f.endsWith('.diagnostics.json'))).toBe(true) + }) +}) -
ui-tui/src/lib/memory.ts@@ -1,5 +1,5 @@ import { createWriteStream } from 'node:fs' -import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readdir, readFile, stat, unlink, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { pipeline } from 'node:stream/promises' @@ -51,6 +51,9 @@ export interface HeapDumpResult { diagPath?: string error?: string heapPath?: string + // True when an auto trigger wrote diagnostics only and intentionally skipped + // the heavy snapshot because HERMES_AUTO_HEAPDUMP was not enabled (#21767). + suppressed?: boolean success: boolean } @@ -153,8 +156,26 @@ export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promis const heapPath = join(dir, `${base}.heapsnapshot`) const diagPath = join(dir, `${base}.diagnostics.json`) + // The diagnostics JSON is KB-sized and the most useful artifact when a + // full snapshot is suppressed by the auto-heapdump opt-in gate below. await writeFile(diagPath, JSON.stringify(diagnostics, null, 2), { mode: 0o600 }) + + // Auto triggers require explicit opt-in: multi-GiB snapshots written on + // every threshold cross can fill the user's disk (issue #21767). + const isAuto = trigger === 'auto-critical' || trigger === 'auto-high' + const autoEnabled = /^(?:1|true|yes|on)$/i.test((process.env.HERMES_AUTO_HEAPDUMP ?? '').trim()) + + if (isAuto && !autoEnabled) { + await pruneHeapdumps(dir).catch(() => undefined) + + // Not an error: the dump did its job — it wrote the lightweight + // diagnostics sidecar and intentionally skipped the heavy snapshot. + // `heapPath` is omitted so callers/notices report diagnostics-only. + return { diagPath, suppressed: true, success: true } + } + await pipeline(getHeapSnapshot(), createWriteStream(heapPath, { mode: 0o600 })) + await pruneHeapdumps(dir).catch(() => undefined) return { diagPath, heapPath, success: true } } catch (e) { @@ -162,6 +183,44 @@ export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promis } } +// Cap total bytes of files in `dir`, deleting oldest first. Covers both +// `.heapsnapshot` and `.diagnostics.json` artifacts so orphan sidecars from +// gated auto-triggers cannot accumulate without bound. The newest file is +// always retained even if it alone exceeds the cap. +async function pruneHeapdumps(dir: string): Promise<void> { + const raw = process.env.HERMES_HEAPDUMP_MAX_BYTES?.trim() + const parsed = raw ? Number(raw) : NaN + const cap = Number.isFinite(parsed) && parsed > 0 ? parsed : 2 * 1024 ** 3 + + const names = await readdir(dir) + + const stats = await Promise.all( + names.map(async name => { + const path = join(dir, name) + const s = await stat(path).catch(() => null) + + return s && s.isFile() ? { mtimeMs: s.mtimeMs, path, size: s.size } : null + }) + ) + + const valid = stats.filter((s): s is { mtimeMs: number; path: string; size: number } => s !== null) + + valid.sort((a, b) => b.mtimeMs - a.mtimeMs) + + let total = valid.reduce((acc, s) => acc + s.size, 0) + + while (total > cap && valid.length > 1) { + const oldest = valid.pop() + + if (!oldest) { + break + } + + await unlink(oldest.path).catch(() => undefined) + total -= oldest.size + } +} + export function formatBytes(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) { return '0B' -
ui-tui/src/lib/memoryMonitor.ts@@ -111,6 +111,14 @@ export function startMemoryMonitor({ let warned = false const WARN_GROWTH_STEP = 150 * MB + // Cooldown prevents repeated auto dumps when heap oscillates around the + // threshold (issue #21767). `dumped` alone is not enough — it clears on + // every transition back to `normal`. + const cooldownRaw = process.env.HERMES_AUTO_HEAPDUMP_COOLDOWN_MS?.trim() + const cooldownParsed = cooldownRaw ? Number(cooldownRaw) : NaN + const cooldownMs = Number.isFinite(cooldownParsed) && cooldownParsed >= 0 ? cooldownParsed : 600_000 + let lastAutoDumpAt = 0 + const tick = async () => { const { heapUsed, rss } = process.memoryUsage() @@ -137,7 +145,12 @@ export function startMemoryMonitor({ return } + if (Date.now() - lastAutoDumpAt < cooldownMs) { + return + } + inFlight.add(level) + lastAutoDumpAt = Date.now() // Prune Ink content caches before dump/exit — half on 'high' (recoverable), // full on 'critical' (post-dump RSS reduction, keeps user running).
-
2 PRs closed without merging
From the close comments: merged via another change 1 · stale or abandoned 1
Closed issues · 1
- Closed#21767TUI automatic heap dumps can fill disk; add opt-in or retention guard
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 LabAt a glance
- Subsystem
- hermes cli
- Merged PRs
- #41948
- Closed issues
- 1
- Attempts
- 2 PRs closed without merging
- Fix link checked
- 8 Jun 2026