Download ui-tui/src/lib/memory.ts from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 8.71 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/ui-tui/src/lib/memory.ts
- Command line
-
hf download hf://SaylorTwift/hermes-agent/ui-tui/src/lib/memory.ts
-
curl -L -o memory.ts https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/ui-tui/src/lib/memory.ts
8.71 kB
| import { createWriteStream } from 'node:fs' | |
| 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' | |
| import { getHeapSnapshot, getHeapSpaceStatistics, getHeapStatistics } from 'node:v8' | |
| export type MemoryTrigger = 'auto-critical' | 'auto-high' | 'manual' | |
| export interface MemoryDiagnostics { | |
| activeHandles: number | |
| activeRequests: number | |
| analysis: { | |
| potentialLeaks: string[] | |
| recommendation: string | |
| } | |
| memoryGrowthRate: { | |
| bytesPerSecond: number | |
| mbPerHour: number | |
| } | |
| memoryUsage: { | |
| arrayBuffers: number | |
| external: number | |
| heapTotal: number | |
| heapUsed: number | |
| rss: number | |
| } | |
| nodeVersion: string | |
| openFileDescriptors?: number | |
| platform: string | |
| resourceUsage: { | |
| maxRSS: number | |
| systemCPUTime: number | |
| userCPUTime: number | |
| } | |
| smapsRollup?: string | |
| timestamp: string | |
| trigger: MemoryTrigger | |
| uptimeSeconds: number | |
| v8HeapSpaces?: { available: number; name: string; size: number; used: number }[] | |
| v8HeapStats: { | |
| detachedContexts: number | |
| heapSizeLimit: number | |
| mallocedMemory: number | |
| nativeContexts: number | |
| peakMallocedMemory: number | |
| } | |
| } | |
| 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 | |
| } | |
| export async function captureMemoryDiagnostics(trigger: MemoryTrigger): Promise<MemoryDiagnostics> { | |
| const usage = process.memoryUsage() | |
| const heapStats = getHeapStatistics() | |
| const resourceUsage = process.resourceUsage() | |
| const uptimeSeconds = process.uptime() | |
| // Not available on Bun / older Node. | |
| let heapSpaces: ReturnType<typeof getHeapSpaceStatistics> | undefined | |
| try { | |
| heapSpaces = getHeapSpaceStatistics() | |
| } catch { | |
| /* noop */ | |
| } | |
| const internals = process as unknown as { | |
| _getActiveHandles: () => unknown[] | |
| _getActiveRequests: () => unknown[] | |
| } | |
| const activeHandles = internals._getActiveHandles().length | |
| const activeRequests = internals._getActiveRequests().length | |
| const openFileDescriptors = await swallow(async () => (await readdir('/proc/self/fd')).length) | |
| const smapsRollup = await swallow(() => readFile('/proc/self/smaps_rollup', 'utf8')) | |
| const nativeMemory = usage.rss - usage.heapUsed | |
| // Real growth rate since STARTED_AT (captured at module load) — NOT a lifetime | |
| // average of rss/uptime, which would report phantom "growth" for a stable process. | |
| const elapsed = Math.max(0, uptimeSeconds - STARTED_AT.uptime) | |
| const bytesPerSecond = elapsed > 0 ? (usage.rss - STARTED_AT.rss) / elapsed : 0 | |
| const mbPerHour = (bytesPerSecond * 3600) / (1024 * 1024) | |
| const potentialLeaks = [ | |
| heapStats.number_of_detached_contexts > 0 && | |
| `${heapStats.number_of_detached_contexts} detached context(s) — possible component/closure leak`, | |
| activeHandles > 100 && `${activeHandles} active handles — possible timer/socket leak`, | |
| nativeMemory > usage.heapUsed && 'Native memory > heap — leak may be in native addons', | |
| mbPerHour > 100 && `High memory growth rate: ${mbPerHour.toFixed(1)} MB/hour`, | |
| openFileDescriptors && openFileDescriptors > 500 && `${openFileDescriptors} open FDs — possible file/socket leak` | |
| ].filter((s): s is string => typeof s === 'string') | |
| return { | |
| activeHandles, | |
| activeRequests, | |
| analysis: { | |
| potentialLeaks, | |
| recommendation: potentialLeaks.length | |
| ? `WARNING: ${potentialLeaks.length} potential leak indicator(s). See potentialLeaks.` | |
| : 'No obvious leak indicators. Inspect heap snapshot for retained objects.' | |
| }, | |
| memoryGrowthRate: { bytesPerSecond, mbPerHour }, | |
| memoryUsage: { | |
| arrayBuffers: usage.arrayBuffers, | |
| external: usage.external, | |
| heapTotal: usage.heapTotal, | |
| heapUsed: usage.heapUsed, | |
| rss: usage.rss | |
| }, | |
| nodeVersion: process.version, | |
| openFileDescriptors, | |
| platform: process.platform, | |
| resourceUsage: { | |
| maxRSS: resourceUsage.maxRSS * 1024, | |
| systemCPUTime: resourceUsage.systemCPUTime, | |
| userCPUTime: resourceUsage.userCPUTime | |
| }, | |
| smapsRollup, | |
| timestamp: new Date().toISOString(), | |
| trigger, | |
| uptimeSeconds, | |
| v8HeapSpaces: heapSpaces?.map(s => ({ | |
| available: s.space_available_size, | |
| name: s.space_name, | |
| size: s.space_size, | |
| used: s.space_used_size | |
| })), | |
| v8HeapStats: { | |
| detachedContexts: heapStats.number_of_detached_contexts, | |
| heapSizeLimit: heapStats.heap_size_limit, | |
| mallocedMemory: heapStats.malloced_memory, | |
| nativeContexts: heapStats.number_of_native_contexts, | |
| peakMallocedMemory: heapStats.peak_malloced_memory | |
| } | |
| } | |
| } | |
| export async function performHeapDump(trigger: MemoryTrigger = 'manual'): Promise<HeapDumpResult> { | |
| try { | |
| // Diagnostics first — heap-snapshot serialization can crash on very large | |
| // heaps, and the JSON sidecar is the most actionable artifact if so. | |
| const diagnostics = await captureMemoryDiagnostics(trigger) | |
| const dir = process.env.HERMES_HEAPDUMP_DIR?.trim() || join(homedir() || tmpdir(), '.hermes', 'heapdumps') | |
| await mkdir(dir, { recursive: true }) | |
| const base = `hermes-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}-${trigger}` | |
| 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) { | |
| return { error: e instanceof Error ? e.message : String(e), success: false } | |
| } | |
| } | |
| // 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' | |
| } | |
| const exp = Math.min(UNITS.length - 1, Math.floor(Math.log10(bytes) / 3)) | |
| const value = bytes / 1024 ** exp | |
| return `${value >= 100 ? value.toFixed(0) : value.toFixed(1)}${UNITS[exp]}` | |
| } | |
| const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] | |
| const STARTED_AT = { rss: process.memoryUsage().rss, uptime: process.uptime() } | |
| // Returns undefined when the probe isn't available (non-Linux paths, sandboxed FS). | |
| const swallow = async <T>(fn: () => Promise<T>): Promise<T | undefined> => { | |
| try { | |
| return await fn() | |
| } catch { | |
| return undefined | |
| } | |
| } | |