From baf493f3c98fbacc3a896d8ee38e17e46da5cf46 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 27 May 2026 07:27:56 -0700 Subject: [PATCH] add BrowserManager.getMemorySnapshot() + shared types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostic foundation for $B memory and the /memory endpoint that land in the next two commits. Collects: - Bun process memory via process.memoryUsage (cross-platform, accurate). - Per-tab JS heap via CDP Performance.getMetrics, lazy per tracked page, swallows target-died errors so a dying tab doesn't poison the snapshot for the rest. - Chromium process tree via SystemInfo.getProcessInfo (PID + type + CPU time). RSS is NOT exposed via CDP — the eng review (D2 USE_CDP) picked CDP over shelling to `ps`, so notes[] tells the caller why the RSS column is absent and points at the follow-up TODO. cdp-inspector exports getModificationHistoryStats so the snapshot can surface buffer occupancy + cap + evicted count without reaching into module-private state. memory-snapshot.ts holds the shared types so server.ts and read-commands can import without circular dep on browser-manager. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/src/browser-manager.ts | 112 ++++++++++++++++++++++++++++++++++ browse/src/cdp-inspector.ts | 17 ++++++ browse/src/memory-snapshot.ts | 73 ++++++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 browse/src/memory-snapshot.ts diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 7734f0a62..a70393f3f 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -21,6 +21,8 @@ import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type D import { validateNavigationUrl } from './url-validation'; import { TabSession, type RefEntry } from './tab-session'; import { resolveChromiumProfile, cleanSingletonLocks } from './config'; +import { withCdpSession } from './cdp-bridge'; +import type { MemorySnapshot, MemoryStructureStats, MemoryTabSnapshot, MemoryProcess } from './memory-snapshot'; /** * Detect whether GSTACK_CHROMIUM_PATH points at a custom Chromium build that @@ -1004,6 +1006,116 @@ export class BrowserManager { } } + /** + * Diagnostic for `$B memory` and the /memory endpoint. + * + * Collects: + * - Bun process memory (cross-platform, accurate, no shelling). + * - Per-tab JS heap via CDP Performance.getMetrics — the most portable + * per-tab signal CDP exposes. Misses native/GPU/Skia/cache memory + * (Codex flag on the eng-review; see follow-up TODO "native/GPU + * memory breakdown"). + * - Chromium process tree via SystemInfo.getProcessInfo — PID + type + * + CPU time. Per-process RSS is NOT exposed via CDP and the eng + * review (D2 USE_CDP) explicitly chose CDP over shelling to `ps`, + * so RSS columns are absent and `notes[]` says why. + * + * `structures` is passed in by the caller (read-commands / server) so + * browser-manager doesn't take a hard dep on every buffer-owning module. + */ + async getMemorySnapshot(structures: MemoryStructureStats): Promise { + const bunMem = process.memoryUsage(); + const notes: string[] = []; + + // Per-tab JS heap. Lazy: only the pages we already track. A target + // that died mid-snapshot is omitted, never throws. + const tabs: MemoryTabSnapshot[] = []; + for (const [id, page] of this.pages) { + try { + const url = (() => { try { return page.url(); } catch { return ''; } })(); + const title = await page.title().catch(() => ''); + const metrics = await withCdpSession(page, async (session) => { + await session.send('Performance.enable').catch(() => undefined); + const result = await session.send('Performance.getMetrics'); + return ((result as { metrics?: Array<{ name: string; value: number }> }).metrics) ?? []; + }); + const mm: Record = {}; + for (const m of metrics) mm[m.name] = m.value; + tabs.push({ + id, + url, + title, + jsHeapUsed: mm.JSHeapUsedSize ?? 0, + jsHeapTotal: mm.JSHeapTotalSize ?? 0, + documents: mm.Documents ?? 0, + nodes: mm.Nodes ?? 0, + listeners: mm.JSEventListeners ?? 0, + }); + } catch { + // Target died or CDP unavailable mid-snapshot — skip this tab. + } + } + + // Chromium process tree. Browser handle may be on the `browser` field + // (launched mode) or accessible via `context.browser()` (persistent + // context / headed mode); try both. + let processes: MemoryProcess[] | null = null; + const browser: Browser | null = this.browser ?? (this.context ? this.context.browser() : null); + if (browser) { + try { + // `newBrowserCDPSession` is browser-wide. Not exposed on every + // Playwright TypeScript surface, but present at runtime on the + // Browser instance — use a typed cast to avoid the @ts-expect-error. + type BrowserWithCDP = Browser & { + newBrowserCDPSession?: () => Promise<{ + send: (method: string, params?: unknown) => Promise; + detach: () => Promise; + }>; + }; + const maybeFactory = (browser as BrowserWithCDP).newBrowserCDPSession; + if (typeof maybeFactory === 'function') { + const browserSession = await maybeFactory.call(browser); + try { + const info = (await browserSession.send('SystemInfo.getProcessInfo')) as { + processInfo?: Array<{ id: number; type: string; cpuTime: number }>; + }; + processes = (info.processInfo ?? []).map((p) => ({ + id: p.id, + type: p.type, + cpuTime: p.cpuTime, + })); + notes.push( + 'Per-Chromium-process RSS not collected — SystemInfo.getProcessInfo exposes PID+type+CPU only. ' + + 'See follow-up TODO "native/GPU memory breakdown" for the deferred fix.', + ); + } finally { + await browserSession.detach().catch(() => undefined); + } + } else { + notes.push('Playwright build does not expose newBrowserCDPSession; per-process info skipped.'); + } + } catch (err: any) { + notes.push(`CDP browser session unavailable: ${err?.message ?? String(err)}`); + } + } else { + notes.push('Browser handle unavailable (server connection mode); per-process info skipped.'); + } + + return { + bunServer: { + rss: bunMem.rss, + heapUsed: bunMem.heapUsed, + heapTotal: bunMem.heapTotal, + external: bunMem.external, + }, + tabs, + processes, + structures, + capturedAt: Date.now(), + notes, + }; + } + // ─── Ref Map (delegates to active session) ────────────────── setRefMap(refs: Map) { this.getActiveSession().setRefMap(refs); diff --git a/browse/src/cdp-inspector.ts b/browse/src/cdp-inspector.ts index fd4a938d1..52a488e57 100644 --- a/browse/src/cdp-inspector.ts +++ b/browse/src/cdp-inspector.ts @@ -670,6 +670,23 @@ export function getModificationHistory(): StyleModification[] { return [...modificationHistory]; } +/** + * Diagnostic accessor for the $B memory snapshot. Returns current buffer + * occupancy, the cap, and how many entries have been evicted since the + * last reset. + */ +export function getModificationHistoryStats(): { + current: number; + cap: number; + evicted: number; +} { + return { + current: modificationHistory.length, + cap: MOD_HISTORY_CAP, + evicted: Math.max(0, modHistoryTotalPushed - MOD_HISTORY_CAP), + }; +} + /** * Reset all modifications, restoring original values. */ diff --git a/browse/src/memory-snapshot.ts b/browse/src/memory-snapshot.ts new file mode 100644 index 000000000..02a54d49d --- /dev/null +++ b/browse/src/memory-snapshot.ts @@ -0,0 +1,73 @@ +// Shared types for the $B memory diagnostic command and the /memory +// endpoint. Lives in its own module so server.ts, read-commands.ts, and +// the extension footer poll can import without taking a circular dep on +// browser-manager.ts. +// +// Background: the gbrowser-OOM investigation (160 GB Activity Monitor +// reading on a friend's machine) needed a diagnostic that could land +// before the next incident — measurement comes first, fixes come after. +// $B memory is that diagnostic. + +/** Counts/bytes for the bounded in-memory structures on the Bun side. */ +export interface MemoryStructureStats { + modificationHistory: { current: number; cap: number; evicted: number }; + activitySubscribers: number; + inspectorSubscribers: number; + consoleBufferLen: number; + networkBufferLen: number; + dialogBufferLen: number; + captureBufferBytes: number; +} + +/** Per-tab JS heap snapshot (CDP Performance.getMetrics). */ +export interface MemoryTabSnapshot { + id: number; + url: string; + title: string; + jsHeapUsed: number; + jsHeapTotal: number; + documents: number; + nodes: number; + listeners: number; +} + +/** Chromium process metadata via CDP SystemInfo.getProcessInfo. */ +export interface MemoryProcess { + /** Chromium-internal process id (not OS PID). */ + id: number; + /** 'browser' | 'renderer' | 'gpu' | 'utility' | 'extension' | ... */ + type: string; + /** CPU time accumulated since process start (seconds). */ + cpuTime: number; +} + +export interface MemorySnapshot { + bunServer: { + rss: number; + heapUsed: number; + heapTotal: number; + external: number; + }; + tabs: MemoryTabSnapshot[]; + /** + * Chromium process tree. `null` when no browser handle is available + * (server in connection mode, or browser not yet launched). + * + * Per-process RSS is NOT included: SystemInfo.getProcessInfo returns + * id+type+cpuTime but Chromium does not expose RSS via CDP. The + * `notes[]` field tells the caller why — see the follow-up TODO + * "native/GPU memory breakdown" for the deferred fix. + */ + processes: MemoryProcess[] | null; + structures: MemoryStructureStats; + capturedAt: number; + notes: string[]; +} + +/** Format bytes as a short human string ("1.4 GB", "312 MB", "84 KB"). */ +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; + return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`; +}