UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

270 lines • 11.7 kB
/** * @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, } from '../utils/id.js'; const VALID_EXTENSIONS = ['.heapsnapshot', '.heaptimeline']; function hasValidHeapSnapshotExtension(filePath) { return VALID_EXTENSIONS.some(ext => filePath.endsWith(ext)); } export class HeapSnapshotManager { #snapshotIdGenerator = createIdGenerator(); #snapshots = new Map(); async getSnapshot(filePath) { if (!hasValidHeapSnapshotExtension(filePath)) { throw new Error(`File ${filePath} must have a .heapsnapshot or .heaptimeline extension.`); } 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(), }); return snapshot; } async #applyNodeFilter(snapshot, filter, filterName, objectId) { if (filterName === 'attributedToSpecificNativeContext') { if (objectId === undefined) { throw new Error('objectId is required when filterName is attributedToSpecificNativeContext'); } const nodeIndex = await snapshot.nodeIndexForId(objectId); if (nodeIndex === undefined) { throw new Error(`Node with ID ${objectId} not found`); } filter.filterName = `nativeContext_${nodeIndex}`; } else if (filterName) { filter.filterName = filterName; } } async getAggregates(filePath, filterName, objectId) { const snapshot = await this.getSnapshot(filePath); const filter = new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter(); await this.#applyNodeFilter(snapshot, filter, filterName, objectId); const aggregates = 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) { const snapshot = await this.getSnapshot(filePath); return await snapshot.getStatistics(); } async getStaticData(filePath) { const snapshot = await this.getSnapshot(filePath); return snapshot.staticData; } async getNativeContextSizes(filePath) { const snapshot = await this.getSnapshot(filePath); return await snapshot.getNativeContextSizes(); } async getRetainedByContextSummary(filePath) { const snapshot = await this.getSnapshot(filePath); return await snapshot.getRetainedByContextSummary(); } async getOrCreateIdForClassKey(filePath, classKey) { 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, id, filterName, objectId) { const snapshot = await this.getSnapshot(filePath); const filter = new DevTools.HeapSnapshotModel.HeapSnapshotModel.NodeFilter(); await this.#applyNodeFilter(snapshot, filter, filterName, objectId); 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, nodeId) { 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 getObjectInfo(filePath, nodeId) { 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.getObjectInfo(nodeIndex); } async getRetainingPaths(filePath, nodeId, maxDepth, maxNodes, maxSiblings) { 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, nodeId) { 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, nodeId, options) { 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, options); return await provider.serializeItemsRange(0, Infinity); } async getClassDiffs(baseFilePath, currentFilePath) { 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, currentFilePath, classIndex) { 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) { 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, currentFilePath) { 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, id) { const cached = this.#getCachedSnapshot(filePath); return cached.idToClassKey[id]; } async #loadSnapshot(absolutePath, uid) { const workerProxy = new DevTools.HeapSnapshotModel.HeapSnapshotProxy.HeapSnapshotWorkerProxy(() => { /* noop */ }, DevTools.Common.Console.Console.instance(), import.meta.resolve('../third_party/devtools-heap-snapshot-worker.js')); try { const { promise: snapshotPromise, resolve: resolveSnapshot } = Promise.withResolvers(); 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 }; } catch (error) { // The worker is created before the read, and a failed load never reaches // the #snapshots map, so dispose()/disposeAll() can never clean it up. // Dispose it here to avoid leaking a worker on every failed load (e.g. a // missing or invalid .heapsnapshot path). workerProxy.dispose(); throw error; } } async getDuplicateStrings(filePath) { const snapshot = await this.getSnapshot(filePath); return await snapshot.getDuplicateStrings(); } async queryObjects(filePath, options) { const snapshot = await this.getSnapshot(filePath); const provider = snapshot.queryObjects(options); return await provider.serializeItemsRange(0, Infinity); } hasSnapshots() { return this.#snapshots.size > 0; } disposeSnapshot(filePath) { const absolutePath = path.resolve(filePath); const cached = this.#snapshots.get(absolutePath); if (cached) { cached.worker.dispose(); this.#snapshots.delete(absolutePath); return true; } return false; } dispose() { for (const cached of this.#snapshots.values()) { cached.worker.dispose(); } this.#snapshots.clear(); } } //# sourceMappingURL=HeapSnapshotManager.js.map