add BrowserManager.getMemorySnapshot() + shared types

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) <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-05-27 07:27:56 -07:00
parent 4f42c7bf08
commit baf493f3c9
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
3 changed files with 202 additions and 0 deletions

View File

@ -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<MemorySnapshot> {
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<string, number> = {};
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<unknown>;
detach: () => Promise<void>;
}>;
};
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<string, RefEntry>) {
this.getActiveSession().setRefMap(refs);

View File

@ -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.
*/

View File

@ -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`;
}