Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 55 additions & 5 deletions src/HeapSnapshotManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,25 @@ import fsSync from 'node:fs';
import path from 'node:path';

import {DevTools} from './third_party/index.js';
import {
createIdGenerator,
stableIdSymbol,
type WithSymbolId,
} from './utils/id.js';

export type AggregatedInfoWithUid =
WithSymbolId<DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo>;

export class HeapSnapshotManager {
#snapshots = new Map<
string,
{
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
// TODO: use a multimap
uidToClassKey: Map<number, string>;
classKeyToUid: Map<string, number>;
idGenerator: () => number;
}
>();

Expand All @@ -28,20 +40,35 @@ export class HeapSnapshotManager {
}

const {snapshot, worker} = await this.#loadSnapshot(absolutePath);
this.#snapshots.set(absolutePath, {snapshot, worker});
this.#snapshots.set(absolutePath, {
snapshot,
worker,
uidToClassKey: new Map<number, string>(),
classKeyToUid: new Map<string, number>(),
Comment thread
Lightning00Blade marked this conversation as resolved.
idGenerator: createIdGenerator(),
});

return snapshot;
}

async getAggregates(
filePath: string,
): Promise<
Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo>
> {
): Promise<Record<string, AggregatedInfoWithUid>> {
const snapshot = await this.getSnapshot(filePath);
const filter =
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
return await snapshot.aggregatesWithFilter(filter);
const aggregates: Record<string, AggregatedInfoWithUid> =
await snapshot.aggregatesWithFilter(filter);

for (const key of Object.keys(aggregates)) {
const uid = await this.getOrCreateUidForClassKey(filePath, key);
const aggregate = aggregates[key];
if (aggregate) {
aggregate[stableIdSymbol] = uid;
}
}

return aggregates;
}

async getStats(
Expand All @@ -58,6 +85,29 @@ export class HeapSnapshotManager {
return snapshot.staticData;
}

async getOrCreateUidForClassKey(
filePath: string,
classKey: string,
): Promise<number> {
const cached = this.#getCachedSnapshot(filePath);
let uid = cached.classKeyToUid.get(classKey);
if (!uid) {
uid = cached.idGenerator();
cached.classKeyToUid.set(classKey, uid);
cached.uidToClassKey.set(uid, classKey);
}
return uid;
}

#getCachedSnapshot(filePath: string) {
const absolutePath = path.resolve(filePath);
const cached = this.#snapshots.get(absolutePath);
if (!cached) {
throw new Error(`Snapshot not loaded for ${filePath}`);
}
return cached;
}

async #loadSnapshot(absolutePath: string): Promise<{
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
Expand Down
5 changes: 2 additions & 3 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import path from 'node:path';
import type {TargetUniverse} from './DevtoolsUtils.js';
import {UniverseManager} from './DevtoolsUtils.js';
import {HeapSnapshotManager} from './HeapSnapshotManager.js';
import type {AggregatedInfoWithUid} from './HeapSnapshotManager.js';
import {McpPage} from './McpPage.js';
import {
NetworkCollector,
Expand Down Expand Up @@ -918,9 +919,7 @@ export class McpContext implements Context {

async getHeapSnapshotAggregates(
filePath: string,
): Promise<
Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo>
> {
): Promise<Record<string, AggregatedInfoWithUid>> {
return await this.#heapSnapshotManager.getAggregates(filePath);
}

Expand Down
20 changes: 5 additions & 15 deletions src/PageCollector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ import {
type Page,
type PageEvents as PuppeteerPageEvents,
} from './third_party/index.js';
import {
createIdGenerator,
stableIdSymbol,
type WithSymbolId,
} from './utils/id.js';

export class UncaughtError {
readonly details: Protocol.Runtime.ExceptionDetails;
Expand All @@ -42,21 +47,6 @@ export type ListenerMap<EventMap extends PageEvents = PageEvents> = {
[K in keyof EventMap]?: (event: EventMap[K]) => void;
};

function createIdGenerator() {
let i = 1;
return () => {
if (i === Number.MAX_SAFE_INTEGER) {
i = 0;
}
return i++;
};
}

export const stableIdSymbol = Symbol('stableIdSymbol');
type WithSymbolId<T> = T & {
[stableIdSymbol]?: number;
};

export class PageCollector<T> {
#browser: Browser;
#listenersInitializer: (
Expand Down
25 changes: 12 additions & 13 deletions src/formatters/HeapSnapshotFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,39 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type {AggregatedInfoWithUid} from '../HeapSnapshotManager.js';
import type {DevTools} from '../third_party/index.js';
import {stableIdSymbol} from '../utils/id.js';

export interface FormattedSnapshotEntry {
className: string;
classUid?: number;
count: number;
selfSize: number;
retainedSize: number;
}

export class HeapSnapshotFormatter {
#aggregates: Record<
string,
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
>;
#aggregates: Record<string, AggregatedInfoWithUid>;

constructor(
aggregates: Record<
string,
DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo
>,
) {
constructor(aggregates: Record<string, AggregatedInfoWithUid>) {
this.#aggregates = aggregates;
}

#getSortedAggregates(): DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo[] {
#getSortedAggregates(): AggregatedInfoWithUid[] {
return Object.values(this.#aggregates).sort((a, b) => b.self - a.self);
}

toString(): string {
const sorted = this.#getSortedAggregates();
const lines: string[] = [];
lines.push('className,count,selfSize,maxRetainedSize');
lines.push('uid,className,count,selfSize,maxRetainedSize');

for (const info of sorted) {
lines.push(`"${info.name}",${info.count},${info.self},${info.maxRet}`);
const uid = info[stableIdSymbol] ?? '';
lines.push(
`${uid},"${info.name}",${info.count},${info.self},${info.maxRet}`,
);
}

return lines.join('\n');
Expand All @@ -47,6 +45,7 @@ export class HeapSnapshotFormatter {
toJSON(): FormattedSnapshotEntry[] {
const sorted = this.#getSortedAggregates();
return sorted.map(info => ({
uid: info[stableIdSymbol],
className: info.name,
count: info.count,
selfSize: info.self,
Expand Down
5 changes: 2 additions & 3 deletions src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import type {ParsedArguments} from '../bin/chrome-devtools-mcp-cli-options.js';
import type {AggregatedInfoWithUid} from '../HeapSnapshotManager.js';
import type {McpPage} from '../McpPage.js';
import {zod} from '../third_party/index.js';
import type {
Expand Down Expand Up @@ -227,9 +228,7 @@ export type Context = Readonly<{
): string | undefined;
getHeapSnapshotAggregates(
filePath: string,
): Promise<
Record<string, DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo>
>;
): Promise<Record<string, AggregatedInfoWithUid>>;
getHeapSnapshotStats(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics>;
Expand Down
36 changes: 34 additions & 2 deletions src/tools/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const takeMemorySnapshot = definePageTool({
name: 'take_memory_snapshot',
description: `Capture a heap snapshot of the currently selected page. Use to analyze the memory distribution of JavaScript objects and debug memory leaks.`,
annotations: {
category: ToolCategory.PERFORMANCE,
category: ToolCategory.MEMORY,
readOnlyHint: false,
},
schema: {
Expand All @@ -38,7 +38,7 @@ export const takeMemorySnapshot = definePageTool({
export const exploreMemorySnapshot = defineTool({
name: 'load_memory_snapshot',
description:
'Loads a memory heapsnapshot and returns snapshot summary stats. ',
'Loads a memory heapsnapshot and returns snapshot summary stats.',
annotations: {
category: ToolCategory.MEMORY,
readOnlyHint: true,
Expand All @@ -56,3 +56,35 @@ export const exploreMemorySnapshot = defineTool({
response.setHeapSnapshotStats(stats, staticData);
},
});

export const getMemorySnapshotDetails = defineTool({
name: 'get_memory_snapshot_details',
description:
'Loads a memory heapsnapshot and returns all available information including statistics, static data, and aggregated node information. Supports pagination for aggregates.',
annotations: {
category: ToolCategory.MEMORY,
readOnlyHint: true,
conditions: ['experimentalMemory'],
},
schema: {
filePath: zod.string().describe('A path to a .heapsnapshot file to read.'),
pageIdx: zod
.number()
.optional()
.describe('The page index for pagination of aggregates.'),
pageSize: zod
.number()
.optional()
.describe('The page size for pagination of aggregates.'),
},
handler: async (request, response, context) => {
const aggregates = await context.getHeapSnapshotAggregates(
request.params.filePath,
);

response.setHeapSnapshotAggregates(aggregates, {
pageIdx: request.params.pageIdx,
pageSize: request.params.pageSize,
});
},
});
21 changes: 21 additions & 0 deletions src/utils/id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

export function createIdGenerator() {
let i = 1;
return () => {
if (i === Number.MAX_SAFE_INTEGER) {
i = 0;
}
return i++;
};
}

export const stableIdSymbol = Symbol('stableIdSymbol');

export type WithSymbolId<T> = T & {
[stableIdSymbol]?: number;
};
6 changes: 3 additions & 3 deletions tests/formatters/HeapSnapshotFormatter.test.js.snapshot
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
exports[`HeapSnapshotFormatter > toString > formats data as CSV and sorts by self size 1`] = `
className,count,selfSize,maxRetainedSize
"ObjectA",10,100,1000
"ObjectB",5,50,500
uid,className,count,selfSize,maxRetainedSize
1,"ObjectA",10,100,1000
2,"ObjectB",5,50,500
`;
5 changes: 5 additions & 0 deletions tests/formatters/HeapSnapshotFormatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {describe, it} from 'node:test';

import {HeapSnapshotFormatter} from '../../src/formatters/HeapSnapshotFormatter.js';
import type {DevTools} from '../../src/third_party/index.js';
import {stableIdSymbol} from '../../src/utils/id.js';

describe('HeapSnapshotFormatter', () => {
const mockAggregates: Record<
Expand All @@ -22,6 +23,7 @@ describe('HeapSnapshotFormatter', () => {
maxRet: 1000,
distance: 1,
idxs: [],
[stableIdSymbol]: 1,
} as unknown as DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo,
ObjectB: {
name: 'ObjectB',
Expand All @@ -30,6 +32,7 @@ describe('HeapSnapshotFormatter', () => {
maxRet: 500,
distance: 2,
idxs: [],
[stableIdSymbol]: 2,
} as unknown as DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo,
};

Expand All @@ -47,12 +50,14 @@ describe('HeapSnapshotFormatter', () => {
const result = formatter.toJSON();
assert.deepStrictEqual(result, [
{
uid: 1,
className: 'ObjectA',
count: 10,
selfSize: 100,
retainedSize: 1000,
},
{
uid: 2,
className: 'ObjectB',
count: 5,
selfSize: 50,
Expand Down
Loading
Loading