// `rnx get memory` / `rnx debug memory` — per-worker live object // counts (nodes registered, pictures, raster images, paragraphs, yoga wasm // nodes), image-loader cache, and JS heaps. one-shot by default; --watch // samples repeatedly and reports per-counter slope, which is the leak-RCA // workflow: run the repro, watch which counter's slope tracks RSS. import { inspectMemory, type MemoryReport, type WorkerMemorySample } from './core' import { printJson, wantsJson } from './shared' import type { WsBridge } from '../../ws-bridge' function formatBytes(n: number): string { if (n < 1024) return `${n}B` if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB` return `${(n / 1024 / 1024).toFixed(1)}MB` } function pct(num: number, denom: number): string { if (denom <= 0) return '?' return `${((num / denom) * 100).toFixed(0)}%` } // flatten one report into named numeric counters so watch mode can diff and // slope every field uniformly. function flattenCounters(report: MemoryReport): Record { const out: Record = {} const worker = (prefix: string, w: WorkerMemorySample | null) => { if (!w) return if (w.nodesRegistered != null) out[`${prefix}.nodes`] = w.nodesRegistered if (w.nodesDetached != null) out[`${prefix}.detached`] = w.nodesDetached if (w.objects) { out[`${prefix}.paragraphs`] = w.objects.paragraphs out[`${prefix}.yogaNodes`] = w.objects.yogaNodes out[`${prefix}.pictures`] = w.objects.pictures out[`${prefix}.rasterImages`] = w.objects.rasterImages } if (w.imageLoader) { out[`${prefix}.imageCacheEntries`] = w.imageLoader.cacheEntries out[`${prefix}.imagePixelBytes`] = w.imageLoader.cachePixelBytes } if (w.workerHeap) out[`${prefix}.heapBytes`] = w.workerHeap.usedJSHeapSize if (w.wasmHeapBytes != null) out[`${prefix}.wasmHeapBytes`] = w.wasmHeapBytes } worker('tenant', report.tenant) worker('shell', report.shell) worker('compositor', report.compositor) if (report.hostHeap) out['host.heapBytes'] = report.hostHeap.usedJSHeapSize return out } function formatCounter(key: string, value: number): string { return key.endsWith('Bytes') ? formatBytes(value) : String(value) } function printWorkerBlock(name: string, w: WorkerMemorySample | null): void { if (!w) { console.log(` ${name}: not available`) return } const objects = w.objects console.log(` ${name}`) if (w.nodesRegistered != null) { console.log(` nodes registered: ${w.nodesRegistered}`) } if (w.nodesDetached != null) { const types = Object.entries(w.detachedTypes ?? {}) .map(([type, count]) => `${type}:${count}`) .join(' ') console.log( ` nodes detached: ${w.nodesDetached}${types ? ` (${types})` : ''}`, ) } if (objects) { console.log(` paragraphs: ${objects.paragraphs}`) console.log(` yoga nodes: ${objects.yogaNodes}`) console.log(` pictures: ${objects.pictures}`) console.log(` raster images: ${objects.rasterImages}`) } if (w.activeNativeAnimations != null) { console.log(` native anims: ${w.activeNativeAnimations}`) } if (w.imageLoader) { const im = w.imageLoader console.log( ` image cache: ${im.cacheEntries}/${im.cacheMaxEntries} entries, ${formatBytes(im.cachePixelBytes)}/${formatBytes(im.cachePixelBudget)} (${pct(im.cachePixelBytes, im.cachePixelBudget)})`, ) } if (w.workerHeap) { console.log( ` js heap: ${formatBytes(w.workerHeap.usedJSHeapSize)} used / ${formatBytes(w.workerHeap.totalJSHeapSize)} total`, ) } if (w.wasmHeapBytes != null) { console.log(` canvaskit wasm: ${formatBytes(w.wasmHeapBytes)}`) } } function printReport(report: MemoryReport): void { console.log(' memory:') printWorkerBlock('tenant worker', report.tenant) printWorkerBlock('shell worker', report.shell) printWorkerBlock('compositor worker', report.compositor) if (report.hostHeap) { console.log(' host') console.log( ` js heap: ${formatBytes(report.hostHeap.usedJSHeapSize)} used / ${formatBytes(report.hostHeap.totalJSHeapSize)} total`, ) } else { console.log(' host js heap: not available (chrome only)') } } function readNumberFlag(args: string[], flag: string, fallback: number): number { const idx = args.indexOf(flag) if (idx === -1) return fallback const value = Number(args[idx + 1]) return Number.isFinite(value) && value > 0 ? value : fallback } export async function runMemorySubcommand( bridge: WsBridge, opts: { args: string[] } = { args: [] }, ): Promise { const json = wantsJson(opts.args) const watchIdx = opts.args.indexOf('--watch') if (watchIdx === -1) { const report = await inspectMemory(bridge) if (json) { printJson(report) return } printReport(report) return } // --watch [seconds] — sample every --interval seconds (default 5) for the // given duration (default 60s), then report per-counter slope. const durationValue = Number(opts.args[watchIdx + 1]) const durationSec = Number.isFinite(durationValue) && durationValue > 0 ? durationValue : 60 const intervalSec = readNumberFlag(opts.args, '--interval', 5) const samples: Array<{ tSec: number; counters: Record }> = [] const start = Date.now() let prev: Record | null = null if (!json) { console.log(` sampling every ${intervalSec}s for ${durationSec}s — deltas per tick:`) } while (true) { const report = await inspectMemory(bridge) const tSec = (Date.now() - start) / 1000 const counters = flattenCounters(report) samples.push({ tSec, counters }) if (!json) { const parts: string[] = [] for (const [key, value] of Object.entries(counters)) { const delta = prev && key in prev ? value - prev[key] : null if (delta === null || delta === 0) continue const sign = delta > 0 ? '+' : '-' const deltaStr = key.endsWith('Bytes') ? `${sign}${formatBytes(Math.abs(delta))}` : `${sign}${Math.abs(delta)}` parts.push(`${key} ${formatCounter(key, value)} (${deltaStr})`) } console.log( ` t+${tSec.toFixed(0)}s ${ parts.length ? parts.join(' ') : prev ? 'no change' : Object.entries(counters) .map(([k, v]) => `${k} ${formatCounter(k, v)}`) .join(' ') }`, ) } prev = counters if (tSec >= durationSec) break await new Promise((resolve) => setTimeout(resolve, intervalSec * 1000)) } // per-counter slope over the whole window const first = samples[0] const last = samples[samples.length - 1] const elapsed = Math.max(last.tSec - first.tSec, 0.001) const perSecond: Record = {} for (const key of Object.keys(last.counters)) { if (!(key in first.counters)) continue perSecond[key] = (last.counters[key] - first.counters[key]) / elapsed } if (json) { printJson({ samples, perSecond, elapsedSec: elapsed }) return } console.log(`\n slope over ${elapsed.toFixed(0)}s:`) const moving = Object.entries(perSecond) .filter(([, v]) => v !== 0) .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])) if (moving.length === 0) { console.log(' all counters flat') return } for (const [key, rate] of moving) { const label = key.endsWith('Bytes') ? `${formatBytes(Math.abs(rate))}/s${rate < 0 ? ' (shrinking)' : ''}` : `${rate > 0 ? '+' : ''}${rate.toFixed(2)}/s` console.log(` ${key.padEnd(28)}${label}`) } }