From 726fd359f883a57fdf7ef7864ba02626cc1236b9 Mon Sep 17 00:00:00 2001 From: Nikolay Vitkov Date: Sun, 19 Apr 2026 22:12:57 +0200 Subject: [PATCH 1/5] chore: use id generation for memory aggragates --- src/HeapSnapshotManager.ts | 36 +++++++++++++++++++++---- src/McpContext.ts | 5 ++-- src/PageCollector.ts | 20 ++++---------- src/formatters/HeapSnapshotFormatter.ts | 25 +++++++++-------- src/tools/ToolDefinition.ts | 5 ++-- src/tools/memory.ts | 4 +-- src/utils/id.ts | 21 +++++++++++++++ tests/utils.ts | 2 +- 8 files changed, 76 insertions(+), 42 deletions(-) create mode 100644 src/utils/id.ts diff --git a/src/HeapSnapshotManager.ts b/src/HeapSnapshotManager.ts index 5d105db99..6aa74fce9 100644 --- a/src/HeapSnapshotManager.ts +++ b/src/HeapSnapshotManager.ts @@ -8,6 +8,14 @@ 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; export class HeapSnapshotManager { #snapshots = new Map< @@ -15,6 +23,9 @@ export class HeapSnapshotManager { { snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy; worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy; + uidToClassKey: Map; + classKeyToUid: Map; + idGenerator: () => number; } >(); @@ -28,20 +39,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(), + classKeyToUid: new Map(), + idGenerator: createIdGenerator(), + }); return snapshot; } async getAggregates( filePath: string, - ): Promise< - Record - > { + ): Promise> { const snapshot = await this.getSnapshot(filePath); const filter = new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter(); - return await snapshot.aggregatesWithFilter(filter); + const aggregates: Record = + 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( diff --git a/src/McpContext.ts b/src/McpContext.ts index 198a91a59..15a83d73f 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -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, @@ -918,9 +919,7 @@ export class McpContext implements Context { async getHeapSnapshotAggregates( filePath: string, - ): Promise< - Record - > { + ): Promise> { return await this.#heapSnapshotManager.getAggregates(filePath); } diff --git a/src/PageCollector.ts b/src/PageCollector.ts index 92f461201..e901fb789 100644 --- a/src/PageCollector.ts +++ b/src/PageCollector.ts @@ -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; @@ -42,21 +47,6 @@ export type ListenerMap = { [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 & { - [stableIdSymbol]?: number; -}; - export class PageCollector { #browser: Browser; #listenersInitializer: ( diff --git a/src/formatters/HeapSnapshotFormatter.ts b/src/formatters/HeapSnapshotFormatter.ts index 37ba96f26..f5bcda0c9 100644 --- a/src/formatters/HeapSnapshotFormatter.ts +++ b/src/formatters/HeapSnapshotFormatter.ts @@ -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; - constructor( - aggregates: Record< - string, - DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo - >, - ) { + constructor(aggregates: Record) { 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'); @@ -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, diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 2aa640f7f..d080be758 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -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 { @@ -227,9 +228,7 @@ export type Context = Readonly<{ ): string | undefined; getHeapSnapshotAggregates( filePath: string, - ): Promise< - Record - >; + ): Promise>; getHeapSnapshotStats( filePath: string, ): Promise; diff --git a/src/tools/memory.ts b/src/tools/memory.ts index 38eacf119..56d29a882 100644 --- a/src/tools/memory.ts +++ b/src/tools/memory.ts @@ -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: { @@ -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, diff --git a/src/utils/id.ts b/src/utils/id.ts new file mode 100644 index 000000000..9e14b231a --- /dev/null +++ b/src/utils/id.ts @@ -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 & { + [stableIdSymbol]?: number; +}; diff --git a/tests/utils.ts b/tests/utils.ts index 6d4668fbf..0ae181965 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -24,8 +24,8 @@ import sinon from 'sinon'; import type {ParsedArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js'; import {McpContext} from '../src/McpContext.js'; import {McpResponse} from '../src/McpResponse.js'; -import {stableIdSymbol} from '../src/PageCollector.js'; import {DevTools} from '../src/third_party/index.js'; +import {stableIdSymbol} from '../src/utils/id.js'; export function getTextContent( content: CallToolResult['content'][number], From c5598878a701414cdebacb8399736ec3b37dc3eb Mon Sep 17 00:00:00 2001 From: Nikolay Vitkov Date: Sun, 19 Apr 2026 22:20:12 +0200 Subject: [PATCH 2/5] add func --- src/HeapSnapshotManager.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/HeapSnapshotManager.ts b/src/HeapSnapshotManager.ts index 6aa74fce9..46b2d0fca 100644 --- a/src/HeapSnapshotManager.ts +++ b/src/HeapSnapshotManager.ts @@ -23,6 +23,7 @@ export class HeapSnapshotManager { { snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy; worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy; + // TODO: use a multimap uidToClassKey: Map; classKeyToUid: Map; idGenerator: () => number; @@ -84,6 +85,20 @@ export class HeapSnapshotManager { return snapshot.staticData; } + async getOrCreateUidForClassKey( + filePath: string, + classKey: string, + ): Promise { + 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; + } + async #loadSnapshot(absolutePath: string): Promise<{ snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy; worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy; From a8b6f5e87751960dca219d6e4dd5574c091beb43 Mon Sep 17 00:00:00 2001 From: Nikolay Vitkov Date: Sun, 19 Apr 2026 22:29:25 +0200 Subject: [PATCH 3/5] add tool --- src/tools/memory.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/tools/memory.ts b/src/tools/memory.ts index 56d29a882..67281b029 100644 --- a/src/tools/memory.ts +++ b/src/tools/memory.ts @@ -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, + }); + }, +}); From c66a0ecc42d51e891ee7a2868ec1375583965b76 Mon Sep 17 00:00:00 2001 From: Nikolay Vitkov Date: Mon, 20 Apr 2026 08:22:47 +0200 Subject: [PATCH 4/5] add tests --- src/HeapSnapshotManager.ts | 9 + .../HeapSnapshotFormatter.test.js.snapshot | 6 +- .../formatters/HeapSnapshotFormatter.test.ts | 5 + tests/tools/memory.test.js.snapshot | 186 ++++++++++++++++++ tests/tools/memory.test.ts | 34 +++- 5 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 tests/tools/memory.test.js.snapshot diff --git a/src/HeapSnapshotManager.ts b/src/HeapSnapshotManager.ts index 46b2d0fca..ea9c022a5 100644 --- a/src/HeapSnapshotManager.ts +++ b/src/HeapSnapshotManager.ts @@ -99,6 +99,15 @@ export class HeapSnapshotManager { 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; diff --git a/tests/formatters/HeapSnapshotFormatter.test.js.snapshot b/tests/formatters/HeapSnapshotFormatter.test.js.snapshot index 45b3af05c..5f729f11d 100644 --- a/tests/formatters/HeapSnapshotFormatter.test.js.snapshot +++ b/tests/formatters/HeapSnapshotFormatter.test.js.snapshot @@ -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 `; diff --git a/tests/formatters/HeapSnapshotFormatter.test.ts b/tests/formatters/HeapSnapshotFormatter.test.ts index 6cde28a7a..21bef9f15 100644 --- a/tests/formatters/HeapSnapshotFormatter.test.ts +++ b/tests/formatters/HeapSnapshotFormatter.test.ts @@ -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< @@ -22,6 +23,7 @@ describe('HeapSnapshotFormatter', () => { maxRet: 1000, distance: 1, idxs: [], + [stableIdSymbol]: 1, } as unknown as DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo, ObjectB: { name: 'ObjectB', @@ -30,6 +32,7 @@ describe('HeapSnapshotFormatter', () => { maxRet: 500, distance: 2, idxs: [], + [stableIdSymbol]: 2, } as unknown as DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo, }; @@ -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, diff --git a/tests/tools/memory.test.js.snapshot b/tests/tools/memory.test.js.snapshot new file mode 100644 index 000000000..4d091bff4 --- /dev/null +++ b/tests/tools/memory.test.js.snapshot @@ -0,0 +1,186 @@ +exports[`memory > get_memory_snapshot_details > with default options 1`] = ` +## Heap Snapshot Data +Showing 1-157 of 157 (Page 1 of 1). +uid,className,count,selfSize,maxRetainedSize +2,"(system)",3205,199264,655004 +16,"(object shape)",2878,181480,184320 +11,"Function",3581,111464,237120 +42,"Window (global*) / https://example.com",2,81068,101832 +94,"Window (prototype) / https://example.com",4,53336,53712 +1,"(compiled code)",713,42812,46988 +3,"(array)",34,41416,117472 +6,"(string)",846,27880,27880 +109,"Document",1,8448,8700 +105,"HTMLElement",1,4728,4768 +104,"Element",1,4224,4540 +15,"Object (global*)",2,3856,13488 +10,"HTMLDocument",1,3288,6192 +89,"system / Context",116,2544,3488 +103,"Node",1,1764,1804 +49,"TypedArray",48,1424,8784 +50,"Error",48,1344,7872 +106,"HTMLBodyElement (prototype) / https://example.com",1,1188,1228 +19,"Array",8,1056,7728 +126,"Performance",1,1040,1784 +32,"String",4,960,6056 +54,"Object",22,928,1168 +130,"StyleEngine",1,912,1104 +9,"Window / https://example.com",1,904,1944 +55,"{constructor, toString, toDateString, toTimeString, toISOString, toUTCString, toGMTString, getDate, setDate, getDay}",4,848,8848 +47,"Math",4,816,7792 +39,"{constructor, buffer, get buffer, byteLength, get byteLength, byteOffset, get byteOffset, length, get length}",4,624,7008 +118,"CSSStyleRule",8,576,1024 +119,"CSSStyleSheet",3,528,976 +44,"console",4,488,6152 +68,"DataView",4,480,5504 +76,"{constructor, getColumnNumber, getEnclosingColumnNumber, getEnclosingLineNumber, getEvalOrigin, getFileName, getFunction}",4,480,4448 +110,"Text",6,480,480 +37,"WebAssembly",4,416,1904 +96,"Window (internal cache) / https://example.com",4,400,20256 +135,"EventListener",10,400,400 +40,"{constructor}",14,392,1560 +30,"Set",4,384,1616 +27,"Map",4,336,1504 +43,"Atomics",4,336,3056 +45,"Intl",4,336,4192 +46,"Reflect",4,336,2480 +67,"{at, copyWithin, entries, fill, find, findIndex, findLast, findLastIndex, flat, flatMap, includes, keys, toReversed}",4,336,1344 +134,"FontFaceSet",1,328,328 +133,"StylePropertyMap",8,320,320 +139,"MutationObserver",2,320,320 +25,"{, constructor, get constructor, set constructor, reduce, toArray, forEach, some, every, find}",4,288,3600 +28,"{constructor, __defineGetter__, __defineSetter__, hasOwnProperty, __lookupGetter__, __lookupSetter__, isPrototypeOf}",4,288,1128 +62,"Intl.Locale",4,288,4672 +33,"WeakMap",4,240,960 +36,"{constructor, exec, dotAll, get dotAll, flags, get flags, global, get global, hasIndices, get hasIndices, ignoreCase}",4,240,576 +48,"Number",4,240,1488 +91,"DisposableStack",4,240,3040 +92,"AsyncDisposableStack",4,240,3040 +4,"{, }",8,224,352 +131,"ScriptedAnimationController",1,208,208 +123,"IntersectionObserver",1,200,240 +7,"(number)",16,192,192 +17,"{isTraceCategoryEnabled, trace, getContinuationPreservedEmbedderData, setContinuationPreservedEmbedderData, console}",4,192,1120 +21,"Generator",4,192,1008 +23,"AsyncGenerator",4,192,1008 +34,"JSON",4,192,1008 +35,"Promise",4,192,1000 +53,"WeakSet",4,192,416 +56,"Intl.NumberFormat",4,192,1536 +59,"Intl.PluralRules",4,192,1168 +60,"Intl.RelativeTimeFormat",4,192,1168 +61,"Intl.ListFormat",4,192,1168 +65,"Intl.DurationFormat",4,192,1168 +66,"Intl.DateTimeFormat",4,192,1536 +69,"BigInt",4,192,1168 +70,"Symbol",4,192,1376 +72,"ArrayBuffer",4,192,2160 +79,"WebAssembly.Table",4,192,768 +80,"WebAssembly.Memory",4,192,768 +120,"NavigationHistoryEntry",1,192,192 +129,"StyleSheetCollection",1,192,192 +146,"DocumentTimeline",1,184,184 +116,"PerformancePaintTiming",2,176,176 +145,"

",2,176,176 +122,"ServiceWorkerContainer",1,168,168 +125,"Navigator",1,168,168 +155,"PerformanceNavigationTiming",1,168,168 +95,"MutationObserver (prototype) / https://example.com",1,156,196 +127,"Navigation",1,152,384 +71,"Boolean",4,144,752 +117,"PerformanceResourceTiming",1,136,136 +147,"",1,136,192 +152,"LargestContentfulPaint",1,136,136 +38,"Tag",4,128,128 +115,"