mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-14 19:45:30 +08:00
feat: Add MCP tools for heap snapshot comparison (#2198)
This commit adds two MCP tools for comparing heap snapshots. `compare_heapsnapshot_summary` compares two memory snapshot and returns which classes have new/deleted objects. `compare_heapsnapshot_class_nodes` can then be used to list the object ids added and deleted for a specific class. Co-authored-by: Dominik Inführ <dinfuehr@chromium.org>
This commit is contained in:
@@ -514,9 +514,11 @@ If you run into any issues, checkout our [troubleshooting guide](./docs/troubles
|
||||
- [`take_snapshot`](docs/tool-reference.md#take_snapshot)
|
||||
- [`screencast_start`](docs/tool-reference.md#screencast_start)
|
||||
- [`screencast_stop`](docs/tool-reference.md#screencast_stop)
|
||||
- **Memory** (9 tools)
|
||||
- **Memory** (11 tools)
|
||||
- [`take_heapsnapshot`](docs/tool-reference.md#take_heapsnapshot)
|
||||
- [`close_heapsnapshot`](docs/tool-reference.md#close_heapsnapshot)
|
||||
- [`compare_heapsnapshots_class_nodes`](docs/tool-reference.md#compare_heapsnapshots_class_nodes)
|
||||
- [`compare_heapsnapshots_summary`](docs/tool-reference.md#compare_heapsnapshots_summary)
|
||||
- [`get_heapsnapshot_class_nodes`](docs/tool-reference.md#get_heapsnapshot_class_nodes)
|
||||
- [`get_heapsnapshot_details`](docs/tool-reference.md#get_heapsnapshot_details)
|
||||
- [`get_heapsnapshot_dominators`](docs/tool-reference.md#get_heapsnapshot_dominators)
|
||||
|
||||
+26
-1
@@ -39,9 +39,11 @@
|
||||
- [`take_snapshot`](#take_snapshot)
|
||||
- [`screencast_start`](#screencast_start)
|
||||
- [`screencast_stop`](#screencast_stop)
|
||||
- **[Memory](#memory)** (9 tools)
|
||||
- **[Memory](#memory)** (11 tools)
|
||||
- [`take_heapsnapshot`](#take_heapsnapshot)
|
||||
- [`close_heapsnapshot`](#close_heapsnapshot)
|
||||
- [`compare_heapsnapshots_class_nodes`](#compare_heapsnapshots_class_nodes)
|
||||
- [`compare_heapsnapshots_summary`](#compare_heapsnapshots_summary)
|
||||
- [`get_heapsnapshot_class_nodes`](#get_heapsnapshot_class_nodes)
|
||||
- [`get_heapsnapshot_details`](#get_heapsnapshot_details)
|
||||
- [`get_heapsnapshot_dominators`](#get_heapsnapshot_dominators)
|
||||
@@ -469,6 +471,29 @@ in the DevTools Elements panel (if any).
|
||||
|
||||
---
|
||||
|
||||
### `compare_heapsnapshots_class_nodes`
|
||||
|
||||
**Description:** Loads two memory heapsnapshots and returns the diff details (added/deleted instances) for a specific class. (requires flag: --memoryDebugging=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **baseFilePath** (string) **(required)**: A path to the base .heapsnapshot file (earlier snapshot).
|
||||
- **classIndex** (number) **(required)**: 0-based index of the class in the summary list to filter results, showing individual objects.
|
||||
- **currentFilePath** (string) **(required)**: A path to the current .heapsnapshot file (later snapshot).
|
||||
|
||||
---
|
||||
|
||||
### `compare_heapsnapshots_summary`
|
||||
|
||||
**Description:** Loads two memory heapsnapshots and returns the summary diff between them (classes with changes). (requires flag: --memoryDebugging=true)
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- **baseFilePath** (string) **(required)**: A path to the base .heapsnapshot file (earlier snapshot).
|
||||
- **currentFilePath** (string) **(required)**: A path to the current .heapsnapshot file (later snapshot).
|
||||
|
||||
---
|
||||
|
||||
### `get_heapsnapshot_class_nodes`
|
||||
|
||||
**Description:** Loads a memory heapsnapshot and returns instances of a specific class with their IDs. (requires flag: --memoryDebugging=true)
|
||||
|
||||
@@ -18,6 +18,7 @@ export default defineConfig([
|
||||
'**/node_modules',
|
||||
'**/build/',
|
||||
'tests/tools/fixtures/',
|
||||
'tests/fixtures/',
|
||||
'src/third_party/lighthouse-devtools-mcp-bundle.js',
|
||||
]),
|
||||
importPlugin.flatConfigs.typescript,
|
||||
|
||||
+103
-3
@@ -17,7 +17,25 @@ import {
|
||||
export type AggregatedInfoWithId =
|
||||
WithSymbolId<DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo>;
|
||||
|
||||
export interface HeapSnapshotClassDiff {
|
||||
className: string;
|
||||
addedCount: number;
|
||||
removedCount: number;
|
||||
countDelta: number;
|
||||
addedSize: number;
|
||||
removedSize: number;
|
||||
sizeDelta: number;
|
||||
}
|
||||
|
||||
export interface HeapSnapshotDetailedClassDiff extends HeapSnapshotClassDiff {
|
||||
addedIds: number[];
|
||||
addedSelfSizes: number[];
|
||||
deletedIds: number[];
|
||||
deletedSelfSizes: number[];
|
||||
}
|
||||
|
||||
export class HeapSnapshotManager {
|
||||
#snapshotIdGenerator = createIdGenerator();
|
||||
#snapshots = new Map<
|
||||
string,
|
||||
{
|
||||
@@ -39,7 +57,8 @@ export class HeapSnapshotManager {
|
||||
return cached.snapshot;
|
||||
}
|
||||
|
||||
const {snapshot, worker} = await this.#loadSnapshot(absolutePath);
|
||||
const uid = this.#snapshotIdGenerator();
|
||||
const {snapshot, worker} = await this.#loadSnapshot(absolutePath, uid);
|
||||
this.#snapshots.set(absolutePath, {
|
||||
snapshot,
|
||||
worker,
|
||||
@@ -173,6 +192,55 @@ export class HeapSnapshotManager {
|
||||
return await provider.serializeItemsRange(0, Infinity);
|
||||
}
|
||||
|
||||
async getClassDiffs(
|
||||
baseFilePath: string,
|
||||
currentFilePath: string,
|
||||
): Promise<HeapSnapshotClassDiff[]> {
|
||||
const rawDiffs = await this.#getSortedRawClassDiffs(
|
||||
baseFilePath,
|
||||
currentFilePath,
|
||||
);
|
||||
return rawDiffs.map(rawDiff => ({
|
||||
className: rawDiff.name,
|
||||
addedCount: rawDiff.addedCount,
|
||||
removedCount: rawDiff.removedCount,
|
||||
countDelta: rawDiff.countDelta,
|
||||
addedSize: rawDiff.addedSize,
|
||||
removedSize: rawDiff.removedSize,
|
||||
sizeDelta: rawDiff.sizeDelta,
|
||||
}));
|
||||
}
|
||||
|
||||
async getDetailedClassDiff(
|
||||
baseFilePath: string,
|
||||
currentFilePath: string,
|
||||
classIndex: number,
|
||||
): Promise<HeapSnapshotDetailedClassDiff> {
|
||||
const classDiffs = await this.#getSortedRawClassDiffs(
|
||||
baseFilePath,
|
||||
currentFilePath,
|
||||
);
|
||||
const rawDiff = classDiffs[classIndex];
|
||||
if (!rawDiff) {
|
||||
throw new Error(
|
||||
`Invalid classIndex: ${classIndex}. Total classes with changes: ${classDiffs.length}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
className: rawDiff.name,
|
||||
addedCount: rawDiff.addedCount,
|
||||
removedCount: rawDiff.removedCount,
|
||||
countDelta: rawDiff.countDelta,
|
||||
addedSize: rawDiff.addedSize,
|
||||
removedSize: rawDiff.removedSize,
|
||||
sizeDelta: rawDiff.sizeDelta,
|
||||
addedIds: rawDiff.addedIds ?? [],
|
||||
addedSelfSizes: rawDiff.addedSelfSizes ?? [],
|
||||
deletedIds: rawDiff.deletedIds ?? [],
|
||||
deletedSelfSizes: rawDiff.deletedSelfSizes ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
#getCachedSnapshot(filePath: string) {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
const cached = this.#snapshots.get(absolutePath);
|
||||
@@ -182,6 +250,35 @@ export class HeapSnapshotManager {
|
||||
return cached;
|
||||
}
|
||||
|
||||
async #getSortedRawClassDiffs(
|
||||
baseFilePath: string,
|
||||
currentFilePath: string,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Diff[]> {
|
||||
const baseSnapshot = await this.getSnapshot(baseFilePath);
|
||||
const currentSnapshot = await this.getSnapshot(currentFilePath);
|
||||
|
||||
const interfaceDefinitions = await currentSnapshot.interfaceDefinitions();
|
||||
const aggregatesForDiff =
|
||||
await baseSnapshot.aggregatesForDiff(interfaceDefinitions);
|
||||
const baseSnapshotId = baseSnapshot.uid;
|
||||
if (baseSnapshotId === undefined) {
|
||||
throw new Error('Base snapshot UID is undefined');
|
||||
}
|
||||
// DevTools calculateSnapshotDiff uses the first parameter (baseSnapshotId)
|
||||
// as a cache key. We pass the unique UID of the base snapshot.
|
||||
const rawDiffs = await currentSnapshot.calculateSnapshotDiff(
|
||||
baseSnapshotId,
|
||||
aggregatesForDiff,
|
||||
);
|
||||
|
||||
// Return a filtered and sorted array here to ensure that
|
||||
// compare_heapsnapshot_summary and compare_heapsnapshot_details agree
|
||||
// on indices.
|
||||
return Object.values(rawDiffs)
|
||||
.filter(diff => diff.addedCount > 0 || diff.removedCount > 0)
|
||||
.sort((a, b) => b.sizeDelta - a.sizeDelta);
|
||||
}
|
||||
|
||||
async resolveClassKeyFromId(
|
||||
filePath: string,
|
||||
id: number,
|
||||
@@ -190,7 +287,10 @@ export class HeapSnapshotManager {
|
||||
return cached.idToClassKey.get(id);
|
||||
}
|
||||
|
||||
async #loadSnapshot(absolutePath: string): Promise<{
|
||||
async #loadSnapshot(
|
||||
absolutePath: string,
|
||||
uid: number,
|
||||
): Promise<{
|
||||
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
|
||||
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
|
||||
}> {
|
||||
@@ -205,7 +305,7 @@ export class HeapSnapshotManager {
|
||||
const {promise: snapshotPromise, resolve: resolveSnapshot} =
|
||||
Promise.withResolvers<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy>();
|
||||
|
||||
const loaderProxy = workerProxy.createLoader(1, snapshotProxy => {
|
||||
const loaderProxy = workerProxy.createLoader(uid, snapshotProxy => {
|
||||
resolveSnapshot(snapshotProxy);
|
||||
});
|
||||
|
||||
|
||||
+27
-1
@@ -16,7 +16,11 @@ import {
|
||||
UniverseManager,
|
||||
} from './devtools/DevtoolsUtils.js';
|
||||
import {HeapSnapshotManager} from './HeapSnapshotManager.js';
|
||||
import type {AggregatedInfoWithId} from './HeapSnapshotManager.js';
|
||||
import type {
|
||||
AggregatedInfoWithId,
|
||||
HeapSnapshotClassDiff,
|
||||
HeapSnapshotDetailedClassDiff,
|
||||
} from './HeapSnapshotManager.js';
|
||||
import {McpPage} from './McpPage.js';
|
||||
import {
|
||||
NetworkCollector,
|
||||
@@ -1016,4 +1020,26 @@ export class McpContext implements Context {
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
|
||||
return await this.#heapSnapshotManager.getEdges(filePath, nodeId);
|
||||
}
|
||||
|
||||
async getHeapSnapshotClassDiffs(
|
||||
baseFilePath: string,
|
||||
currentFilePath: string,
|
||||
): Promise<HeapSnapshotClassDiff[]> {
|
||||
return await this.#heapSnapshotManager.getClassDiffs(
|
||||
baseFilePath,
|
||||
currentFilePath,
|
||||
);
|
||||
}
|
||||
|
||||
async getHeapSnapshotDetailedClassDiff(
|
||||
baseFilePath: string,
|
||||
currentFilePath: string,
|
||||
classIndex: number,
|
||||
): Promise<HeapSnapshotDetailedClassDiff> {
|
||||
return await this.#heapSnapshotManager.getDetailedClassDiff(
|
||||
baseFilePath,
|
||||
currentFilePath,
|
||||
classIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+51
-2
@@ -8,11 +8,18 @@ import type {WebMCPTool} from 'puppeteer-core';
|
||||
|
||||
import type {ParsedArguments} from './bin/chrome-devtools-mcp-cli-options.js';
|
||||
import {ConsoleFormatter} from './formatters/ConsoleFormatter.js';
|
||||
import {HeapSnapshotFormatter} from './formatters/HeapSnapshotFormatter.js';
|
||||
import {isEdgeLike, isNodeLike} from './formatters/HeapSnapshotFormatter.js';
|
||||
import {
|
||||
HeapSnapshotFormatter,
|
||||
isEdgeLike,
|
||||
isNodeLike,
|
||||
} from './formatters/HeapSnapshotFormatter.js';
|
||||
import {IssueFormatter} from './formatters/IssueFormatter.js';
|
||||
import {NetworkFormatter} from './formatters/NetworkFormatter.js';
|
||||
import {SnapshotFormatter} from './formatters/SnapshotFormatter.js';
|
||||
import type {
|
||||
HeapSnapshotClassDiff,
|
||||
HeapSnapshotDetailedClassDiff,
|
||||
} from './HeapSnapshotManager.js';
|
||||
import type {McpContext} from './McpContext.js';
|
||||
import type {McpPage} from './McpPage.js';
|
||||
import {UncaughtError} from './PageCollector.js';
|
||||
@@ -225,6 +232,8 @@ export class McpResponse implements Response {
|
||||
nodes?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange;
|
||||
retainingPaths?: DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths;
|
||||
dominators?: DevTools.HeapSnapshotModel.HeapSnapshotModel.DominatorChain;
|
||||
classDiffs?: HeapSnapshotClassDiff[];
|
||||
detailedClassDiff?: HeapSnapshotDetailedClassDiff;
|
||||
};
|
||||
#networkRequestsOptions?: {
|
||||
include: boolean;
|
||||
@@ -501,6 +510,24 @@ export class McpResponse implements Response {
|
||||
};
|
||||
}
|
||||
|
||||
setHeapSnapshotClassDiffs(classDiffs: HeapSnapshotClassDiff[]) {
|
||||
this.#heapSnapshotOptions = {
|
||||
...this.#heapSnapshotOptions,
|
||||
include: true,
|
||||
classDiffs,
|
||||
};
|
||||
}
|
||||
|
||||
setHeapSnapshotDetailedClassDiff(
|
||||
detailedClassDiff: HeapSnapshotDetailedClassDiff,
|
||||
) {
|
||||
this.#heapSnapshotOptions = {
|
||||
...this.#heapSnapshotOptions,
|
||||
include: true,
|
||||
detailedClassDiff,
|
||||
};
|
||||
}
|
||||
|
||||
attachImage(value: ImageContentData): void {
|
||||
this.#images.push(value);
|
||||
}
|
||||
@@ -832,6 +859,8 @@ export class McpResponse implements Response {
|
||||
heapSnapshotNodes?: readonly object[];
|
||||
heapSnapshotRetainingPaths?: object;
|
||||
heapSnapshotDominators?: readonly object[];
|
||||
heapSnapshotClassDiffs?: HeapSnapshotClassDiff[];
|
||||
heapSnapshotDetailedClassDiff?: HeapSnapshotDetailedClassDiff;
|
||||
extensionServiceWorkers?: object[];
|
||||
extensionPages?: object[];
|
||||
errorMessage?: string;
|
||||
@@ -1170,6 +1199,26 @@ Call ${handleDialog.name} to handle it before continuing.`);
|
||||
}
|
||||
structuredContent.heapSnapshotDominators = dominators;
|
||||
}
|
||||
const classDiffs = this.#heapSnapshotOptions.classDiffs;
|
||||
if (classDiffs) {
|
||||
response.push('### Heap Snapshot Diff');
|
||||
response.push(
|
||||
useToon && toonEncode
|
||||
? toonEncode(classDiffs)
|
||||
: HeapSnapshotFormatter.formatDiffSummary(classDiffs),
|
||||
);
|
||||
structuredContent.heapSnapshotClassDiffs = classDiffs;
|
||||
}
|
||||
const detailedClassDiff = this.#heapSnapshotOptions.detailedClassDiff;
|
||||
if (detailedClassDiff) {
|
||||
response.push('### Heap Snapshot Detailed Diff');
|
||||
response.push(
|
||||
useToon && toonEncode
|
||||
? toonEncode(detailedClassDiff)
|
||||
: HeapSnapshotFormatter.formatDiffDetails(detailedClassDiff),
|
||||
);
|
||||
structuredContent.heapSnapshotDetailedClassDiff = detailedClassDiff;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.detailedNetworkRequest) {
|
||||
|
||||
@@ -108,6 +108,55 @@ export const commands: Commands = {
|
||||
},
|
||||
},
|
||||
},
|
||||
compare_heapsnapshots_class_nodes: {
|
||||
description:
|
||||
'Loads two memory heapsnapshots and returns the diff details (added/deleted instances) for a specific class. (requires flag: --memoryDebugging=true)',
|
||||
category: 'Memory',
|
||||
args: {
|
||||
baseFilePath: {
|
||||
name: 'baseFilePath',
|
||||
type: 'string',
|
||||
description:
|
||||
'A path to the base .heapsnapshot file (earlier snapshot).',
|
||||
required: true,
|
||||
},
|
||||
currentFilePath: {
|
||||
name: 'currentFilePath',
|
||||
type: 'string',
|
||||
description:
|
||||
'A path to the current .heapsnapshot file (later snapshot).',
|
||||
required: true,
|
||||
},
|
||||
classIndex: {
|
||||
name: 'classIndex',
|
||||
type: 'number',
|
||||
description:
|
||||
'0-based index of the class in the summary list to filter results, showing individual objects.',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
compare_heapsnapshots_summary: {
|
||||
description:
|
||||
'Loads two memory heapsnapshots and returns the summary diff between them (classes with changes). (requires flag: --memoryDebugging=true)',
|
||||
category: 'Memory',
|
||||
args: {
|
||||
baseFilePath: {
|
||||
name: 'baseFilePath',
|
||||
type: 'string',
|
||||
description:
|
||||
'A path to the base .heapsnapshot file (earlier snapshot).',
|
||||
required: true,
|
||||
},
|
||||
currentFilePath: {
|
||||
name: 'currentFilePath',
|
||||
type: 'string',
|
||||
description:
|
||||
'A path to the current .heapsnapshot file (later snapshot).',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
drag: {
|
||||
description: 'Drag an element onto another element',
|
||||
category: 'Input automation',
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {AggregatedInfoWithId} from '../HeapSnapshotManager.js';
|
||||
import type {
|
||||
AggregatedInfoWithId,
|
||||
HeapSnapshotClassDiff,
|
||||
HeapSnapshotDetailedClassDiff,
|
||||
} from '../HeapSnapshotManager.js';
|
||||
import {DevTools} from '../third_party/index.js';
|
||||
import {stableIdSymbol} from '../utils/id.js';
|
||||
|
||||
@@ -157,4 +161,57 @@ export class HeapSnapshotFormatter {
|
||||
> {
|
||||
return Object.entries(aggregates).sort((a, b) => b[1].maxRet - a[1].maxRet);
|
||||
}
|
||||
|
||||
static formatDiffSummary(diffs: HeapSnapshotClassDiff[]): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
'index,className,addedCount,removedCount,countDelta,addedSize,removedSize,sizeDelta',
|
||||
);
|
||||
|
||||
let index = 0;
|
||||
for (const diff of diffs) {
|
||||
lines.push(
|
||||
`${index},${diff.className},${diff.addedCount},${diff.removedCount},${diff.countDelta},${DevTools.I18n.ByteUtilities.formatBytesToKb(diff.addedSize)},${DevTools.I18n.ByteUtilities.formatBytesToKb(diff.removedSize)},${DevTools.I18n.ByteUtilities.formatBytesToKb(diff.sizeDelta)}`,
|
||||
);
|
||||
index++;
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
static formatDiffDetails(diff: HeapSnapshotDetailedClassDiff): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
`${diff.className}: # new: ${diff.addedCount}, # deleted: ${diff.removedCount}, # delta: ${formatSignedCount(diff.countDelta)}, alloc size: ${formatSignedSize(diff.addedSize)}, freed size: ${formatSignedSize(diff.removedSize)}, size delta: ${formatSignedSize(diff.sizeDelta)}`,
|
||||
);
|
||||
|
||||
const addedIds = diff.addedIds;
|
||||
const addedSelfSizes = diff.addedSelfSizes;
|
||||
const deletedIds = diff.deletedIds;
|
||||
const deletedSelfSizes = diff.deletedSelfSizes;
|
||||
|
||||
lines.push(`Objects:`);
|
||||
|
||||
for (let i = 0; i < addedIds.length; i++) {
|
||||
lines.push(
|
||||
` + @${addedIds[i]} (self_size: ${DevTools.I18n.ByteUtilities.formatBytesToKb(addedSelfSizes[i])})`,
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < deletedIds.length; i++) {
|
||||
lines.push(
|
||||
` - @${deletedIds[i]} (self_size: ${DevTools.I18n.ByteUtilities.formatBytesToKb(deletedSelfSizes[i])})`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
function formatSignedCount(n: number): string {
|
||||
return n > 0 ? `+${n}` : `${n}`;
|
||||
}
|
||||
|
||||
function formatSignedSize(bytes: number): string {
|
||||
const formatted = DevTools.I18n.ByteUtilities.formatBytesToKb(bytes);
|
||||
return bytes > 0 ? `+${formatted}` : formatted;
|
||||
}
|
||||
|
||||
@@ -810,5 +810,35 @@
|
||||
"argType": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "compare_heapsnapshots_class_nodes",
|
||||
"args": [
|
||||
{
|
||||
"name": "base_file_path_length",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "current_file_path_length",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "class_index",
|
||||
"argType": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "compare_heapsnapshots_summary",
|
||||
"args": [
|
||||
{
|
||||
"name": "base_file_path_length",
|
||||
"argType": "number"
|
||||
},
|
||||
{
|
||||
"name": "current_file_path_length",
|
||||
"argType": "number"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
*/
|
||||
|
||||
import type {ParsedArguments} from '../bin/chrome-devtools-mcp-cli-options.js';
|
||||
import type {AggregatedInfoWithId} from '../HeapSnapshotManager.js';
|
||||
import type {
|
||||
AggregatedInfoWithId,
|
||||
HeapSnapshotClassDiff,
|
||||
HeapSnapshotDetailedClassDiff,
|
||||
} from '../HeapSnapshotManager.js';
|
||||
import type {McpPage} from '../McpPage.js';
|
||||
import {zod} from '../third_party/index.js';
|
||||
import type {
|
||||
@@ -122,6 +126,10 @@ export interface Response {
|
||||
setHeapSnapshotDominators(
|
||||
dominators: DevTools.HeapSnapshotModel.HeapSnapshotModel.DominatorChain,
|
||||
): void;
|
||||
setHeapSnapshotClassDiffs(classDiffs: HeapSnapshotClassDiff[]): void;
|
||||
setHeapSnapshotDetailedClassDiff(
|
||||
detailedClassDiff: HeapSnapshotDetailedClassDiff,
|
||||
): void;
|
||||
setIncludePages(value: boolean): void;
|
||||
setIncludeNetworkRequests(
|
||||
value: boolean,
|
||||
@@ -271,6 +279,15 @@ export type Context = Readonly<{
|
||||
filePath: string,
|
||||
nodeId: number,
|
||||
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange>;
|
||||
getHeapSnapshotClassDiffs(
|
||||
baseFilePath: string,
|
||||
currentFilePath: string,
|
||||
): Promise<HeapSnapshotClassDiff[]>;
|
||||
getHeapSnapshotDetailedClassDiff(
|
||||
baseFilePath: string,
|
||||
currentFilePath: string,
|
||||
classIndex: number,
|
||||
): Promise<HeapSnapshotDetailedClassDiff>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -280,3 +280,66 @@ export const getHeapSnapshotDominators = defineTool({
|
||||
response.setHeapSnapshotDominators(dominators);
|
||||
},
|
||||
});
|
||||
|
||||
export const compareHeapSnapshotsSummary = defineTool({
|
||||
name: 'compare_heapsnapshots_summary',
|
||||
description:
|
||||
'Loads two memory heapsnapshots and returns the summary diff between them (classes with changes).',
|
||||
annotations: {
|
||||
category: ToolCategory.MEMORY,
|
||||
readOnlyHint: true,
|
||||
conditions: ['memoryDebugging'],
|
||||
},
|
||||
verifyFilesSchema: ['baseFilePath', 'currentFilePath'],
|
||||
schema: {
|
||||
baseFilePath: zod
|
||||
.string()
|
||||
.describe('A path to the base .heapsnapshot file (earlier snapshot).'),
|
||||
currentFilePath: zod
|
||||
.string()
|
||||
.describe('A path to the current .heapsnapshot file (later snapshot).'),
|
||||
},
|
||||
blockedByDialog: false,
|
||||
handler: async (request, response, context) => {
|
||||
const diff = await context.getHeapSnapshotClassDiffs(
|
||||
request.params.baseFilePath,
|
||||
request.params.currentFilePath,
|
||||
);
|
||||
|
||||
response.setHeapSnapshotClassDiffs(diff);
|
||||
},
|
||||
});
|
||||
|
||||
export const compareHeapSnapshotsClassNodes = defineTool({
|
||||
name: 'compare_heapsnapshots_class_nodes',
|
||||
description:
|
||||
'Loads two memory heapsnapshots and returns the diff details (added/deleted instances) for a specific class.',
|
||||
annotations: {
|
||||
category: ToolCategory.MEMORY,
|
||||
readOnlyHint: true,
|
||||
conditions: ['memoryDebugging'],
|
||||
},
|
||||
verifyFilesSchema: ['baseFilePath', 'currentFilePath'],
|
||||
schema: {
|
||||
baseFilePath: zod
|
||||
.string()
|
||||
.describe('A path to the base .heapsnapshot file (earlier snapshot).'),
|
||||
currentFilePath: zod
|
||||
.string()
|
||||
.describe('A path to the current .heapsnapshot file (later snapshot).'),
|
||||
classIndex: zod
|
||||
.number()
|
||||
.describe(
|
||||
'0-based index of the class in the summary list to filter results, showing individual objects.',
|
||||
),
|
||||
},
|
||||
blockedByDialog: false,
|
||||
handler: async (request, response, context) => {
|
||||
const classDiffResult = await context.getHeapSnapshotDetailedClassDiff(
|
||||
request.params.baseFilePath,
|
||||
request.params.currentFilePath,
|
||||
request.params.classIndex,
|
||||
);
|
||||
response.setHeapSnapshotDetailedClassDiff(classDiffResult);
|
||||
},
|
||||
});
|
||||
|
||||
Vendored
+8
File diff suppressed because one or more lines are too long
Vendored
+8
File diff suppressed because one or more lines are too long
Vendored
+8
File diff suppressed because one or more lines are too long
Vendored
+49
@@ -0,0 +1,49 @@
|
||||
'use strict';
|
||||
|
||||
// Note: This file is not executed during tests. It is only used to document how
|
||||
// heap-1.heapsnapshot, heap-2.heapsnapshot, and heap-3.heapsnapshot were created,
|
||||
// and to allow recreating them in the future if necessary.
|
||||
//
|
||||
// Heap snapshots were created with this command line:
|
||||
// d8 --allow-natives-syntax snapshot_diffs.js
|
||||
|
||||
function InitialObject(label, payloadSize) {
|
||||
this.kind = 'initial';
|
||||
this.label = label;
|
||||
this.payload = `${label}-` + 'x'.repeat(payloadSize);
|
||||
this.meta = {createdAt: Date.now()};
|
||||
}
|
||||
|
||||
function NewObject(label, payloadSize) {
|
||||
this.kind = 'new';
|
||||
this.label = label;
|
||||
this.payload = `${label}-` + 'y'.repeat(payloadSize);
|
||||
this.extra = [1, 2, 3, 4, 5];
|
||||
}
|
||||
|
||||
const refs = {
|
||||
a: new InitialObject('a', 200000),
|
||||
b: new InitialObject('b', 180000),
|
||||
keep: new InitialObject('keep', 220000),
|
||||
};
|
||||
|
||||
%TakeHeapSnapshot('heap-1.heapsnapshot');
|
||||
|
||||
// Drop two refs by overwriting them with new objects.
|
||||
refs.a = new NewObject('a-replacement', 210000);
|
||||
refs.b = new NewObject('b-replacement', 190000);
|
||||
|
||||
%TakeHeapSnapshot('heap-2.heapsnapshot');
|
||||
|
||||
// Add five more NewObject refs.
|
||||
refs.c = new NewObject('c-extra', 150000);
|
||||
refs.d = new NewObject('d-extra', 160000);
|
||||
refs.e = new NewObject('e-extra', 170000);
|
||||
refs.f = new NewObject('f-extra', 180000);
|
||||
refs.g = new NewObject('g-extra', 190000);
|
||||
|
||||
%TakeHeapSnapshot('heap-3.heapsnapshot');
|
||||
|
||||
print(
|
||||
'Wrote heap-1.heapsnapshot, heap-2.heapsnapshot, and heap-3.heapsnapshot',
|
||||
);
|
||||
@@ -130,6 +130,66 @@ describe('HeapSnapshotFormatter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDiffSummary', () => {
|
||||
it('includes classes with balanced added and removed objects', () => {
|
||||
const summarized = [
|
||||
{
|
||||
className: 'Balanced',
|
||||
addedCount: 1,
|
||||
removedCount: 1,
|
||||
countDelta: 0,
|
||||
addedSize: 100,
|
||||
removedSize: 100,
|
||||
sizeDelta: 0,
|
||||
},
|
||||
];
|
||||
const result = HeapSnapshotFormatter.formatDiffSummary(summarized);
|
||||
const expected = [
|
||||
'index,className,addedCount,removedCount,countDelta,addedSize,removedSize,sizeDelta',
|
||||
`0,Balanced,1,1,0,${DevTools.I18n.ByteUtilities.formatBytesToKb(100)},${DevTools.I18n.ByteUtilities.formatBytesToKb(100)},${DevTools.I18n.ByteUtilities.formatBytesToKb(0)}`,
|
||||
].join('\n');
|
||||
|
||||
assert.strictEqual(result, expected);
|
||||
|
||||
const summarizedJson = JSON.stringify(summarized);
|
||||
assert.ok(summarizedJson);
|
||||
assert.equal(summarizedJson.includes('addedIndexes'), false);
|
||||
assert.equal(summarizedJson.includes('deletedIndexes'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDiffDetails', () => {
|
||||
it('formats detailed diffs correctly', () => {
|
||||
const details = {
|
||||
className: 'MyClass',
|
||||
addedCount: 2,
|
||||
removedCount: 1,
|
||||
countDelta: 1,
|
||||
addedSize: 120,
|
||||
removedSize: 60,
|
||||
sizeDelta: 60,
|
||||
addedIds: [101, 102],
|
||||
addedSelfSizes: [60, 60],
|
||||
deletedIds: [201],
|
||||
deletedSelfSizes: [60],
|
||||
};
|
||||
|
||||
const formatted = HeapSnapshotFormatter.formatDiffDetails(details);
|
||||
const formatted120 = DevTools.I18n.ByteUtilities.formatBytesToKb(120);
|
||||
const formatted60 = DevTools.I18n.ByteUtilities.formatBytesToKb(60);
|
||||
|
||||
const expected = [
|
||||
`MyClass: # new: 2, # deleted: 1, # delta: +1, alloc size: +${formatted120}, freed size: +${formatted60}, size delta: +${formatted60}`,
|
||||
'Objects:',
|
||||
` + @101 (self_size: ${formatted60})`,
|
||||
` + @102 (self_size: ${formatted60})`,
|
||||
` - @201 (self_size: ${formatted60})`,
|
||||
].join('\n');
|
||||
|
||||
assert.strictEqual(formatted, expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sort', () => {
|
||||
it('sorts aggregates by retained size descending', () => {
|
||||
const unsortedAggregates: Record<
|
||||
|
||||
@@ -1,3 +1,40 @@
|
||||
exports[`memory > compare_heapsnapshots_class_nodes > compare heap-1 to heap-2 with classIndex filter 1`] = `
|
||||
## Heap Snapshot Data
|
||||
### Heap Snapshot Detailed Diff
|
||||
NewObject: # new: 2, # deleted: 0, # delta: +2, alloc size: +0.1 kB, freed size: 0.0 kB, size delta: +0.1 kB
|
||||
Objects:
|
||||
+ @35179 (self_size: 0.1 kB)
|
||||
+ @35181 (self_size: 0.1 kB)
|
||||
`;
|
||||
|
||||
exports[`memory > compare_heapsnapshots_summary > compare heap-1 to heap-2 1`] = `
|
||||
## Heap Snapshot Data
|
||||
### Heap Snapshot Diff
|
||||
index,className,addedCount,removedCount,countDelta,addedSize,removedSize,sizeDelta
|
||||
0,(object shape),11,1,10,0.4 kB,0.0 kB,0.4 kB
|
||||
1,(compiled code),7,1,6,0.3 kB,0.0 kB,0.2 kB
|
||||
2,NewObject,2,0,2,0.1 kB,0.0 kB,0.1 kB
|
||||
3,(concatenated string),45,43,2,0.9 kB,0.9 kB,0.0 kB
|
||||
4,Array,2,0,2,0.0 kB,0.0 kB,0.0 kB
|
||||
5,{constructor},1,0,1,0.0 kB,0.0 kB,0.0 kB
|
||||
6,(string),4,4,0,0.1 kB,0.1 kB,0.0 kB
|
||||
7,(number),0,2,-2,0.0 kB,0.0 kB,-0.0 kB
|
||||
8,Object,0,2,-2,0.0 kB,0.0 kB,-0.0 kB
|
||||
9,InitialObject,0,2,-2,0.0 kB,0.1 kB,-0.1 kB
|
||||
`;
|
||||
|
||||
exports[`memory > compare_heapsnapshots_summary > compare heap-2 to heap-3 1`] = `
|
||||
## Heap Snapshot Data
|
||||
### Heap Snapshot Diff
|
||||
index,className,addedCount,removedCount,countDelta,addedSize,removedSize,sizeDelta
|
||||
0,(concatenated string),105,0,105,2.1 kB,0.0 kB,2.1 kB
|
||||
1,(object shape),7,1,6,0.3 kB,0.1 kB,0.3 kB
|
||||
2,(string),10,0,10,0.2 kB,0.0 kB,0.2 kB
|
||||
3,NewObject,5,0,5,0.1 kB,0.0 kB,0.1 kB
|
||||
4,Array,5,0,5,0.1 kB,0.0 kB,0.1 kB
|
||||
5,system / PropertyArray,1,0,1,0.0 kB,0.0 kB,0.0 kB
|
||||
`;
|
||||
|
||||
exports[`memory > get_heapsnapshot_class_nodes > with default options 1`] = `
|
||||
## Heap Snapshot Data
|
||||
nodeId,nodeName,type,distance,selfSize,retainedSize
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
getHeapSnapshotRetainingPaths,
|
||||
getHeapSnapshotEdges,
|
||||
getHeapSnapshotDominators,
|
||||
compareHeapSnapshotsSummary,
|
||||
compareHeapSnapshotsClassNodes,
|
||||
} from '../../src/tools/memory.js';
|
||||
import {withMcpContext} from '../utils.js';
|
||||
|
||||
@@ -361,4 +363,170 @@ describe('memory', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('compare_heapsnapshots_summary', () => {
|
||||
it('compare heap-1 to heap-2', async t => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const filePathA = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/heap-1.heapsnapshot',
|
||||
);
|
||||
const filePathB = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/heap-2.heapsnapshot',
|
||||
);
|
||||
|
||||
await compareHeapSnapshotsSummary.handler(
|
||||
{params: {baseFilePath: filePathA, currentFilePath: filePathB}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
const responseData = await response.handle(
|
||||
compareHeapSnapshotsSummary.name,
|
||||
context,
|
||||
);
|
||||
const output = responseData.content
|
||||
.map(c => (c.type === 'text' ? c.text : ''))
|
||||
.join('\n');
|
||||
|
||||
t.assert.snapshot(output);
|
||||
});
|
||||
});
|
||||
|
||||
it('compare heap-2 to heap-3', async t => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const filePathA = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/heap-2.heapsnapshot',
|
||||
);
|
||||
const filePathB = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/heap-3.heapsnapshot',
|
||||
);
|
||||
|
||||
await compareHeapSnapshotsSummary.handler(
|
||||
{params: {baseFilePath: filePathA, currentFilePath: filePathB}},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
const responseData = await response.handle(
|
||||
compareHeapSnapshotsSummary.name,
|
||||
context,
|
||||
);
|
||||
const output = responseData.content
|
||||
.map(c => (c.type === 'text' ? c.text : ''))
|
||||
.join('\n');
|
||||
|
||||
t.assert.snapshot(output);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('compare_heapsnapshots_class_nodes', () => {
|
||||
it('compare heap-1 to heap-2 with classIndex filter', async t => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const filePathA = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/heap-1.heapsnapshot',
|
||||
);
|
||||
const filePathB = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/heap-2.heapsnapshot',
|
||||
);
|
||||
|
||||
await compareHeapSnapshotsClassNodes.handler(
|
||||
{
|
||||
params: {
|
||||
baseFilePath: filePathA,
|
||||
currentFilePath: filePathB,
|
||||
classIndex: 2, // NewObject
|
||||
},
|
||||
},
|
||||
response,
|
||||
context,
|
||||
);
|
||||
|
||||
const responseData = await response.handle(
|
||||
compareHeapSnapshotsClassNodes.name,
|
||||
context,
|
||||
);
|
||||
const output = responseData.content
|
||||
.map(c => (c.type === 'text' ? c.text : ''))
|
||||
.join('\n');
|
||||
|
||||
t.assert.snapshot(output);
|
||||
});
|
||||
});
|
||||
|
||||
it('compare heap-1 to heap-2 with invalid classIndex throws error', async t => {
|
||||
await withMcpContext(async (response, context) => {
|
||||
const filePathA = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/heap-1.heapsnapshot',
|
||||
);
|
||||
const filePathB = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/heap-2.heapsnapshot',
|
||||
);
|
||||
|
||||
await t.assert.rejects(
|
||||
compareHeapSnapshotsClassNodes.handler(
|
||||
{
|
||||
params: {
|
||||
baseFilePath: filePathA,
|
||||
currentFilePath: filePathB,
|
||||
classIndex: 99,
|
||||
},
|
||||
},
|
||||
response,
|
||||
context,
|
||||
),
|
||||
/Invalid classIndex: 99. Total classes with changes: 10/,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Verifies that the caching mechanism in HeapSnapshotManager correctly
|
||||
// distinguishes comparisons when the same "current" snapshot is compared
|
||||
// against different "base" snapshots. If the cache key (diffCacheKey) is
|
||||
// not unique per base snapshot, the second comparison might incorrectly
|
||||
// return cached results from the first comparison.
|
||||
it('compares the same current snapshot against different bases', async () => {
|
||||
await withMcpContext(async (_response, context) => {
|
||||
const filePathA = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/heap-1.heapsnapshot',
|
||||
);
|
||||
const filePathB = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/heap-2.heapsnapshot',
|
||||
);
|
||||
const filePathC = join(
|
||||
process.cwd(),
|
||||
'tests/fixtures/heap-3.heapsnapshot',
|
||||
);
|
||||
|
||||
const firstDiff = await context.getHeapSnapshotClassDiffs(
|
||||
filePathA,
|
||||
filePathC,
|
||||
);
|
||||
const secondDiff = await context.getHeapSnapshotClassDiffs(
|
||||
filePathB,
|
||||
filePathC,
|
||||
);
|
||||
const firstNewObjectDiff = firstDiff.find(
|
||||
entry => entry.className === 'NewObject',
|
||||
);
|
||||
const secondNewObjectDiff = secondDiff.find(
|
||||
entry => entry.className === 'NewObject',
|
||||
);
|
||||
assert.ok(firstNewObjectDiff);
|
||||
assert.ok(secondNewObjectDiff);
|
||||
assert.equal(firstNewObjectDiff.addedCount, 7);
|
||||
assert.equal(secondNewObjectDiff.addedCount, 5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user