diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 16489eae8d23..915f08a895c2 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -3658,6 +3658,33 @@ extern "C" int getRSS(size_t* rss) #endif } +size_t Process::heapUsedBytes(JSC::VM& vm) +{ + auto& objectSpace = vm.heap.objectSpace(); + + // A full collection's beginMarking() stales every block's mark bits, and the mutator runs + // through the concurrent part of marking, so a walk there sums a nearly empty heap. Eden + // leaves the marks alone, so only full marking has to fall back on the last snapshot. + if (objectSpace.isMarking() && vm.heap.collectionScope() == JSC::CollectionScope::Full) { + // With nothing cached, a torn walk still beats claiming an empty heap. Don't latch it: + // the version only moves at endMarking(), so it would be served for the whole cycle. + if (m_heapUsedVersion == JSC::MarkedSpace::nullVersion) + return objectSpace.size(); + return m_heapUsedBytes; + } + + // MarkedSpace::size() costs a walk of the whole heap but, outside of full marking, only + // changes when a collection does. Recompute it once per collection, keyed on the + // version endMarking() publishes. + JSC::HeapVersion version = objectSpace.newlyAllocatedVersion(); + if (m_heapUsedVersion != version) { + m_heapUsedVersion = version; + m_heapUsedBytes = objectSpace.size(); + } + + return m_heapUsedBytes; +} + JSC_DEFINE_HOST_FUNCTION(Process_functionMemoryUsage, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) { auto& vm = JSC::getVM(globalObject); @@ -3684,12 +3711,15 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionMemoryUsage, (JSC::JSGlobalObject * glo // arrayBuffers: 9386 // } - result->putDirectOffset(vm, 0, JSC::jsNumber(current_rss)); - result->putDirectOffset(vm, 1, JSC::jsNumber(vm.heap.blockBytesAllocated())); + // heapTotal/heapUsed describe the JS object heap only, so both come from the marked space, + // leaving the off-heap bytes owned by JS cells to external/arrayBuffers below. The snapshot + // heapUsedBytes() falls back on can predate a sweep, so clamp it to the capacity it ships with. + size_t heapTotal = vm.heap.objectSpace().capacity(); + size_t heapUsed = std::min(process->heapUsedBytes(vm), heapTotal); - // heap.size() loops through every cell... - // TODO: add a binding for heap.sizeAfterLastCollection() - result->putDirectOffset(vm, 2, JSC::jsNumber(vm.heap.sizeAfterLastEdenCollection())); + result->putDirectOffset(vm, 0, JSC::jsNumber(current_rss)); + result->putDirectOffset(vm, 1, JSC::jsNumber(heapTotal)); + result->putDirectOffset(vm, 2, JSC::jsNumber(heapUsed)); result->putDirectOffset(vm, 3, JSC::jsNumber(vm.heap.extraMemorySize() + vm.heap.externalMemorySize())); diff --git a/src/jsc/bindings/BunProcess.h b/src/jsc/bindings/BunProcess.h index 92d70e747f21..a5213f16587d 100644 --- a/src/jsc/bindings/BunProcess.h +++ b/src/jsc/bindings/BunProcess.h @@ -33,6 +33,11 @@ class Process : public WebCore::JSEventEmitter { WriteBarrier m_argv; WriteBarrier m_execArgv; + // Cache for heapUsedBytes(). MarkedSpace's own version starts at initialVersion and + // nextVersion() skips nullVersion, so nullVersion doubles as "not computed yet". + JSC::HeapVersion m_heapUsedVersion { JSC::MarkedSpace::nullVersion }; + size_t m_heapUsedBytes { 0 }; + public: Process(JSC::Structure* structure, WebCore::JSDOMGlobalObject& globalObject, Ref&& impl) : Base(structure, globalObject, WTF::move(impl)) @@ -82,6 +87,9 @@ class Process : public WebCore::JSEventEmitter { JSValue getExecArgv(JSGlobalObject* globalObject); void setExecArgv(JSGlobalObject* globalObject, JSValue execArgv); + // Live bytes in the JS object heap, as of the last collection. + size_t heapUsedBytes(JSC::VM&); + static JSC::Structure* createStructure(JSC::VM& vm, JSC::JSGlobalObject* globalObject, JSC::JSValue prototype) { diff --git a/test/js/node/diagnostics_channel/diagnostics_channel.test.ts b/test/js/node/diagnostics_channel/diagnostics_channel.test.ts index 37dfd54d7a8f..978fabe6c6d5 100644 --- a/test/js/node/diagnostics_channel/diagnostics_channel.test.ts +++ b/test/js/node/diagnostics_channel/diagnostics_channel.test.ts @@ -322,20 +322,31 @@ describe("Channel", () => { }); // test-diagnostics-channel-memory-leak.js - test("references are not leaked", () => { + test("references are not leaked", async () => { function noop() {} - const heapUsedBefore = process.memoryUsage().heapUsed; - for (let i = 0; i < 1000; i++) { + const total = 1000; + const refs: WeakRef[] = []; + for (let i = 0; i < total; i++) { const name = `channel7-${i}`; subscribe(name, noop); unsubscribe(name, noop); + refs.push(new WeakRef(channel(name))); } - gc(true); - const heapUsedAfter = process.memoryUsage().heapUsed; + // Once its last subscriber is gone, the registry holds a channel weakly. Constructing a + // WeakRef keeps its target alive for the rest of the job, so yield before each collection. + let alive = refs.length; + for (let i = 0; i < 20 && alive > 0; i++) { + await new Promise(resolve => setImmediate(resolve)); + gc(true); + alive = refs.filter(ref => ref.deref() !== undefined).length; + } - expect(heapUsedBefore).toBeGreaterThanOrEqual(heapUsedAfter); + // Conservative stack scanning pins whichever channels still have a pointer in a live + // stack slot or register, so this asserts they are collectable, not collected. Retaining + // a reference the way this test guards against would leave every one of them alive. + expect(alive).toBeLessThan(total / 10); }); }); diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index be14051ce870..9938c28935a2 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -578,6 +578,118 @@ describe.concurrent(() => { expect(process.memoryUsage.rss()).toEqual(expect.any(Number)); }); + it("process.memoryUsage() keeps heapUsed <= heapTotal while off-heap memory grows", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const CHUNK = 4 << 20; + const COUNT = 40; + const buffers = []; + let violations = 0; + let worstRatio = 0; + let minHeapUsed = Infinity; + for (let i = 0; i < COUNT; i++) { + buffers.push(new ArrayBuffer(CHUNK)); + const sample = process.memoryUsage(); + if (sample.heapUsed > sample.heapTotal) violations++; + worstRatio = Math.max(worstRatio, sample.heapUsed / sample.heapTotal); + minHeapUsed = Math.min(minHeapUsed, sample.heapUsed); + } + const withBuffers = process.memoryUsage(); + + Bun.gc(true); + const heapUsedBefore = process.memoryUsage().heapUsed; + const objects = []; + for (let i = 0; i < 200_000; i++) objects.push({ i, next: null }); + Bun.gc(true); + const withObjects = process.memoryUsage(); + + console.log(JSON.stringify({ + violations, + worstRatio, + minHeapUsed, + allocated: CHUNK * COUNT, + withBuffers, + heapGrowth: withObjects.heapUsed - heapUsedBefore, + withObjectsHeapUsed: withObjects.heapUsed, + withObjectsHeapTotal: withObjects.heapTotal, + alive: buffers.length + objects.length, + })); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) throw new Error(`exited with ${exitCode}\n${stderr}`); + const result = JSON.parse(stdout); + + // heapUsed is a subset of heapTotal, at every sample. ArrayBuffer backing stores + // are off-heap, so they must not land in heapUsed. + expect(result.violations).toBe(0); + expect(result.worstRatio).toBeLessThanOrEqual(1); + expect(result.withBuffers.heapUsed).toBeLessThanOrEqual(result.withBuffers.heapTotal); + expect(result.withObjectsHeapUsed).toBeLessThanOrEqual(result.withObjectsHeapTotal); + + // ...they are reported in arrayBuffers, which node documents as part of external. + expect(result.withBuffers.arrayBuffers).toBeGreaterThanOrEqual(result.allocated); + expect(result.withBuffers.external).toBeGreaterThanOrEqual(result.withBuffers.arrayBuffers); + + // ...but heapUsed still tracks JS object allocation, and never reads as an empty heap. + // A collection is typically already marking by the first sample, which is exactly where + // a too-eager "skip the walk while marking" guard reports zero. + expect(result.minHeapUsed).toBeGreaterThan(0); + expect(result.heapGrowth).toBeGreaterThan(2 * 1024 * 1024); + expect(exitCode).toBe(0); + }); + + it("process.memoryUsage() keeps heapUsed <= heapTotal after the heap shrinks", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + // Big backing stores: freeing these shrinks capacity through sweepPreciseAllocations(). + let live = []; + for (let i = 0; i < 60; i++) live.push(new Array(60_000).fill(i)); + Bun.gc(true); + const grown = process.memoryUsage(); + + // Read it here, not before: an array only written to is dead by the measurement above. + const liveCount = live.length; + live = null; + Bun.gc(true); + const shrunk = process.memoryUsage(); + + console.log(JSON.stringify({ liveCount, grown, shrunk })); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + if (exitCode !== 0) throw new Error(`exited with ${exitCode}\n${stderr}`); + const result = JSON.parse(stdout); + + // Dropping the live set hands blocks back, so capacity falls. heapUsed is a snapshot of + // the live set, so it has to fall with it rather than outlive the capacity it is paired + // with: a stale snapshot reported against a shrunk capacity is how heapUsed > heapTotal. + expect(result.liveCount).toBe(60); + expect(result.grown.heapTotal).toBeGreaterThan(8 * 1024 * 1024); + expect(result.shrunk.heapTotal).toBeLessThan(result.grown.heapTotal); + expect(result.shrunk.heapUsed).toBeLessThan(result.grown.heapUsed); + + expect(result.grown.heapUsed).toBeLessThanOrEqual(result.grown.heapTotal); + expect(result.shrunk.heapUsed).toBeLessThanOrEqual(result.shrunk.heapTotal); + expect(exitCode).toBe(0); + }); + describe("process.cpuUsage", () => { it("works", () => { expect(process.cpuUsage()).toEqual({