Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
robobun marked this conversation as resolved.

JSC_DEFINE_HOST_FUNCTION(Process_functionMemoryUsage, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
auto& vm = JSC::getVM(globalObject);
Expand All @@ -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()));

Expand Down
8 changes: 8 additions & 0 deletions src/jsc/bindings/BunProcess.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ class Process : public WebCore::JSEventEmitter {
WriteBarrier<Unknown> m_argv;
WriteBarrier<Unknown> 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<WebCore::EventEmitter>&& impl)
: Base(structure, globalObject, WTF::move(impl))
Expand Down Expand Up @@ -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)
{
Expand Down
23 changes: 17 additions & 6 deletions test/js/node/diagnostics_channel/diagnostics_channel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Channel>[] = [];
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);
});
});

Expand Down
112 changes: 112 additions & 0 deletions test/js/node/process/process.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Comment thread
robobun marked this conversation as resolved.

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({
Expand Down
Loading