367 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|