diff --git a/src/main/ipc/performance.ts b/src/main/ipc/performance.ts index 95bfe3caa..30f75dfad 100644 --- a/src/main/ipc/performance.ts +++ b/src/main/ipc/performance.ts @@ -1,9 +1,13 @@ import { ipcMain } from 'electron' +import { getHeapStatistics } from 'node:v8' import { performanceService } from '@main/performance/PerformanceService.js' import { ProcessTelemetry } from '@main/performance/ProcessTelemetry.js' import type { SessionManager } from '@main/sessionManager.js' -import type { PerformanceRecord } from '@shared/performance/types.js' +import type { + PerformanceRecord, + SystemPerformanceStats, +} from '@shared/performance/types.js' export function registerPerformanceIpc(manager: SessionManager): void { const processTelemetry = new ProcessTelemetry(manager) @@ -24,4 +28,47 @@ export function registerPerformanceIpc(manager: SessionManager): void { ipcMain.handle('performance:pane-stats', async (_evt, sessionIds?: string[]) => processTelemetry.snapshot(Array.isArray(sessionIds) ? sessionIds : undefined), ) + + // Main-process heap + RSS snapshot for the header badge. + // + // WHY synchronous-style handler (no I/O): the renderer polls this + // at 1 Hz to drive the always-visible badge + sparkline. Anything + // beyond `process.memoryUsage()` + `v8.getHeapStatistics()` would + // be wasted work on every tick — both calls are cheap and return + // immediately without touching disk. + // + // WHY gated on performanceService.getConfig().enabled (which + // mirrors CC_SHELL_PERF): the badge is an opt-in diagnostic, not + // a feature for end users. When disabled we still respond with a + // valid shape (enabled: false plus zeros) so the renderer hook + // can detect the gate on its first probe without throwing. The + // poller stops after seeing enabled=false; subsequent ticks + // never fire so the zero values are never displayed. + ipcMain.handle('performance:system-stats', (): SystemPerformanceStats => { + const enabled = performanceService.getConfig().enabled + if (!enabled) { + return { + enabled: false, + sampledAt: Date.now(), + heapUsed: 0, + heapTotal: 0, + heapLimit: 0, + rss: 0, + external: 0, + arrayBuffers: 0, + } + } + const mem = process.memoryUsage() + const heap = getHeapStatistics() + return { + enabled: true, + sampledAt: Date.now(), + heapUsed: heap.used_heap_size, + heapTotal: heap.total_heap_size, + heapLimit: heap.heap_size_limit, + rss: mem.rss, + external: mem.external, + arrayBuffers: mem.arrayBuffers, + } + }) } diff --git a/src/preload/api/performance.ts b/src/preload/api/performance.ts index 06dce6cff..ae3d5ef57 100644 --- a/src/preload/api/performance.ts +++ b/src/preload/api/performance.ts @@ -5,6 +5,7 @@ import type { PanePerformanceSnapshot, PerformanceRecord, PerformanceSnapshot, + SystemPerformanceStats, } from '@shared/performance/types.js' export const performanceApi = { @@ -22,4 +23,7 @@ export const performanceApi = { getPanePerformanceStats: (sessionIds: string[]): Promise => ipcRenderer.invoke('performance:pane-stats', sessionIds), + + getSystemPerformanceStats: (): Promise => + ipcRenderer.invoke('performance:system-stats'), } diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index f511fea57..6919bfed3 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -24,6 +24,7 @@ import { WorktreesBar } from '@renderer/features/worktrees/ui/WorktreesBar' import { AppearanceMenu } from '@renderer/features/feed/AppearanceMenu' import { PathPickerModal } from '@renderer/features/path-picker/ui/PathPickerModal' import { PerformancePanel } from '@renderer/features/performance/ui/PerformancePanel' +import { SystemPerfHeader } from '@renderer/features/system-perf/ui/SystemPerfHeader' import { AUTO_DEBUG_BUNDLE_INTERVAL_MS, autosaveActiveAgentDebugBundles, @@ -389,6 +390,14 @@ export default function App() { perf + {/* + Always-visible main-process heap + RSS badge with a 60s + sparkline. Self-gates on CC_SHELL_PERF — renders null + until the first IPC probe confirms telemetry is on. + Click expands to a PerformancePanel-sized popover with + the full buffered window and growth rates. + */} + diff --git a/src/renderer/src/features/system-perf/ui/SystemPerfBadge.tsx b/src/renderer/src/features/system-perf/ui/SystemPerfBadge.tsx new file mode 100644 index 000000000..6721a43b8 --- /dev/null +++ b/src/renderer/src/features/system-perf/ui/SystemPerfBadge.tsx @@ -0,0 +1,154 @@ +import type { SystemPerformanceStats } from '@shared/performance/types' + +// Color zones for the heap badge. +// +// Thresholds chosen against observed cc-shell OOMs: +// - 2026-05-06: crashed at ~3.84 GiB / 4.09 GiB cap → 93% (deep red). +// - 2026-05-11: crashed at ~2.69 GiB at an effective ~2.78 GiB cap +// → 97% (deep red), but the heap-watchdog snapshot threshold is +// 75% — meaning a yellow zone starting at 60% gives the user +// roughly the same warning the watchdog uses, while red at 80% +// is "you have minutes, not hours." +// - Hardcoded rather than env-driven on purpose: the zone boundaries +// are not a per-user preference, they're a diagnostic UX choice. +// If a future user needs to tune them we can lift to env vars +// then; YAGNI today. +const YELLOW_ZONE_RATIO = 0.6 +const RED_ZONE_RATIO = 0.8 + +// Sparkline geometry. 60 samples × 1 Hz = last 60 seconds. +// +// We deliberately show ONLY the last minute in the badge sparkline +// (the popover chart shows the full 10-minute ring buffer). The +// badge is meant to answer "is the heap growing right now?" — a +// 60s window catches even rapid growth visibly while staying +// glanceable. +const SPARK_SAMPLES = 60 +const SPARK_WIDTH = 60 +const SPARK_HEIGHT = 12 + +type Zone = 'green' | 'yellow' | 'red' + +function zoneFor(used: number, limit: number): Zone { + if (limit <= 0) return 'green' + const ratio = used / limit + if (ratio >= RED_ZONE_RATIO) return 'red' + if (ratio >= YELLOW_ZONE_RATIO) return 'yellow' + return 'green' +} + +// Tailwind text-* colors used for the badge labels. We resolve via +// a switch (not a record indexed by zone) so dead-code elimination +// in the build can drop unused classes if the bundler is strict. +function textClassFor(zone: Zone): string { + switch (zone) { + case 'red': + return 'text-red-400' + case 'yellow': + return 'text-amber-400' + case 'green': + return 'text-emerald-400' + } +} + +// Raw stroke color for the SVG polyline. SVG doesn't pick up +// Tailwind text utilities through currentColor unless we set +// `color` on the parent — easier to just emit hex values matching +// Tailwind's color-400 stops. +function strokeFor(zone: Zone): string { + switch (zone) { + case 'red': + return '#f87171' + case 'yellow': + return '#fbbf24' + case 'green': + return '#34d399' + } +} + +// "1.23 GB" / "456 MB". We always use SI multiples (1e9 / 1e6) to +// match Activity Monitor's display rather than Chrome's binary +// (GiB) units. Off by ~7% from GiB but in line with what users +// see in the OS-level monitor. +function formatBytes(bytes: number): string { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB` + if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB` + if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(0)} KB` + return `${bytes} B` +} + +type Props = { + current: SystemPerformanceStats + buffer: SystemPerformanceStats[] + onClick: () => void + open: boolean +} + +// Header strip rendering live main-process memory. +// +// WHY a button (not a div): the popover toggles on click. Making +// the whole strip the click target keeps the hit area generous +// without adding a separate caret icon — the OOM-debug user is +// looking AT the numbers and wants to inspect them, so the entire +// strip is the affordance. +// +// WHY `[-webkit-app-region:no-drag]`: the header is the +// draggable region for the Electron window on macOS. Without this, +// clicks on the badge would be eaten by window drag and the +// popover would never open. +export function SystemPerfBadge({ current, buffer, onClick, open }: Props) { + const zone = zoneFor(current.heapUsed, current.heapLimit) + const textClass = textClassFor(zone) + const stroke = strokeFor(zone) + + // Take the last SPARK_SAMPLES from the buffer. Early in the + // session there are fewer than 60 samples; we render whatever + // we have left-aligned, leaving the right side empty until the + // buffer fills. That's intentional: a half-filled sparkline + // makes it obvious the data isn't synthetic. + const window = buffer.slice(-SPARK_SAMPLES) + const points = window + .map((s, i) => { + const x = (i / Math.max(1, SPARK_SAMPLES - 1)) * SPARK_WIDTH + const ratio = s.heapLimit > 0 ? Math.min(1, s.heapUsed / s.heapLimit) : 0 + const y = SPARK_HEIGHT - ratio * SPARK_HEIGHT + return `${x.toFixed(1)},${y.toFixed(1)}` + }) + .join(' ') + + return ( + + ) +} diff --git a/src/renderer/src/features/system-perf/ui/SystemPerfHeader.tsx b/src/renderer/src/features/system-perf/ui/SystemPerfHeader.tsx new file mode 100644 index 000000000..7d4dbb5f8 --- /dev/null +++ b/src/renderer/src/features/system-perf/ui/SystemPerfHeader.tsx @@ -0,0 +1,42 @@ +import { useState } from 'react' + +import { useSystemPerfPoller } from '@renderer/features/system-perf/useSystemPerfPoller' + +import { SystemPerfBadge } from '@renderer/features/system-perf/ui/SystemPerfBadge' +import { SystemPerfPopover } from '@renderer/features/system-perf/ui/SystemPerfPopover' + +// Top-level system-perf surface mounted in App.tsx's header strip. +// +// WHY one wrapper component (not two siblings in App.tsx): +// - Badge + Popover share the SAME poller result. Mounting them +// as siblings would either require lifting state into App.tsx +// (cluttering an already-big file) or duplicating the poller +// (double-polling). +// - The popover's open/close state is purely local UI — there's +// no command-palette entry yet (YAGNI), so it doesn't belong +// on uiShell. +// - Hiding the whole subtree behind one `enabled` check means +// when CC_SHELL_PERF is off, this entire feature compiles down +// to a no-op render with zero IPC traffic after the first probe. +export function SystemPerfHeader() { + const { enabled, current, buffer } = useSystemPerfPoller() + const [open, setOpen] = useState(false) + + // First probe hasn't finished, OR the flag is off, OR the IPC + // failed. In any of those cases we render nothing — no + // placeholder, no spinner. The header should look identical to + // the no-flag state until we have real data to display. + if (enabled !== true || !current) return null + + return ( +
+ setOpen(prev => !prev)} + /> + +
+ ) +} diff --git a/src/renderer/src/features/system-perf/ui/SystemPerfPopover.tsx b/src/renderer/src/features/system-perf/ui/SystemPerfPopover.tsx new file mode 100644 index 000000000..71093d637 --- /dev/null +++ b/src/renderer/src/features/system-perf/ui/SystemPerfPopover.tsx @@ -0,0 +1,250 @@ +import { useMemo } from 'react' + +import type { SystemPerformanceStats } from '@shared/performance/types' + +// Popover dimensions — mirror PerformancePanel's outer chrome so +// the two diagnostic surfaces look like siblings. +// +// PerformancePanel uses w-[430px] / mt-1 / shadow-xl / right-0 +// top-full. We match those exactly. The INNER layout is different +// (one chart + a few summary numbers vs. PerformancePanel's +// per-pane grid) because the source data is different — system +// perf is a small handful of numbers across time, not a list of +// agents. +const POPOVER_WIDTH_CLASS = 'w-[430px] max-w-[calc(100vw-24px)]' + +// Chart geometry inside the popover. Width is the popover content +// width minus padding; height is chosen to give the two series +// (heapUsed + rss) enough vertical separation to read without +// dominating the popover. +const CHART_WIDTH = 398 // 430 - 2*16 padding +const CHART_HEIGHT = 140 +const CHART_PAD_TOP = 8 +const CHART_PAD_BOTTOM = 16 + +// "1.23 GB" / "456 MB" — duplicated from SystemPerfBadge so the +// popover doesn't import from a UI sibling. Keeping the helper +// local also means we can change one's formatting without +// accidentally shifting the other (the badge is space-constrained +// in the header; the popover has room for more precision). +function formatBytes(bytes: number, fractionDigits = 2): string { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(fractionDigits)} GB` + if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB` + if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(0)} KB` + return `${bytes} B` +} + +// Growth rate: bytes/sec over the last `seconds` window. Returns +// positive for growth, negative for shrinkage, null if there's +// not enough buffer to span the window. +// +// WHY a linear "first vs. last" estimate (not a regression): +// curve-fitting buys nothing for an OOM-debug UI. The user wants +// to know "am I leaking?" — and a 60-second delta answers that +// without code that future-me has to puzzle over. +function growthBytesPerSec( + buffer: SystemPerformanceStats[], + field: 'heapUsed' | 'rss', + seconds: number, +): number | null { + if (buffer.length < 2) return null + const last = buffer[buffer.length - 1] + if (!last) return null + const cutoff = last.sampledAt - seconds * 1000 + // Find the oldest sample that's still within the window. + let firstIdx = 0 + for (let i = buffer.length - 2; i >= 0; i--) { + const s = buffer[i] + if (!s) break + if (s.sampledAt < cutoff) { + firstIdx = i + 1 + break + } + } + const first = buffer[firstIdx] + if (!first || first === last) return null + const dt = (last.sampledAt - first.sampledAt) / 1000 + if (dt <= 0) return null + return (last[field] - first[field]) / dt +} + +function formatGrowth(bps: number | null): string { + if (bps === null) return '—' + const sign = bps >= 0 ? '+' : '−' + const magnitude = Math.abs(bps) + if (magnitude >= 1e6) return `${sign}${(magnitude / 1e6).toFixed(1)} MB/s` + if (magnitude >= 1e3) return `${sign}${(magnitude / 1e3).toFixed(0)} KB/s` + return `${sign}${magnitude.toFixed(0)} B/s` +} + +type Props = { + open: boolean + current: SystemPerformanceStats | null + buffer: SystemPerformanceStats[] +} + +// Expanded "what's in the buffer" view. Mirrors PerformancePanel's +// positioning so the two telemetry surfaces feel related — same +// width, same border / bg / shadow / z, same right-anchored +// drop-down. +export function SystemPerfPopover({ open, current, buffer }: Props) { + // Hooks must run unconditionally. We compute the chart geometry + // regardless of `open`, then bail. The work is trivial (one map + // over 600 numbers at most) and skipping the hook would change + // the hook count between renders. + const chart = useMemo(() => { + if (buffer.length < 2 || !current) { + return { heapPoints: '', rssPoints: '', heapCapY: CHART_PAD_TOP } + } + // Y scale: max of (heapLimit, peak rss in window). Anchoring to + // heapLimit keeps the cap line visible at 100% of the heap + // axis, while letting RSS exceed heap (which it usually does + // because RSS includes native + ArrayBuffer + everything else) + // by extending the upper bound to whichever is larger. + const peakRss = buffer.reduce( + (m, s) => (s.rss > m ? s.rss : m), + 0, + ) + const yMax = Math.max(current.heapLimit, peakRss) + if (yMax <= 0) { + return { heapPoints: '', rssPoints: '', heapCapY: CHART_PAD_TOP } + } + const chartInner = CHART_HEIGHT - CHART_PAD_TOP - CHART_PAD_BOTTOM + const project = (value: number) => { + const ratio = Math.min(1, value / yMax) + return CHART_HEIGHT - CHART_PAD_BOTTOM - ratio * chartInner + } + const heapPoints = buffer + .map((s, i) => { + const x = (i / Math.max(1, buffer.length - 1)) * CHART_WIDTH + return `${x.toFixed(1)},${project(s.heapUsed).toFixed(1)}` + }) + .join(' ') + const rssPoints = buffer + .map((s, i) => { + const x = (i / Math.max(1, buffer.length - 1)) * CHART_WIDTH + return `${x.toFixed(1)},${project(s.rss).toFixed(1)}` + }) + .join(' ') + return { heapPoints, rssPoints, heapCapY: project(current.heapLimit) } + }, [buffer, current]) + + if (!open || !current) return null + + // Peak heap in the buffered window: surfaced because a single + // current reading doesn't tell you whether you've been higher + // earlier. If peak is well above current, you've seen growth + + // GC; if peak ≈ current, growth is monotonic and concerning. + const peakHeap = buffer.reduce((m, s) => (s.heapUsed > m ? s.heapUsed : m), 0) + const peakRss = buffer.reduce((m, s) => (s.rss > m ? s.rss : m), 0) + + const heapGrowth = growthBytesPerSec(buffer, 'heapUsed', 60) + const rssGrowth = growthBytesPerSec(buffer, 'rss', 60) + + const windowSeconds = buffer.length > 1 + ? Math.round(((buffer[buffer.length - 1]?.sampledAt ?? 0) - (buffer[0]?.sampledAt ?? 0)) / 1000) + : 0 + + return ( +
+
+
+ metric + current + peak · Δ60s +
+
+
+ heap + + {formatBytes(current.heapUsed)} /{' '} + {formatBytes(current.heapLimit)} + + + {formatBytes(peakHeap)} ·{' '} + 0 ? 'text-amber-400' : ''}> + {formatGrowth(heapGrowth)} + + +
+
+ rss + {formatBytes(current.rss)} + + {formatBytes(peakRss)} ·{' '} + 0 ? 'text-amber-400' : ''}> + {formatGrowth(rssGrowth)} + + +
+
+ native + {formatBytes(current.external)} ext + {formatBytes(current.arrayBuffers)} buffers +
+
+
+
+ last {windowSeconds}s + + + + heap + + + + rss + + + + cap + + +
+ + {/* heap-cap reference line — sits at 100% of the heap + axis (or below if RSS is the y-axis ceiling). Dashed + so it visually separates from the live series. */} + + {chart.rssPoints ? ( + + ) : null} + {chart.heapPoints ? ( + + ) : null} + +
+
+
+ ) +} diff --git a/src/renderer/src/features/system-perf/useSystemPerfPoller.ts b/src/renderer/src/features/system-perf/useSystemPerfPoller.ts new file mode 100644 index 000000000..4e4feadfc --- /dev/null +++ b/src/renderer/src/features/system-perf/useSystemPerfPoller.ts @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react' + +import type { SystemPerformanceStats } from '@shared/performance/types' + +// Ring-buffer length for the in-renderer history. +// +// 600 samples × 1 Hz = 10 minutes of data. Chosen because: +// - The 2026-05-11 OOM grew from ~600 MB → 2.7 GB over roughly 4 +// hours, so a 10-minute window is enough to see meaningful growth +// trajectory in the sparkline without burying short-lived spikes. +// - 600 numbers × ~80 bytes per object is ~50 KB of renderer memory. +// Trivially cheap. Keeping the buffer longer (e.g. 1 hour) starts +// to argue for a downsampling strategy; this size keeps the math +// embarrassingly simple. +// - SVG polylines with 600 points render fine; longer series start +// to be noticeable in DevTools paint profiles. +const BUFFER_CAPACITY = 600 + +// Poll cadence for the header badge. +// +// 1 Hz is the lowest interesting rate for a near-realtime OOM +// indicator. The IPC roundtrip is sub-millisecond +// (process.memoryUsage + v8.getHeapStatistics) and the renderer +// re-renders only the badge subtree, so we don't pay enough to +// justify a slower rate. Faster (e.g. 250 ms) would only matter for +// catching second-scale spikes which v8 fatal-error logs already +// preserve via the GC traces. +const POLL_INTERVAL_MS = 1000 + +export type SystemPerfPollerState = { + // null until the first probe completes; thereafter true if + // CC_SHELL_PERF gating allows polling, false if it does not. + // The badge component renders nothing in either null or false + // state — see SystemPerfHeader. + enabled: boolean | null + current: SystemPerformanceStats | null + // Append-only series, capped at BUFFER_CAPACITY. Oldest samples + // are dropped from the front when the cap is reached. + buffer: SystemPerformanceStats[] +} + +const INITIAL_STATE: SystemPerfPollerState = { + enabled: null, + current: null, + buffer: [], +} + +// One-shot probe + 1 Hz polling loop driving the header badge. +// +// WHY a hook (not a store): there's exactly one consumer pair +// (SystemPerfBadge + SystemPerfPopover, mounted under a single +// SystemPerfHeader). Keeping state local avoids growing the +// uiShell slice with telemetry-only fields and keeps the poller's +// lifecycle tied to the header's mount, which mirrors how +// PerformancePanel manages its own polling (see +// PerformancePanel.tsx — useEffect-owned setInterval). +// +// WHY probe first, then start the interval: when CC_SHELL_PERF is +// off, the badge does not render. We want exactly zero recurring +// work in that case — no setInterval, no allocation churn. The +// first tick decides whether to schedule subsequent ticks. +export function useSystemPerfPoller(): SystemPerfPollerState { + const [state, setState] = useState(INITIAL_STATE) + + useEffect(() => { + let cancelled = false + let timer: number | null = null + + const tick = async () => { + try { + const stats = await window.api.getSystemPerformanceStats() + if (cancelled) return + if (!stats.enabled) { + // Disabled: record the gate state once and stop. No + // interval is started; if the user toggles CC_SHELL_PERF + // they need to restart the app, which is consistent with + // every other place that reads the flag. + setState(prev => + prev.enabled === false ? prev : { enabled: false, current: null, buffer: [] }, + ) + return + } + setState(prev => { + // Append-and-trim. Using slice + concat (not push on the + // existing array) keeps the array reference fresh so the + // popover's chart re-renders. The cost is one allocation + // per tick — fine at 1 Hz with capacity 600. + const nextBuffer = + prev.buffer.length >= BUFFER_CAPACITY + ? [...prev.buffer.slice(prev.buffer.length - BUFFER_CAPACITY + 1), stats] + : [...prev.buffer, stats] + return { enabled: true, current: stats, buffer: nextBuffer } + }) + // Schedule subsequent ticks only after the first successful + // probe. If the probe ever throws, we never start the + // interval and the user sees no badge — a deliberate + // fail-silent so the rest of the app stays usable. + if (timer === null) { + timer = window.setInterval(() => void tick(), POLL_INTERVAL_MS) + } + } catch { + if (!cancelled) { + setState(prev => + prev.enabled === false ? prev : { enabled: false, current: null, buffer: [] }, + ) + } + } + } + + void tick() + + return () => { + cancelled = true + if (timer !== null) window.clearInterval(timer) + } + }, []) + + return state +} diff --git a/src/shared/performance/types.ts b/src/shared/performance/types.ts index 034a0d3f6..47db76ad1 100644 --- a/src/shared/performance/types.ts +++ b/src/shared/performance/types.ts @@ -70,3 +70,55 @@ export type PanePerformanceSnapshot = { sampledAt: number panes: PanePerformanceStats[] } + +// Main-process snapshot powering the always-visible header badge. +// +// WHY a NEW type rather than extending PerformanceSnapshot or +// PanePerformanceSnapshot: +// +// - PerformanceSnapshot returns ON-DISK log files (large, infrequent +// IO) — wrong shape for the 1 Hz header poll. +// - PanePerformanceSnapshot describes PER-AGENT processes (cpu / +// memory / status for sessionIds) — that's agent perf, not main- +// process perf. The header strip is intentionally NOT per-agent; +// the OOM crashes we're chasing happen in the Electron main +// process, not in agent subprocesses. +// +// The fields below are deliberately the v8/process numbers a v8 +// fatal-error log reports, in the same units. `heapUsed` is the +// number the OOM crash header prints; `heapLimit` is what v8 will +// crash AT (typically 4 GiB on macOS, but observed lower in +// production — see heapWatchdog.ts for the empirical note). `rss` +// captures native-side allocations (Buffers, native modules) that +// v8 heap numbers DO NOT show — the 2026-05-11 OOM had Rust frames +// and a Buffer::New in the crash stack, exactly the kind of growth +// v8 heap alone misses. +export type SystemPerformanceStats = { + /** Mirrors PerformanceConfig.enabled — controls whether telemetry + * is on. The header badge renders only when enabled === true. */ + enabled: boolean + /** Wall-clock ms when the sample was taken. Used to plot the + * buffered series with real timestamps. */ + sampledAt: number + /** v8 used_heap_size in bytes — the number v8's fatal-error log + * reports as "heap used". Source of truth for the color zone. */ + heapUsed: number + /** v8 total_heap_size — committed heap. Often larger than used due + * to internal fragmentation; surfaced for the expanded popover. */ + heapTotal: number + /** v8 heap_size_limit — the cap v8 will OOM at. Used as the + * denominator for the green/yellow/red zones. Measured at + * runtime because Electron varies this across dev/prod launches. */ + heapLimit: number + /** process.memoryUsage().rss — resident set size. Catches native + * growth (Buffers, native modules) that the v8 heap doesn't + * reflect. No fixed ceiling, hence no zone color. */ + rss: number + /** process.memoryUsage().external — memory used by C++ objects + * bound to JS objects. Component of RSS but split out so the + * popover can surface "is this a native leak or a JS leak?". */ + external: number + /** process.memoryUsage().arrayBuffers — ArrayBuffer-backed + * allocations. The Buffer::New in the OOM crash points here. */ + arrayBuffers: number +}