Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
17 changes: 16 additions & 1 deletion docs/tool-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@
- [`take_snapshot`](#take_snapshot)
- [`screencast_start`](#screencast_start)
- [`screencast_stop`](#screencast_stop)
- **[Memory](#memory)** (13 tools)
- **[Memory](#memory)** (14 tools)
- [`take_heapsnapshot`](#take_heapsnapshot)
- [`analyze_heapsnapshot_contexts`](#analyze_heapsnapshot_contexts)
- [`close_heapsnapshot`](#close_heapsnapshot)
- [`compare_heapsnapshots`](#compare_heapsnapshots)
- [`get_heapsnapshot_class_nodes`](#get_heapsnapshot_class_nodes)
Expand Down Expand Up @@ -507,6 +508,20 @@ in the DevTools Elements panel (if any).

---

### `analyze_heapsnapshot_contexts`

**Description:** Loads a memory heapsnapshot to identify and rank closure contexts holding dead captured fields—variables no remaining live closure can read. Scopes are ranked globally by the retained size of these dead values to provide a prioritizing heuristic, rather than an exact measure of reclaimable bytes. (requires flag: --memoryDebugging=true)

**Parameters:**

- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
- **pageIdx** (integer) _(optional)_: The zero-based page index. Defaults to 0.
- **pageSize** (integer) _(optional)_: The number of contexts to return per page. Defaults to 20.
- **retainedSize** (string) _(optional)_: Inclusive range for the dead-field score of a context (e.g. "10KB", "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum.
- **scopeInfoNodeId** (integer) _(optional)_: Only return contexts declared by the scope with this ScopeInfo node id, as reported in the scope header of a previous call.

---

### `close_heapsnapshot`

**Description:** Closes a previously loaded memory heapsnapshot, freeing its memory. (requires flag: --memoryDebugging=true)
Expand Down
6 changes: 6 additions & 0 deletions src/McpContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,12 @@ export class McpContext implements Context {
return await this.#heapSnapshotManager.getObjectInfo(filePath, nodeId);
}

async analyzeHeapSnapshotContexts(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ContextAnalysisResult> {
return await this.#heapSnapshotManager.analyzeContexts(filePath);
}

async closeHeapSnapshot(filePath: string): Promise<boolean> {
return this.#heapSnapshotManager.disposeSnapshot(filePath);
}
Expand Down
51 changes: 50 additions & 1 deletion src/McpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import {
resolveContainerQueries,
} from './formatters/CssFormatter.js';
import {
collectRankedContexts,
type ContextAnalysisReport,
type ContextFilterOptions,
HeapSnapshotFormatter,
isEdgeLike,
isNodeLike,
Expand Down Expand Up @@ -78,6 +81,9 @@ interface TraceInsightData {
insightName: InsightName;
}

interface ContextAnalysisOptions
extends PaginationOptions, ContextFilterOptions {}

export class McpResponse implements Response {
#includePages = false;
#includeExtensionServiceWorkers = false;
Expand Down Expand Up @@ -109,6 +115,8 @@ export class McpResponse implements Response {
detailedClassDiff?: HeapSnapshotDetailedClassDiff;
duplicateStrings?: DuplicateStringGroup[];
objectInfo?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ObjectInfo;
contextAnalysis?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ContextAnalysisResult;
contextAnalysisOptions?: ContextAnalysisOptions;
};
#networkRequestsOptions?: {
include: boolean;
Expand Down Expand Up @@ -461,6 +469,18 @@ export class McpResponse implements Response {
};
}

setHeapSnapshotContextAnalysis(
contextAnalysis: DevTools.HeapSnapshotModel.HeapSnapshotModel.ContextAnalysisResult,
options?: ContextAnalysisOptions,
) {
this.#heapSnapshotOptions = {
...this.#heapSnapshotOptions,
include: true,
contextAnalysis,
contextAnalysisOptions: options,
};
}

attachImage(value: ImageContentData): void {
this.#images.push(value);
}
Expand Down Expand Up @@ -857,6 +877,7 @@ export class McpResponse implements Response {
heapSnapshotDetailedClassDiff?: HeapSnapshotDetailedClassDiff;
heapSnapshotDuplicateStrings?: readonly DuplicateStringGroup[];
heapSnapshotObjectDetails?: DevTools.HeapSnapshotModel.HeapSnapshotModel.ObjectInfo;
heapSnapshotContextAnalysis?: ContextAnalysisReport;
extensionServiceWorkers?: object[];
extensionPages?: object[];
comments?: StructuredCommentThread[];
Expand Down Expand Up @@ -1316,6 +1337,33 @@ Call ${handleDialog(this.#args).name} to handle it before continuing.`);
);
structuredContent.heapSnapshotObjectDetails = objectInfo;
}
const contextAnalysis = this.#heapSnapshotOptions.contextAnalysis;
if (contextAnalysis) {
const contextAnalysisOptions =
this.#heapSnapshotOptions.contextAnalysisOptions;
const rankedContexts = collectRankedContexts(contextAnalysis, {
retainedSize: contextAnalysisOptions?.retainedSize,
scopeInfoNodeId: contextAnalysisOptions?.scopeInfoNodeId,
});
const paginationData = this.#dataWithPagination(rankedContexts, {
pageIdx: contextAnalysisOptions?.pageIdx ?? 0,
pageSize: contextAnalysisOptions?.pageSize,
});
const report: ContextAnalysisReport = {
contexts: paginationData.items,
scriptsWithoutScopes: contextAnalysis.scriptsWithoutScopes,
};

response.push('### Context Analysis');
structuredContent.pagination = paginationData.pagination;
response.push(...paginationData.info);
response.push(
compactEncode
? compactEncode(report)
: HeapSnapshotFormatter.formatContextAnalysis(report),
);
structuredContent.heapSnapshotContextAnalysis = report;
}
}

if (data.detailedNetworkRequest) {
Expand Down Expand Up @@ -1536,8 +1584,9 @@ Call ${handleDialog(this.#args).name} to handle it before continuing.`);
}

const {startIndex, endIndex, currentPage, totalPages} = paginationResult;
const displayStartIndex = data.length === 0 ? 0 : startIndex + 1;
response.push(
`Showing ${startIndex + 1}-${endIndex} of ${data.length} (Page ${currentPage + 1} of ${totalPages}).`,
`Showing ${displayStartIndex}-${endIndex} of ${data.length} (Page ${currentPage + 1} of ${totalPages}).`,
);
if (pagination) {
if (paginationResult.hasNextPage) {
Expand Down
40 changes: 40 additions & 0 deletions src/config/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,46 @@ export type Commands = Record<
}
>;
export const commands: Commands = {
analyze_heapsnapshot_contexts: {
description:
'Loads a memory heapsnapshot to identify and rank closure contexts holding dead captured fields—variables no remaining live closure can read. Scopes are ranked globally by the retained size of these dead values to provide a prioritizing heuristic, rather than an exact measure of reclaimable bytes. (requires flag: --memoryDebugging=true)',
category: 'Memory',
args: {
filePath: {
name: 'filePath',
type: 'string',
description: 'A path to a .heapsnapshot file to read.',
required: true,
},
retainedSize: {
name: 'retainedSize',
type: 'string',
description:
'Inclusive range for the dead-field score of a context (e.g. "10KB", "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum.',
required: false,
},
scopeInfoNodeId: {
name: 'scopeInfoNodeId',
type: 'integer',
description:
'Only return contexts declared by the scope with this ScopeInfo node id, as reported in the scope header of a previous call.',
required: false,
},
pageIdx: {
name: 'pageIdx',
type: 'integer',
description: 'The zero-based page index. Defaults to 0.',
required: false,
},
pageSize: {
name: 'pageSize',
type: 'integer',
description:
'The number of contexts to return per page. Defaults to 20.',
required: false,
},
},
},
click: {
description: 'Clicks on the provided element',
category: 'Input automation',
Expand Down
97 changes: 97 additions & 0 deletions src/formatters/HeapSnapshotFormatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
DuplicateStringGroup,
} from '../processors/HeapSnapshotManager.js';
import {DevTools} from '../third_party/index.js';
import type {ByteSizeRange} from '../utils/bytes.js';
import {stableIdSymbol} from '../utils/id.js';

const {formatBytesToKb} = DevTools.I18n.ByteUtilities;
Expand All @@ -23,6 +24,57 @@ export interface FormattedSnapshotEntry {
retainedSize: string;
}

export interface ContextAnalysisReport {
contexts: readonly ScopedContext[];
scriptsWithoutScopes: readonly DevTools.HeapSnapshotModel.HeapSnapshotModel.ScriptWithoutScopes[];
}

/** A context paired with the scope that declares it. */
export interface ScopedContext {
scope: DevTools.HeapSnapshotModel.HeapSnapshotModel.ScopeAnalysis;
context: DevTools.HeapSnapshotModel.HeapSnapshotModel.ContextAnalysis;
}

export interface ContextFilterOptions {
/** Inclusive dead-field retained-size score range a context must fall into. */
retainedSize?: ByteSizeRange;
/** Restricts the result to contexts declared by this scope. */
scopeInfoNodeId?: number;
}

/**
* Flattens the analysis into the contexts matching `options`, ranked descending
* by their dead-field retained-size score across all scopes.
*/
export function collectRankedContexts(
analysis: DevTools.HeapSnapshotModel.HeapSnapshotModel.ContextAnalysisResult,
options: ContextFilterOptions = {},
): ScopedContext[] {
const {min = 0, max} = options.retainedSize ?? {};
const contexts: ScopedContext[] = [];
for (const scope of analysis.scopes) {
if (
options.scopeInfoNodeId !== undefined &&
scope.scopeInfoNodeId !== options.scopeInfoNodeId
) {
continue;
}
for (const context of scope.contexts) {
const score = context.deadFieldsRetainedSizeSum;
if (score < min || (max !== undefined && score > max)) {
continue;
}
contexts.push({scope, context});
}
}
contexts.sort(
(left, right) =>
right.context.deadFieldsRetainedSizeSum -
left.context.deadFieldsRetainedSizeSum,
);
return contexts;
}

export function isNodeLike(
item: unknown,
): item is DevTools.HeapSnapshotModel.HeapSnapshotModel.Node {
Expand Down Expand Up @@ -275,6 +327,51 @@ export class HeapSnapshotFormatter {
];
return lines.join('\n');
}

static formatContextAnalysis(report: ContextAnalysisReport): string {
const lines: string[] = [];

if (report.contexts.length === 0) {
lines.push('No live contexts with dead fields were found.');
}

for (const {scope, context} of report.contexts) {
if (lines.length > 0) {
lines.push('');
}
lines.push(
`#### Context @${context.contextNodeId} in \`${formatScopeName(scope)}\` — ${formatBytesToKb(context.deadFieldsRetainedSizeSum)}`,
`Script @${scope.scriptNodeId}, ScopeInfo @${scope.scopeInfoNodeId}, offsets ${scope.scopeStart}-${scope.scopeEnd}, ${formatCount(scope.contextFieldCount, 'context field')}`,
);
for (const field of context.deadFields) {
lines.push(
`- \`${field.name}\` (${field.valueName} @${field.valueNodeId}) — ${formatBytesToKb(field.retainedSize)}`,
);
}
}

if (report.scriptsWithoutScopes.length > 0) {
lines.push('', 'Scripts without scope metadata could not be analyzed:');
for (const script of report.scriptsWithoutScopes) {
lines.push(
`- \`${script.scriptName || `script @${script.scriptNodeId}`}\` (@${script.scriptNodeId}) — ${formatCount(script.contextCount, 'context')}`,
);
}
}

return lines.join('\n');
}
}

function formatCount(count: number, singular: string): string {
return `${count} ${singular}${count === 1 ? '' : 's'}`;
}

function formatScopeName(
scope: DevTools.HeapSnapshotModel.HeapSnapshotModel.ScopeAnalysis,
): string {
const scriptName = scope.scriptName || `script @${scope.scriptNodeId}`;
return scope.scopeName ? `${scope.scopeName} (${scriptName})` : scriptName;
}

function formatDOMLinkState(
Expand Down
7 changes: 7 additions & 0 deletions src/processors/HeapSnapshotManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,13 @@ export class HeapSnapshotManager {
return await snapshot.getObjectInfo(nodeIndex);
}

async analyzeContexts(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ContextAnalysisResult> {
const snapshot = await this.getSnapshot(filePath);
return await snapshot.analyzeContexts();
}

async getRetainingPaths(
filePath: string,
nodeId: number,
Expand Down
25 changes: 25 additions & 0 deletions src/telemetry/tool_call_metrics.json
Original file line number Diff line number Diff line change
Expand Up @@ -1197,5 +1197,30 @@
"argType": "number"
}
]
},
{
"name": "analyze_heapsnapshot_contexts",
"args": [
{
"name": "file_path_length",
"argType": "number"
},
{
"name": "retained_size_length",
"argType": "number"
},
{
"name": "scope_info_node_id",
"argType": "number"
},
{
"name": "page_idx",
"argType": "number"
},
{
"name": "page_size",
"argType": "number"
}
]
}
]
8 changes: 8 additions & 0 deletions src/tools/ToolDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
import type {McpPage} from '../McpPage.js';
import type {DevToolsCommentBridge} from '../devtools/DevToolsCommentBridge.js';
import type {CssFormatterOptions} from '../formatters/CssFormatter.js';
import type {ContextFilterOptions} from '../formatters/HeapSnapshotFormatter.js';
import {zod} from '../third_party/index.js';
import type {
Dialog,
Expand Down Expand Up @@ -180,6 +181,10 @@ export interface Response {
setHeapSnapshotObjectDetails(
objectInfo: DevTools.HeapSnapshotModel.HeapSnapshotModel.ObjectInfo,
): void;
setHeapSnapshotContextAnalysis(
analysis: DevTools.HeapSnapshotModel.HeapSnapshotModel.ContextAnalysisResult,
options?: PaginationOptions & ContextFilterOptions,
): void;
setIncludePages(value: boolean): void;
setIncludeNetworkRequests(
value: boolean,
Expand Down Expand Up @@ -320,6 +325,9 @@ export type Context = Readonly<{
filePath: string,
nodeId: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ObjectInfo>;
analyzeHeapSnapshotContexts(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ContextAnalysisResult>;
closeHeapSnapshot(filePath: string): Promise<boolean>;
getHeapSnapshotRetainingPaths(
filePath: string,
Expand Down
Loading
Loading