Skip to content

Commit da38526

Browse files
author
Dominik Inführ
committed
Add MCP tool for heap snapshot comparison
1 parent 08c234e commit da38526

18 files changed

Lines changed: 763 additions & 7 deletions

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,9 +514,11 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles
514514
- [`take_snapshot`](docs/tool-reference.md#take_snapshot)
515515
- [`screencast_start`](docs/tool-reference.md#screencast_start)
516516
- [`screencast_stop`](docs/tool-reference.md#screencast_stop)
517-
- **Memory** (9 tools)
517+
- **Memory** (11 tools)
518518
- [`take_heapsnapshot`](docs/tool-reference.md#take_heapsnapshot)
519519
- [`close_heapsnapshot`](docs/tool-reference.md#close_heapsnapshot)
520+
- [`compare_heapsnapshots_class_nodes`](docs/tool-reference.md#compare_heapsnapshots_class_nodes)
521+
- [`compare_heapsnapshots_summary`](docs/tool-reference.md#compare_heapsnapshots_summary)
520522
- [`get_heapsnapshot_class_nodes`](docs/tool-reference.md#get_heapsnapshot_class_nodes)
521523
- [`get_heapsnapshot_details`](docs/tool-reference.md#get_heapsnapshot_details)
522524
- [`get_heapsnapshot_dominators`](docs/tool-reference.md#get_heapsnapshot_dominators)

docs/tool-reference.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@
3939
- [`take_snapshot`](#take_snapshot)
4040
- [`screencast_start`](#screencast_start)
4141
- [`screencast_stop`](#screencast_stop)
42-
- **[Memory](#memory)** (9 tools)
42+
- **[Memory](#memory)** (11 tools)
4343
- [`take_heapsnapshot`](#take_heapsnapshot)
4444
- [`close_heapsnapshot`](#close_heapsnapshot)
45+
- [`compare_heapsnapshots_class_nodes`](#compare_heapsnapshots_class_nodes)
46+
- [`compare_heapsnapshots_summary`](#compare_heapsnapshots_summary)
4547
- [`get_heapsnapshot_class_nodes`](#get_heapsnapshot_class_nodes)
4648
- [`get_heapsnapshot_details`](#get_heapsnapshot_details)
4749
- [`get_heapsnapshot_dominators`](#get_heapsnapshot_dominators)
@@ -469,6 +471,29 @@ in the DevTools Elements panel (if any).
469471

470472
---
471473

474+
### `compare_heapsnapshots_class_nodes`
475+
476+
**Description:** Loads two memory heapsnapshots and returns the diff details (added/deleted instances) for a specific class. (requires flag: --memoryDebugging=true)
477+
478+
**Parameters:**
479+
480+
- **baseFilePath** (string) **(required)**: A path to the base .heapsnapshot file (earlier snapshot).
481+
- **classIndex** (number) **(required)**: 0-based index of the class in the summary list to filter results, showing individual objects.
482+
- **currentFilePath** (string) **(required)**: A path to the current .heapsnapshot file (later snapshot).
483+
484+
---
485+
486+
### `compare_heapsnapshots_summary`
487+
488+
**Description:** Loads two memory heapsnapshots and returns the summary diff between them (classes with changes). (requires flag: --memoryDebugging=true)
489+
490+
**Parameters:**
491+
492+
- **baseFilePath** (string) **(required)**: A path to the base .heapsnapshot file (earlier snapshot).
493+
- **currentFilePath** (string) **(required)**: A path to the current .heapsnapshot file (later snapshot).
494+
495+
---
496+
472497
### `get_heapsnapshot_class_nodes`
473498

474499
**Description:** Loads a memory heapsnapshot and returns instances of a specific class with their IDs. (requires flag: --memoryDebugging=true)

eslint.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export default defineConfig([
1818
'**/node_modules',
1919
'**/build/',
2020
'tests/tools/fixtures/',
21+
'tests/fixtures/',
2122
'src/third_party/lighthouse-devtools-mcp-bundle.js',
2223
]),
2324
importPlugin.flatConfigs.typescript,

src/HeapSnapshotManager.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,33 @@ import {
1717
export type AggregatedInfoWithId =
1818
WithSymbolId<DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo>;
1919

20+
export interface HeapSnapshotClassDiff {
21+
className: string;
22+
addedCount: number;
23+
removedCount: number;
24+
countDelta: number;
25+
addedSize: number;
26+
removedSize: number;
27+
sizeDelta: number;
28+
}
29+
30+
export interface HeapSnapshotDetailedClassDiff extends HeapSnapshotClassDiff {
31+
addedIds: number[];
32+
addedSelfSizes: number[];
33+
deletedIds: number[];
34+
deletedSelfSizes: number[];
35+
}
36+
2037
export class HeapSnapshotManager {
38+
#snapshotIdGenerator = createIdGenerator();
2139
#snapshots = new Map<
2240
string,
2341
{
2442
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
2543
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
44+
// DevTools calculateSnapshotDiff requires a unique baseSnapshotId to cache
45+
// the calculated diffs on the worker. We use this key for that purpose.
46+
diffCacheKey: number;
2647
// TODO: use a multimap
2748
idToClassKey: Map<number, string>;
2849
classKeyToId: Map<string, number>;
@@ -43,6 +64,7 @@ export class HeapSnapshotManager {
4364
this.#snapshots.set(absolutePath, {
4465
snapshot,
4566
worker,
67+
diffCacheKey: this.#snapshotIdGenerator(),
4668
idToClassKey: new Map<number, string>(),
4769
classKeyToId: new Map<string, number>(),
4870
idGenerator: createIdGenerator(),
@@ -173,6 +195,56 @@ export class HeapSnapshotManager {
173195
return await provider.serializeItemsRange(0, Infinity);
174196
}
175197

198+
async getClassDiffs(
199+
baseFilePath: string,
200+
currentFilePath: string,
201+
): Promise<HeapSnapshotClassDiff[]> {
202+
const rawDiffs = await this.#getSortedRawClassDiffs(
203+
baseFilePath,
204+
currentFilePath,
205+
);
206+
return rawDiffs.map(rawDiff => ({
207+
className: rawDiff.name,
208+
addedCount: rawDiff.addedCount,
209+
removedCount: rawDiff.removedCount,
210+
countDelta: rawDiff.countDelta,
211+
addedSize: rawDiff.addedSize,
212+
removedSize: rawDiff.removedSize,
213+
sizeDelta: rawDiff.sizeDelta,
214+
}));
215+
}
216+
217+
async getDetailedClassDiff(
218+
baseFilePath: string,
219+
currentFilePath: string,
220+
classIndex: number,
221+
): Promise<HeapSnapshotDetailedClassDiff> {
222+
const classDiffs = await this.#getSortedRawClassDiffs(
223+
baseFilePath,
224+
currentFilePath,
225+
);
226+
const result = classDiffs[classIndex];
227+
if (!result) {
228+
throw new Error(
229+
`Invalid classIndex: ${classIndex}. Total classes with changes: ${classDiffs.length}`,
230+
);
231+
}
232+
const rawDiff = result;
233+
return {
234+
className: rawDiff.name,
235+
addedCount: rawDiff.addedCount,
236+
removedCount: rawDiff.removedCount,
237+
countDelta: rawDiff.countDelta,
238+
addedSize: rawDiff.addedSize,
239+
removedSize: rawDiff.removedSize,
240+
sizeDelta: rawDiff.sizeDelta,
241+
addedIds: rawDiff.addedIds ?? [],
242+
addedSelfSizes: rawDiff.addedSelfSizes ?? [],
243+
deletedIds: rawDiff.deletedIds ?? [],
244+
deletedSelfSizes: rawDiff.deletedSelfSizes ?? [],
245+
};
246+
}
247+
176248
#getCachedSnapshot(filePath: string) {
177249
const absolutePath = path.resolve(filePath);
178250
const cached = this.#snapshots.get(absolutePath);
@@ -182,6 +254,33 @@ export class HeapSnapshotManager {
182254
return cached;
183255
}
184256

257+
async #getSortedRawClassDiffs(
258+
baseFilePath: string,
259+
currentFilePath: string,
260+
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff[]> {
261+
const baseSnapshot = await this.getSnapshot(baseFilePath);
262+
const currentSnapshot = await this.getSnapshot(currentFilePath);
263+
264+
const interfaceDefinitions = await currentSnapshot.interfaceDefinitions();
265+
const aggregatesForDiff =
266+
await baseSnapshot.aggregatesForDiff(interfaceDefinitions);
267+
const cachedBaseSnapshot = this.#getCachedSnapshot(baseFilePath);
268+
// DevTools calculateSnapshotDiff uses the first parameter (baseSnapshotId)
269+
// as a cache key. We pass the unique diffCacheKey we generated when loading
270+
// the base snapshot.
271+
const rawDiffs = await currentSnapshot.calculateSnapshotDiff(
272+
cachedBaseSnapshot.diffCacheKey,
273+
aggregatesForDiff,
274+
);
275+
276+
// Return a filtered and sorted array here to ensure that
277+
// compare_heapsnapshot_summary and compare_heapsnapshot_details agree
278+
// on indices.
279+
return Object.values(rawDiffs)
280+
.filter(diff => diff.addedCount > 0 || diff.removedCount > 0)
281+
.sort((a, b) => b.sizeDelta - a.sizeDelta);
282+
}
283+
185284
async resolveClassKeyFromId(
186285
filePath: string,
187286
id: number,

src/McpContext.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import {
1616
UniverseManager,
1717
} from './devtools/DevtoolsUtils.js';
1818
import {HeapSnapshotManager} from './HeapSnapshotManager.js';
19-
import type {AggregatedInfoWithId} from './HeapSnapshotManager.js';
19+
import type {
20+
AggregatedInfoWithId,
21+
HeapSnapshotClassDiff,
22+
HeapSnapshotDetailedClassDiff,
23+
} from './HeapSnapshotManager.js';
2024
import {McpPage} from './McpPage.js';
2125
import {
2226
NetworkCollector,
@@ -982,4 +986,26 @@ export class McpContext implements Context {
982986
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
983987
return await this.#heapSnapshotManager.getEdges(filePath, nodeId);
984988
}
989+
990+
async getHeapSnapshotClassDiffs(
991+
baseFilePath: string,
992+
currentFilePath: string,
993+
): Promise<HeapSnapshotClassDiff[]> {
994+
return await this.#heapSnapshotManager.getClassDiffs(
995+
baseFilePath,
996+
currentFilePath,
997+
);
998+
}
999+
1000+
async getHeapSnapshotDetailedClassDiff(
1001+
baseFilePath: string,
1002+
currentFilePath: string,
1003+
classIndex: number,
1004+
): Promise<HeapSnapshotDetailedClassDiff> {
1005+
return await this.#heapSnapshotManager.getDetailedClassDiff(
1006+
baseFilePath,
1007+
currentFilePath,
1008+
classIndex,
1009+
);
1010+
}
9851011
}

src/McpResponse.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,18 @@ import type {WebMCPTool} from 'puppeteer-core';
88

99
import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js';
1010
import {ConsoleFormatter} from './formatters/ConsoleFormatter.js';
11-
import {HeapSnapshotFormatter} from './formatters/HeapSnapshotFormatter.js';
12-
import {isEdgeLike, isNodeLike} from './formatters/HeapSnapshotFormatter.js';
11+
import {
12+
HeapSnapshotFormatter,
13+
isEdgeLike,
14+
isNodeLike,
15+
} from './formatters/HeapSnapshotFormatter.js';
1316
import {IssueFormatter} from './formatters/IssueFormatter.js';
1417
import {NetworkFormatter} from './formatters/NetworkFormatter.js';
1518
import {SnapshotFormatter} from './formatters/SnapshotFormatter.js';
19+
import type {
20+
HeapSnapshotClassDiff,
21+
HeapSnapshotDetailedClassDiff,
22+
} from './HeapSnapshotManager.js';
1623
import type {McpContext} from './McpContext.js';
1724
import type {McpPage} from './McpPage.js';
1825
import {UncaughtError} from './PageCollector.js';
@@ -224,6 +231,8 @@ export class McpResponse implements Response {
224231
nodes?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange;
225232
retainingPaths?: DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths;
226233
dominators?: DevTools.HeapSnapshotModel.HeapSnapshotModel.DominatorChain;
234+
classDiffs?: HeapSnapshotClassDiff[];
235+
detailedClassDiff?: HeapSnapshotDetailedClassDiff;
227236
};
228237
#networkRequestsOptions?: {
229238
include: boolean;
@@ -500,6 +509,24 @@ export class McpResponse implements Response {
500509
};
501510
}
502511

512+
setHeapSnapshotClassDiffs(classDiffs: HeapSnapshotClassDiff[]) {
513+
this.#heapSnapshotOptions = {
514+
...this.#heapSnapshotOptions,
515+
include: true,
516+
classDiffs,
517+
};
518+
}
519+
520+
setHeapSnapshotDetailedClassDiff(
521+
detailedClassDiff: HeapSnapshotDetailedClassDiff,
522+
) {
523+
this.#heapSnapshotOptions = {
524+
...this.#heapSnapshotOptions,
525+
include: true,
526+
detailedClassDiff,
527+
};
528+
}
529+
503530
attachImage(value: ImageContentData): void {
504531
this.#images.push(value);
505532
}
@@ -831,6 +858,8 @@ export class McpResponse implements Response {
831858
heapSnapshotNodes?: readonly object[];
832859
heapSnapshotRetainingPaths?: object;
833860
heapSnapshotDominators?: readonly object[];
861+
heapSnapshotClassDiffs?: HeapSnapshotClassDiff[];
862+
heapSnapshotDetailedClassDiff?: HeapSnapshotDetailedClassDiff;
834863
extensionServiceWorkers?: object[];
835864
extensionPages?: object[];
836865
errorMessage?: string;
@@ -1155,6 +1184,26 @@ Call ${handleDialog.name} to handle it before continuing.`);
11551184
}
11561185
structuredContent.heapSnapshotDominators = dominators;
11571186
}
1187+
const classDiffs = this.#heapSnapshotOptions.classDiffs;
1188+
if (classDiffs) {
1189+
response.push('### Heap Snapshot Diff');
1190+
response.push(
1191+
useToon
1192+
? toonEncode(classDiffs)
1193+
: HeapSnapshotFormatter.formatDiffSummary(classDiffs),
1194+
);
1195+
structuredContent.heapSnapshotClassDiffs = classDiffs;
1196+
}
1197+
const detailedClassDiff = this.#heapSnapshotOptions.detailedClassDiff;
1198+
if (detailedClassDiff) {
1199+
response.push('### Heap Snapshot Detailed Diff');
1200+
response.push(
1201+
useToon
1202+
? toonEncode(detailedClassDiff)
1203+
: HeapSnapshotFormatter.formatDiffDetails(detailedClassDiff),
1204+
);
1205+
structuredContent.heapSnapshotDetailedClassDiff = detailedClassDiff;
1206+
}
11581207
}
11591208

11601209
if (data.detailedNetworkRequest) {

src/bin/chrome-devtools-cli-options.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,55 @@ export const commands: Commands = {
108108
},
109109
},
110110
},
111+
compare_heapsnapshots_class_nodes: {
112+
description:
113+
'Loads two memory heapsnapshots and returns the diff details (added/deleted instances) for a specific class. (requires flag: --memoryDebugging=true)',
114+
category: 'Memory',
115+
args: {
116+
baseFilePath: {
117+
name: 'baseFilePath',
118+
type: 'string',
119+
description:
120+
'A path to the base .heapsnapshot file (earlier snapshot).',
121+
required: true,
122+
},
123+
currentFilePath: {
124+
name: 'currentFilePath',
125+
type: 'string',
126+
description:
127+
'A path to the current .heapsnapshot file (later snapshot).',
128+
required: true,
129+
},
130+
classIndex: {
131+
name: 'classIndex',
132+
type: 'number',
133+
description:
134+
'0-based index of the class in the summary list to filter results, showing individual objects.',
135+
required: true,
136+
},
137+
},
138+
},
139+
compare_heapsnapshots_summary: {
140+
description:
141+
'Loads two memory heapsnapshots and returns the summary diff between them (classes with changes). (requires flag: --memoryDebugging=true)',
142+
category: 'Memory',
143+
args: {
144+
baseFilePath: {
145+
name: 'baseFilePath',
146+
type: 'string',
147+
description:
148+
'A path to the base .heapsnapshot file (earlier snapshot).',
149+
required: true,
150+
},
151+
currentFilePath: {
152+
name: 'currentFilePath',
153+
type: 'string',
154+
description:
155+
'A path to the current .heapsnapshot file (later snapshot).',
156+
required: true,
157+
},
158+
},
159+
},
111160
drag: {
112161
description: 'Drag an element onto another element',
113162
category: 'Input automation',

0 commit comments

Comments
 (0)