Files
chromedevtools--chrome-devt…/src/HeapSnapshotManager.ts
T
2026-07-13 12:30:05 +08:00

367 lines
11 KiB
TypeScript

/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
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 AggregatedInfoWithId =
WithSymbolId<DevTools.HeapSnapshotModel.HeapSnapshotModel.AggregatedInfo>;
export interface HeapSnapshotAggregateData {
aggregates: Record<string, AggregatedInfoWithId>;
objectCount: number;
totalSelfSize: number;
}
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 type DuplicateStringGroup =
DevTools.HeapSnapshotModel.HeapSnapshotModel.DuplicateStringGroup;
export class HeapSnapshotManager {
#snapshotIdGenerator = createIdGenerator();
#snapshots = new Map<
string,
{
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
// 1-indexed array where index is the class ID.
idToClassKey: string[];
classKeyToId: Map<string, number>;
}
>();
async getSnapshot(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy> {
const absolutePath = path.resolve(filePath);
const cached = this.#snapshots.get(absolutePath);
if (cached) {
return cached.snapshot;
}
const uid = this.#snapshotIdGenerator();
const {snapshot, worker} = await this.#loadSnapshot(absolutePath, uid);
this.#snapshots.set(absolutePath, {
snapshot,
worker,
idToClassKey: [''],
classKeyToId: new Map<string, number>(),
});
return snapshot;
}
async getAggregates(
filePath: string,
filterName?: string,
): Promise<HeapSnapshotAggregateData> {
const snapshot = await this.getSnapshot(filePath);
const filter =
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
if (filterName) {
filter.filterName = filterName;
}
const aggregates: Record<string, AggregatedInfoWithId> =
await snapshot.aggregatesWithFilter(filter);
let objectCount = 0;
let totalSelfSize = 0;
for (const [key, aggregate] of Object.entries(aggregates)) {
const id = await this.getOrCreateIdForClassKey(filePath, key);
aggregate[stableIdSymbol] = id;
objectCount += aggregate.count;
totalSelfSize += aggregate.self;
}
return {
aggregates,
objectCount,
totalSelfSize,
};
}
async getStats(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.Statistics> {
const snapshot = await this.getSnapshot(filePath);
return await snapshot.getStatistics();
}
async getStaticData(
filePath: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.StaticData | null> {
const snapshot = await this.getSnapshot(filePath);
return snapshot.staticData;
}
async getOrCreateIdForClassKey(
filePath: string,
classKey: string,
): Promise<number> {
const cached = this.#getCachedSnapshot(filePath);
let id = cached.classKeyToId.get(classKey);
if (!id) {
id = cached.idToClassKey.length;
cached.classKeyToId.set(classKey, id);
cached.idToClassKey.push(classKey);
}
return id;
}
async getNodesById(
filePath: string,
id: number,
filterName?: string,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
const snapshot = await this.getSnapshot(filePath);
const filter =
new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter();
if (filterName) {
filter.filterName = filterName;
}
const className = await this.resolveClassKeyFromId(filePath, id);
if (!className) {
throw new Error(`Class with ID ${id} not found in heap snapshot`);
}
const provider = snapshot.createNodesProviderForClass(className, filter);
return await provider.serializeItemsRange(0, Infinity);
}
async getRetainers(
filePath: string,
nodeId: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
const snapshot = await this.getSnapshot(filePath);
const nodeIndex = await snapshot.nodeIndexForId(nodeId);
if (nodeIndex === undefined) {
throw new Error(`Node with ID ${nodeId} not found`);
}
const provider = snapshot.createRetainingEdgesProvider(nodeIndex);
return await provider.serializeItemsRange(0, Infinity);
}
async getRetainingPaths(
filePath: string,
nodeId: number,
maxDepth?: number,
maxNodes?: number,
maxSiblings?: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.RetainingPaths> {
const snapshot = await this.getSnapshot(filePath);
const nodeIndex = await snapshot.nodeIndexForId(nodeId);
if (nodeIndex === undefined) {
throw new Error(`Node with ID ${nodeId} not found`);
}
return await snapshot.getRetainingPaths(
nodeIndex,
maxDepth,
maxNodes,
maxSiblings,
);
}
async getDominatorsOf(
filePath: string,
nodeId: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.DominatorChain> {
const snapshot = await this.getSnapshot(filePath);
const nodeIndex = await snapshot.nodeIndexForId(nodeId);
if (nodeIndex === undefined) {
throw new Error(`Node with ID ${nodeId} not found`);
}
return await snapshot.getDominatorsOf(nodeIndex);
}
async getEdges(
filePath: string,
nodeId: number,
): Promise<DevTools.HeapSnapshotModel.HeapSnapshotModel.ItemsRange> {
const snapshot = await this.getSnapshot(filePath);
const nodeIndex = await snapshot.nodeIndexForId(nodeId);
if (nodeIndex === undefined) {
throw new Error(`Node with ID ${nodeId} not found`);
}
const provider = snapshot.createEdgesProvider(nodeIndex);
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);
if (!cached) {
throw new Error(`Snapshot not loaded for ${filePath}`);
}
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,
): Promise<string | undefined> {
const cached = this.#getCachedSnapshot(filePath);
return cached.idToClassKey[id];
}
async #loadSnapshot(
absolutePath: string,
uid: number,
): Promise<{
snapshot: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy;
worker: DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy;
}> {
const workerProxy =
new DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy(
() => {
/* noop */
},
import.meta.resolve('./third_party/devtools-heap-snapshot-worker.js'),
);
const {promise: snapshotPromise, resolve: resolveSnapshot} =
Promise.withResolvers<DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotProxy>();
const loaderProxy = workerProxy.createLoader(uid, snapshotProxy => {
resolveSnapshot(snapshotProxy);
});
const fileStream = fsSync.createReadStream(absolutePath, {
encoding: 'utf-8',
highWaterMark: 1024 * 1024,
});
for await (const chunk of fileStream) {
await loaderProxy.write(chunk);
}
await loaderProxy.close();
const snapshot = await snapshotPromise;
return {snapshot, worker: workerProxy};
}
async getDuplicateStrings(filePath: string): Promise<DuplicateStringGroup[]> {
const snapshot = await this.getSnapshot(filePath);
return await snapshot.getDuplicateStrings();
}
hasSnapshots(): boolean {
return this.#snapshots.size > 0;
}
dispose(filePath: string): boolean {
const absolutePath = path.resolve(filePath);
const cached = this.#snapshots.get(absolutePath);
if (cached) {
cached.worker.dispose();
this.#snapshots.delete(absolutePath);
return true;
}
return false;
}
}