feat: Use ranges with human readable sizes (#2589)

Replace separate minimum and maximum heap-size filters with range
strings. Values can use byte counts or units, such as `1024-2048`,
`1MB-2MB`, `-1MiB`, and `1GiB-`. A bare value such as `10M` is treated
as a minimum.

The range syntax is used by `query_heapsnapshot_objects` for retained
and self sizes and by `get_heapsnapshot_edges` for retained sizes.
This commit is contained in:
Dominik Inführ
2026-08-19 13:47:13 +00:00
committed by GitHub
parent 9bd78c0374
commit 61e312f593
8 changed files with 378 additions and 81 deletions
+4 -6
View File
@@ -546,9 +546,9 @@ in the DevTools Elements panel (if any).
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
- **nodeId** (number) **(required)**: The node ID to get outgoing edges for.
- **excludePrimitives** (boolean) _(optional)_: Whether to exclude primitive target nodes. Default is true.
- **minRetainedSize** (number) _(optional)_: Minimum retained size in bytes for target nodes.
- **pageIdx** (number) _(optional)_: The page index for pagination.
- **pageSize** (number) _(optional)_: The page size for pagination.
- **retainedSize** (string) _(optional)_: Inclusive retained size range (e.g. "1MB-2MB", "-1MB", or "1MB-") for target nodes. A single value is treated as a minimum. Currently, only the lower bound is applied.
- **sortBy** (enum: "retainedSize", "selfSize", "name") _(optional)_: Sort order for edges. Default is retainedSize.
---
@@ -603,21 +603,19 @@ in the DevTools Elements panel (if any).
### `query_heapsnapshot_objects`
**Description:** Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, minRetainedSize, maxRetainedSize, minSelfSize, isDetached, sortBy). (requires flag: --memoryDebugging=true)
**Description:** Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, retainedSize, selfSize, isDetached, sortBy). (requires flag: --memoryDebugging=true)
**Parameters:**
- **filePath** (string) **(required)**: A path to a .heapsnapshot file to read.
- **className** (string) _(optional)_: Optional regex or text matching object class name.
- **isDetached** (boolean) _(optional)_: Whether to filter for detached DOM nodes.
- **maxRetainedSize** (number) _(optional)_: Maximum retained size in bytes.
- **maxSelfSize** (number) _(optional)_: Maximum self size in bytes.
- **minRetainedSize** (number) _(optional)_: Minimum retained size in bytes.
- **minSelfSize** (number) _(optional)_: Minimum self size in bytes.
- **nodeType** (string) _(optional)_: Optional V8 node type filter (e.g. object, closure, string, array, code).
- **pageIdx** (number) _(optional)_: The page index for pagination.
- **pageSize** (number) _(optional)_: The page size for pagination.
- **propertyName** (string) _(optional)_: Optional property name filter for outgoing reference edges.
- **retainedSize** (string) _(optional)_: Inclusive retained size range (e.g. "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum.
- **selfSize** (string) _(optional)_: Inclusive self size range (e.g. "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum.
- **sortBy** (enum: "retainedSize", "selfSize", "id") _(optional)_: Sort order for results. Default is retainedSize.
---
+16 -25
View File
@@ -518,10 +518,11 @@ export const commands: Commands = {
required: false,
enum: ['retainedSize', 'selfSize', 'name'],
},
minRetainedSize: {
name: 'minRetainedSize',
type: 'number',
description: 'Minimum retained size in bytes for target nodes.',
retainedSize: {
name: 'retainedSize',
type: 'string',
description:
'Inclusive retained size range (e.g. "1MB-2MB", "-1MB", or "1MB-") for target nodes. A single value is treated as a minimum. Currently, only the lower bound is applied.',
required: false,
},
excludePrimitives: {
@@ -1102,7 +1103,7 @@ export const commands: Commands = {
},
query_heapsnapshot_objects: {
description:
'Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, minRetainedSize, maxRetainedSize, minSelfSize, isDetached, sortBy). (requires flag: --memoryDebugging=true)',
'Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, retainedSize, selfSize, isDetached, sortBy). (requires flag: --memoryDebugging=true)',
category: 'Memory',
args: {
filePath: {
@@ -1131,28 +1132,18 @@ export const commands: Commands = {
'Optional V8 node type filter (e.g. object, closure, string, array, code).',
required: false,
},
minRetainedSize: {
name: 'minRetainedSize',
type: 'number',
description: 'Minimum retained size in bytes.',
retainedSize: {
name: 'retainedSize',
type: 'string',
description:
'Inclusive retained size range (e.g. "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum.',
required: false,
},
maxRetainedSize: {
name: 'maxRetainedSize',
type: 'number',
description: 'Maximum retained size in bytes.',
required: false,
},
minSelfSize: {
name: 'minSelfSize',
type: 'number',
description: 'Minimum self size in bytes.',
required: false,
},
maxSelfSize: {
name: 'maxSelfSize',
type: 'number',
description: 'Maximum self size in bytes.',
selfSize: {
name: 'selfSize',
type: 'string',
description:
'Inclusive self size range (e.g. "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum.',
required: false,
},
isDetached: {
+22 -5
View File
@@ -831,11 +831,16 @@
},
{
"name": "min_retained_size",
"argType": "number"
"argType": "number",
"isDeprecated": true
},
{
"name": "exclude_primitives",
"argType": "boolean"
},
{
"name": "retained_size_length",
"argType": "number"
}
]
},
@@ -1000,19 +1005,23 @@
},
{
"name": "min_retained_size",
"argType": "number"
"argType": "number",
"isDeprecated": true
},
{
"name": "max_retained_size",
"argType": "number"
"argType": "number",
"isDeprecated": true
},
{
"name": "min_self_size",
"argType": "number"
"argType": "number",
"isDeprecated": true
},
{
"name": "max_self_size",
"argType": "number"
"argType": "number",
"isDeprecated": true
},
{
"name": "is_detached",
@@ -1029,6 +1038,14 @@
{
"name": "page_size",
"argType": "number"
},
{
"name": "retained_size_length",
"argType": "number"
},
{
"name": "self_size_length",
"argType": "number"
}
]
}
+17 -26
View File
@@ -5,6 +5,7 @@
*/
import {zod} from '../third_party/index.js';
import {byteSizeRangeSchema} from '../utils/bytes.js';
import {ToolCategory} from './categories.js';
import {definePageTool, defineTool} from './ToolDefinition.js';
@@ -305,10 +306,9 @@ export const getHeapSnapshotEdges = defineTool({
.enum(['retainedSize', 'selfSize', 'name'])
.optional()
.describe('Sort order for edges. Default is retainedSize.'),
minRetainedSize: zod
.number()
.optional()
.describe('Minimum retained size in bytes for target nodes.'),
retainedSize: byteSizeRangeSchema(
'Inclusive retained size range (e.g. "1MB-2MB", "-1MB", or "1MB-") for target nodes. A single value is treated as a minimum. Currently, only the lower bound is applied.',
).optional(),
excludePrimitives: zod
.boolean()
.optional()
@@ -322,7 +322,8 @@ export const getHeapSnapshotEdges = defineTool({
request.params.nodeId,
{
sortBy: request.params.sortBy ?? 'retainedSize',
minRetainedSize: request.params.minRetainedSize,
// DevTools currently only supports a lower retained-size bound here.
minRetainedSize: request.params.retainedSize?.min,
excludePrimitives: request.params.excludePrimitives ?? true,
},
);
@@ -469,7 +470,7 @@ export const getHeapSnapshotObjectDetails = defineTool({
export const queryHeapSnapshotObjects = defineTool({
name: 'query_heapsnapshot_objects',
description:
'Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, minRetainedSize, maxRetainedSize, minSelfSize, isDetached, sortBy).',
'Loads a memory heapsnapshot and queries objects matching specific filters (className, propertyName, nodeType, retainedSize, selfSize, isDetached, sortBy).',
annotations: {
category: ToolCategory.MEMORY,
readOnlyHint: true,
@@ -493,22 +494,12 @@ export const queryHeapSnapshotObjects = defineTool({
.describe(
'Optional V8 node type filter (e.g. object, closure, string, array, code).',
),
minRetainedSize: zod
.number()
.optional()
.describe('Minimum retained size in bytes.'),
maxRetainedSize: zod
.number()
.optional()
.describe('Maximum retained size in bytes.'),
minSelfSize: zod
.number()
.optional()
.describe('Minimum self size in bytes.'),
maxSelfSize: zod
.number()
.optional()
.describe('Maximum self size in bytes.'),
retainedSize: byteSizeRangeSchema(
'Inclusive retained size range (e.g. "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum.',
).optional(),
selfSize: byteSizeRangeSchema(
'Inclusive self size range (e.g. "1MB-2MB", "-1MB", or "1MB-"). A single value is treated as a minimum.',
).optional(),
isDetached: zod
.boolean()
.optional()
@@ -527,10 +518,10 @@ export const queryHeapSnapshotObjects = defineTool({
className: request.params.className,
propertyName: request.params.propertyName,
nodeType: request.params.nodeType,
minRetainedSize: request.params.minRetainedSize,
maxRetainedSize: request.params.maxRetainedSize,
minSelfSize: request.params.minSelfSize,
maxSelfSize: request.params.maxSelfSize,
minRetainedSize: request.params.retainedSize?.min,
maxRetainedSize: request.params.retainedSize?.max,
minSelfSize: request.params.selfSize?.min,
maxSelfSize: request.params.selfSize?.max,
isDetached: request.params.isDetached,
sortBy: request.params.sortBy,
},
+134
View File
@@ -0,0 +1,134 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {zod} from '../third_party/index.js';
const BYTE_UNITS: Readonly<Record<string, number>> = {
b: 1,
byte: 1,
bytes: 1,
k: 1000,
kb: 1000,
kib: 1024,
m: 1000 * 1000,
mb: 1000 * 1000,
mib: 1024 * 1024,
g: 1000 * 1000 * 1000,
gb: 1000 * 1000 * 1000,
gib: 1024 * 1024 * 1024,
t: 1000 * 1000 * 1000 * 1000,
tb: 1000 * 1000 * 1000 * 1000,
tib: 1024 * 1024 * 1024 * 1024,
};
/**
* Parses a byte size string (e.g. "1M", "1MB", "500KB", "1.5GB", "1024") into bytes.
*/
export function parseByteSize(value: string): number {
const trimmed = value.trim();
if (trimmed === '') {
throw new Error(`Invalid byte size: "${value}"`);
}
const match = trimmed.match(/^(\d+(?:\.\d+)?)\s*([a-zA-Z]+)?$/);
if (!match) {
throw new Error(
`Invalid byte size format: "${value}". Expected a number or format like "1024", "1M", "1MB", "1G", "1GB".`,
);
}
const numMatch = match[1];
if (numMatch === undefined) {
throw new Error(`Invalid byte size: "${value}"`);
}
const num = Number(numMatch);
if (!Number.isFinite(num) || num < 0) {
throw new Error(`Invalid byte size: "${value}"`);
}
const unitMatch = match[2];
if (unitMatch === undefined) {
return Math.round(num);
}
const unit = unitMatch.toLowerCase();
const multiplier = BYTE_UNITS[unit];
if (multiplier === undefined) {
throw new Error(
`Unknown unit "${unitMatch}" in "${value}". Supported units: B, KB, KiB, MB, MiB, GB, GiB, TB, TiB.`,
);
}
const bytes = Math.round(num * multiplier);
if (!Number.isFinite(bytes)) {
throw new Error(`Invalid byte size: "${value}"`);
}
return bytes;
}
export interface ByteSizeRange {
min: number;
max?: number;
}
/**
* Parses an inclusive byte-size range (e.g. "1MB", "1MB-2MB", "-1MB", "1MB-").
*/
export function parseByteSizeRange(value: string): ByteSizeRange {
const trimmed = value.trim();
if (!trimmed.includes('-')) {
return {min: parseByteSize(trimmed), max: undefined};
}
const parts = trimmed.split('-');
if (parts.length !== 2) {
throw new Error(
`Invalid byte size range: "${value}". Expected a size or range like "1MB", "1MB-2MB", "-1MB", or "1MB-".`,
);
}
const minValue = parts[0];
const maxValue = parts[1];
if (minValue === undefined || maxValue === undefined) {
throw new Error(`Invalid byte size range: "${value}"`);
}
const minText = minValue.trim();
const maxText = maxValue.trim();
if (minText === '' && maxText === '') {
throw new Error(
`Invalid byte size range: "${value}". At least one bound is required.`,
);
}
const min = minText === '' ? 0 : parseByteSize(minText);
const max = maxText === '' ? undefined : parseByteSize(maxText);
if (max !== undefined && min > max) {
throw new Error(
`Invalid byte size range: "${value}". The lower bound must not exceed the upper bound.`,
);
}
return {min, max};
}
export function byteSizeRangeSchema(description: string) {
return zod
.string()
.transform((value, context) => {
try {
return parseByteSizeRange(value);
} catch (error) {
context.addIssue({
code: zod.ZodIssueCode.custom,
message:
error instanceof Error ? error.message : 'Invalid byte size range',
});
return zod.NEVER;
}
})
.describe(description);
}
+26 -17
View File
@@ -302,6 +302,15 @@ Showing 1-2 of 56 (Page 1 of 28).
Next page: 1
`;
exports[`memory > get_heapsnapshot_edges > with retainedSize range 1`] = `
## Heap Snapshot Data
name,type,nodeId,nodeName,selfSize,retainedSize
map,internal,25577,system / Map,0.0 kB,0.7 kB
constructor,property,25575,String,0.1 kB,0.4 kB
__proto__,property,25329,{constructor, __defineGetter__, __defineSetter__, …, get __proto__, set __proto__, toLocaleString},0.1 kB,0.3 kB
Showing 1-3 of 3 (Page 1 of 1).
`;
exports[`memory > get_heapsnapshot_edges > with valid nodeId 1`] = `
## Heap Snapshot Data
name,type,nodeId,nodeName,selfSize,retainedSize
@@ -450,6 +459,23 @@ Not retained by context size: 798 kB (11792 objects)
Total size: 802 kB
`;
exports[`memory > query_heapsnapshot_objects > with an unbounded retainedSize filter 1`] = `
## Heap Snapshot Data
nodeId,nodeName,type,distance,selfSize,retainedSize
1,,synthetic,100000000,0.0 kB,802 kB
7249,system / NativeContext / https://example.com,hidden,1,1.2 kB,350 kB
7199,system / NativeContext,hidden,1,1.2 kB,84.4 kB
7307,system / NativeContext,hidden,1,1.2 kB,84.4 kB
7195,system / NativeContext / https://example.com,hidden,1,1.2 kB,60.1 kB
27635,Window (global*) / https://example.com,object,2,40.5 kB,51.0 kB
30887,Window (global*) / https://example.com,object,2,40.5 kB,50.9 kB
3,(GC roots),synthetic,100000001,0.0 kB,45.6 kB
49547,,array,2,4.1 kB,41.0 kB
36241,,array,2,4.1 kB,41.0 kB
Showing 1-10 of 148 (Page 1 of 15).
Next page: 1
`;
exports[`memory > query_heapsnapshot_objects > with className filter 1`] = `
## Heap Snapshot Data
nodeId,nodeName,type,distance,selfSize,retainedSize
@@ -484,23 +510,6 @@ Showing 1-10 of 27466 (Page 1 of 2747).
Next page: 1
`;
exports[`memory > query_heapsnapshot_objects > with minRetainedSize filter 1`] = `
## Heap Snapshot Data
nodeId,nodeName,type,distance,selfSize,retainedSize
1,,synthetic,100000000,0.0 kB,802 kB
7249,system / NativeContext / https://example.com,hidden,1,1.2 kB,350 kB
7199,system / NativeContext,hidden,1,1.2 kB,84.4 kB
7307,system / NativeContext,hidden,1,1.2 kB,84.4 kB
7195,system / NativeContext / https://example.com,hidden,1,1.2 kB,60.1 kB
27635,Window (global*) / https://example.com,object,2,40.5 kB,51.0 kB
30887,Window (global*) / https://example.com,object,2,40.5 kB,50.9 kB
3,(GC roots),synthetic,100000001,0.0 kB,45.6 kB
49547,,array,2,4.1 kB,41.0 kB
36241,,array,2,4.1 kB,41.0 kB
Showing 1-10 of 148 (Page 1 of 15).
Next page: 1
`;
exports[`memory > query_heapsnapshot_objects > with sortBy selfSize and pagination 1`] = `
## Heap Snapshot Data
nodeId,nodeName,type,distance,selfSize,retainedSize
+37 -2
View File
@@ -26,6 +26,7 @@ import {
getHeapSnapshotObjectDetails,
queryHeapSnapshotObjects,
} from '../../src/tools/memory.js';
import {parseByteSizeRange} from '../../src/utils/bytes.js';
import {stableIdSymbol} from '../../src/utils/id.js';
import {resolveCanonicalPath} from '../../src/utils/files.js';
import {withMcpContext} from '../utils.js';
@@ -447,6 +448,34 @@ describe('memory', () => {
t.assert.snapshot(output);
});
});
it('with retainedSize range', async t => {
await withMcpContext(async (response, context) => {
const filePath = join(
process.cwd(),
'tests/fixtures/example.heapsnapshot',
);
await getHeapSnapshotEdges.handler(
{
params: {
filePath,
nodeId: 25341,
retainedSize: parseByteSizeRange('100B-100B'),
},
},
response,
context,
);
const responseData = await response.handle(context);
const output = responseData.content
.map(c => (c.type === 'text' ? c.text : ''))
.join('\n');
t.assert.snapshot(output);
});
});
});
describe('get_heapsnapshot_dominators', () => {
@@ -697,7 +726,7 @@ describe('memory', () => {
});
});
it('with minRetainedSize filter', async t => {
it('with an unbounded retainedSize filter', async t => {
await withMcpContext(async (response, context) => {
const filePath = join(
process.cwd(),
@@ -705,7 +734,13 @@ describe('memory', () => {
);
await queryHeapSnapshotObjects.handler(
{params: {filePath, minRetainedSize: 1000, pageSize: 10}},
{
params: {
filePath,
retainedSize: parseByteSizeRange('1KB'),
pageSize: 10,
},
},
response,
context,
);
+122
View File
@@ -0,0 +1,122 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {describe, it} from 'node:test';
import {
byteSizeRangeSchema,
parseByteSize,
parseByteSizeRange,
} from '../../src/utils/bytes.js';
describe('parseByteSize', () => {
it('should parse plain numeric strings', () => {
assert.strictEqual(parseByteSize('0'), 0);
assert.strictEqual(parseByteSize('1024'), 1024);
assert.strictEqual(parseByteSize(' 1048576 '), 1048576);
});
it('should parse bytes units', () => {
assert.strictEqual(parseByteSize('100B'), 100);
assert.strictEqual(parseByteSize('100b'), 100);
assert.strictEqual(parseByteSize('100 Bytes'), 100);
assert.strictEqual(parseByteSize('100 byte'), 100);
});
it('should parse kilobytes units', () => {
assert.strictEqual(parseByteSize('1K'), 1000);
assert.strictEqual(parseByteSize('1k'), 1000);
assert.strictEqual(parseByteSize('1KB'), 1000);
assert.strictEqual(parseByteSize('1kb'), 1000);
assert.strictEqual(parseByteSize('1KiB'), 1024);
assert.strictEqual(parseByteSize('1.5 KB'), 1500);
});
it('should parse megabytes units', () => {
assert.strictEqual(parseByteSize('1M'), 1000000);
assert.strictEqual(parseByteSize('1m'), 1000000);
assert.strictEqual(parseByteSize('1MB'), 1000000);
assert.strictEqual(parseByteSize('1mb'), 1000000);
assert.strictEqual(parseByteSize('1MiB'), 1048576);
assert.strictEqual(parseByteSize('2.5MB'), 2500000);
});
it('should parse gigabytes units', () => {
assert.strictEqual(parseByteSize('1G'), 1000000000);
assert.strictEqual(parseByteSize('1g'), 1000000000);
assert.strictEqual(parseByteSize('1GB'), 1000000000);
assert.strictEqual(parseByteSize('1gb'), 1000000000);
assert.strictEqual(parseByteSize('1GiB'), 1073741824);
assert.strictEqual(parseByteSize('0.5GB'), 500000000);
});
it('should parse terabytes units', () => {
assert.strictEqual(parseByteSize('1T'), 1000000000000);
assert.strictEqual(parseByteSize('1TB'), 1000000000000);
assert.strictEqual(parseByteSize('1TiB'), 1099511627776);
});
it('should throw for invalid inputs', () => {
assert.throws(() => parseByteSize(''), /Invalid byte size/);
assert.throws(() => parseByteSize(' '), /Invalid byte size/);
assert.throws(() => parseByteSize('abc'), /Invalid byte size/);
assert.throws(() => parseByteSize('10XYZ'), /Unknown unit/);
assert.throws(() => parseByteSize('-10MB'), /Invalid byte size/);
assert.throws(() => parseByteSize(`${'9'.repeat(300)}TB`), /Invalid byte/);
});
it('should parse inclusive byte-size ranges', () => {
assert.deepStrictEqual(parseByteSizeRange('10M'), {
min: 10 * 1000 * 1000,
max: undefined,
});
assert.deepStrictEqual(parseByteSizeRange('1KB-2MiB'), {
min: 1000,
max: 2 * 1024 * 1024,
});
assert.deepStrictEqual(parseByteSizeRange('-1MB'), {
min: 0,
max: 1000 * 1000,
});
assert.deepStrictEqual(parseByteSizeRange('1MiB-'), {
min: 1024 * 1024,
max: undefined,
});
assert.deepStrictEqual(parseByteSizeRange(' 1 KB - 2 MB '), {
min: 1000,
max: 2 * 1000 * 1000,
});
});
it('should reject invalid byte-size ranges', () => {
assert.throws(() => parseByteSizeRange(''), /Invalid byte size/);
assert.throws(() => parseByteSizeRange('-'), /At least one bound/);
assert.throws(() => parseByteSizeRange('2MB-1MB'), /lower bound/);
assert.throws(() => parseByteSizeRange('1MB-2MB-3MB'), /Invalid byte/);
assert.throws(() => parseByteSizeRange('invalid-1MB'), /Invalid byte/);
});
});
describe('byteSizeRangeSchema', () => {
it('parses valid byte-size range string', () => {
const schema = byteSizeRangeSchema('test');
const result = schema.safeParse('1KB-2MB');
assert.strictEqual(result.success, true);
if (result.success) {
assert.deepStrictEqual(result.data, {
min: 1000,
max: 2 * 1000 * 1000,
});
}
});
it('fails for invalid byte-size range string', () => {
const schema = byteSizeRangeSchema('test');
const result = schema.safeParse('2MB-1MB');
assert.strictEqual(result.success, false);
});
});