From a7aa5f1826847af3b94ab81895a2b9a8f4348707 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:37:02 +0000 Subject: [PATCH] heap-snapshot: keep node ids stable across v8 snapshots Bun.generateHeapSnapshot("v8"), v8.writeHeapSnapshot(), v8.getHeapSnapshot() and Worker#getHeapSnapshot() all called heapProfiler.clearSnapshots() before constructing BunV8HeapSnapshotBuilder. That nulls m_previousSnapshot and defeats the id-reuse logic the builder already implements: every surviving object got a fresh id on every snapshot, so DevTools' Comparison view and memlab saw the whole heap as freed-and-reallocated between any two snapshots. Drop the clearSnapshots() calls so the builder can look up surviving cells in the previous snapshot. Heap::removeDeadHeapSnapshotNodes() already prunes dead entries from the retained chain on every GC. --- src/jsc/bindings/BunHeapProfiler.cpp | 1 - src/jsc/bindings/BunObject.cpp | 1 - src/jsc/bindings/webcore/JSWorker.cpp | 1 - test/js/bun/util/v8-heap-snapshot-fixture.ts | 45 ++++++++++++++++++++ test/js/bun/util/v8-heap-snapshot.test.ts | 5 +++ 5 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/jsc/bindings/BunHeapProfiler.cpp b/src/jsc/bindings/BunHeapProfiler.cpp index 2eadd8070e8d..400511158507 100644 --- a/src/jsc/bindings/BunHeapProfiler.cpp +++ b/src/jsc/bindings/BunHeapProfiler.cpp @@ -940,7 +940,6 @@ WTF::String generateHeapSnapshotV8(JSC::VM& vm) { vm.ensureHeapProfiler(); auto& heapProfiler = *vm.heapProfiler(); - heapProfiler.clearSnapshots(); JSC::BunV8HeapSnapshotBuilder builder(heapProfiler); return builder.json(); diff --git a/src/jsc/bindings/BunObject.cpp b/src/jsc/bindings/BunObject.cpp index 02a1127e00df..21bccdaaf79b 100644 --- a/src/jsc/bindings/BunObject.cpp +++ b/src/jsc/bindings/BunObject.cpp @@ -778,7 +778,6 @@ JSC_DEFINE_HOST_FUNCTION(functionGenerateHeapSnapshot, (JSC::JSGlobalObject * gl auto& vm = JSC::getVM(globalObject); vm.ensureHeapProfiler(); auto& heapProfiler = *vm.heapProfiler(); - heapProfiler.clearSnapshots(); Bun__Feature__heap_snapshot += 1; diff --git a/src/jsc/bindings/webcore/JSWorker.cpp b/src/jsc/bindings/webcore/JSWorker.cpp index 50a94a365c2b..25ac25edc797 100644 --- a/src/jsc/bindings/webcore/JSWorker.cpp +++ b/src/jsc/bindings/webcore/JSWorker.cpp @@ -741,7 +741,6 @@ static inline JSC::EncodedJSValue jsWorkerPrototypeFunction_getHeapSnapshotBody( auto& vm = workerCtx.vm(); vm.ensureHeapProfiler(); auto& heapProfiler = *vm.heapProfiler(); - heapProfiler.clearSnapshots(); JSC::BunV8HeapSnapshotBuilder builder(heapProfiler); String snapshot = builder.json(); diff --git a/test/js/bun/util/v8-heap-snapshot-fixture.ts b/test/js/bun/util/v8-heap-snapshot-fixture.ts index 99d04eecec4b..f32651504c7b 100644 --- a/test/js/bun/util/v8-heap-snapshot-fixture.ts +++ b/test/js/bun/util/v8-heap-snapshot-fixture.ts @@ -103,6 +103,51 @@ switch (mode) { break; } + case "id-stability": { + // DevTools' Comparison view and memlab key on the V8 contract that a + // surviving object keeps the same node id across snapshots. The tagged + // object is held on globalThis so it outlives both snapshots and is easy to + // find via its property edge. + globalThis.__ID_STABILITY_TAG__ = { marker: "id-stability-probe" }; + + const findTagId = (text: string) => { + const j = JSON.parse(text); + const m = j.snapshot.meta; + const nodeStride = m.node_fields.length; + const edgeStride = m.edge_fields.length; + const idIdx = m.node_fields.indexOf("id"); + const edgeCountIdx = m.node_fields.indexOf("edge_count"); + const edgeTypeIdx = m.edge_fields.indexOf("type"); + const edgeNameIdx = m.edge_fields.indexOf("name_or_index"); + const toNodeIdx = m.edge_fields.indexOf("to_node"); + const propertyType = m.edge_types[edgeTypeIdx].indexOf("property"); + const tagString = j.strings.indexOf("__ID_STABILITY_TAG__"); + if (tagString === -1) return null; + + let e = 0; + for (let n = 0; n < j.nodes.length; n += nodeStride) { + const edgeCount = j.nodes[n + edgeCountIdx]; + for (let k = 0; k < edgeCount; k++) { + const base = e + k * edgeStride; + if (j.edges[base + edgeTypeIdx] === propertyType && j.edges[base + edgeNameIdx] === tagString) { + return j.nodes[j.edges[base + toNodeIdx] + idIdx]; + } + } + e += edgeCount * edgeStride; + } + return null; + }; + + const id1 = findTagId(Bun.generateHeapSnapshot("v8")); + // Unrelated allocation between snapshots so the second one is not just a + // byte-for-byte replay of the first. + globalThis.__pad__ = Array.from({ length: 1000 }, (_, i) => ({ i })); + const id2 = findTagId(new TextDecoder().decode(Bun.generateHeapSnapshot("v8", "arraybuffer"))); + + result = { id1, id2, found: id1 !== null, stable: id1 === id2 }; + break; + } + case "stream-edges": { // Build a graph touching the main Web Streams cell classes and keep every // handle live across the snapshot so analyzeHeap can see the edges. diff --git a/test/js/bun/util/v8-heap-snapshot.test.ts b/test/js/bun/util/v8-heap-snapshot.test.ts index 8141ce96d21a..b0dae1cf3cf9 100644 --- a/test/js/bun/util/v8-heap-snapshot.test.ts +++ b/test/js/bun/util/v8-heap-snapshot.test.ts @@ -89,6 +89,11 @@ test("v8.writeHeapSnapshot() with path", async () => { expect(await runFixture("write-path", { args: [path] })).toEqual({ returnedPath: path, ...structure }); }); +test("v8 heap snapshot node ids are stable across snapshots", async () => { + const { id1, id2, found, stable } = await runFixture("id-stability"); + expect({ found, stable, id1, id2 }).toEqual({ found: true, stable: true, id1, id2: id1 }); +}); + test("v8 heap snapshot labels Web Streams internal edges", async () => { const edges = await runFixture("stream-edges"); // Every WriteBarrier member reported by analyzeHeap shows up as a named