From 30a98a53e6224d0f9f8f086e4553cf5ba018cf21 Mon Sep 17 00:00:00 2001 From: Dylan Conway Date: Tue, 18 Aug 2026 04:03:06 -0700 Subject: [PATCH 1/4] Don't request a GC before waiting on the entry point load_entry_point (and the worker and test-runner variants) called perform_gc() -> VM::collectAsync() right before waiting on the entry module's promise. At that point the heap holds little more than the freshly created global object, so the request frees nothing, but it is honoured at the next allocation slow path inside loadAndEvaluateModule: a synchronous Eden collection on the main thread that also starts the parallel marker threads, all before the first line of the program runs (BUN_JSC_logGC=1 shows a ~3ms pause on every start). JSC's GC activity callbacks and the idle GC timer still collect once there is something to collect. --- src/jsc/VirtualMachine.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index acb4294ea77f..3a0e43b786b6 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -2824,8 +2824,6 @@ impl VirtualMachine { // pending_internal_promise can change if hot module reloading is enabled if self.is_watcher_enabled() { - // accessed here (no overlapping `&mut EventLoop`). - self.event_loop_mut().perform_gc(); loop { let Some(p) = self.pending_internal_promise else { break; @@ -2848,7 +2846,6 @@ impl VirtualMachine { if crate::JSPromise::status_ptr(promise) == crate::js_promise::Status::Rejected { return Ok(promise); } - self.event_loop_mut().perform_gc(); let _ = self.wait_for_promise(jsc::AnyPromise::Internal(promise)); } @@ -4888,7 +4885,6 @@ impl VirtualMachine { entry_path: &[u8], ) -> crate::CrateResult<*mut JSInternalPromise> { let promise = self.reload_entry_point(entry_path)?; - self.event_loop_mut().perform_gc(); self.event_loop_mut() .wait_for_worker_entry_evaluation(jsc::AnyPromise::Internal(promise)); if let Some(worker) = self.worker_ref() { @@ -4908,7 +4904,6 @@ impl VirtualMachine { // pending_internal_promise can change if hot module reloading is enabled if self.is_watcher_enabled() { - self.event_loop_mut().perform_gc(); loop { let Some(p) = self.pending_internal_promise else { break; @@ -4931,7 +4926,6 @@ impl VirtualMachine { if crate::JSPromise::status_ptr(promise) == crate::js_promise::Status::Rejected { return Ok(promise); } - self.event_loop_mut().perform_gc(); let _ = self.wait_for_promise(jsc::AnyPromise::Internal(promise)); } From afcab588a52fe8be64006da4aa0f018601aeb235 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:09:41 +0000 Subject: [PATCH 2/4] Don't request a GC before waiting on each preload either load_preloads() made the same perform_gc() request as load_entry_point() once per preload module. Remove it too, and add tests that count the eden collections JSC logs while a script, a script with a preload, a test file, and a worker start up. The full collections Bun runs on purpose (VM teardown on the ASAN lanes, a worker after its entry point and at teardown) are not counted, and the children run with a very long idle GC interval: BUN_GC_TIMER_DISABLE would also suppress the startup requests the tests exist to catch. --- src/runtime/jsc_hooks.rs | 4 -- test/js/bun/gc/gc-controller-cadence.test.ts | 73 +++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 72b9ffcaa347..248e0bfe178d 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -853,8 +853,6 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS // enabled. // SAFETY: `el` is the live per-thread event loop. let el = unsafe { &*vm }.event_loop(); - // SAFETY: `el` is the live per-thread event loop. - unsafe { (*el).perform_gc() }; loop { // SAFETY: `pending_internal_promise` was set just above (or // swapped by HMR to another live cell); `status()` is a @@ -878,8 +876,6 @@ unsafe fn load_preloads(vm: *mut VirtualMachine) -> bun_jsc::CrateResult<*mut JS } } } else { - // SAFETY: `el` is the live per-thread event loop. - unsafe { (*(*vm).event_loop()).perform_gc() }; // SAFETY: per fn contract — short-lived `&mut *vm`; `promise` is a // live protected JSC heap cell. let _ = unsafe { (*vm).wait_for_promise(AnyPromise::Internal(promise)) }; diff --git a/test/js/bun/gc/gc-controller-cadence.test.ts b/test/js/bun/gc/gc-controller-cadence.test.ts index 26740480c2ab..1ca98d7314c8 100644 --- a/test/js/bun/gc/gc-controller-cadence.test.ts +++ b/test/js/bun/gc/gc-controller-cadence.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isDebug } from "harness"; +import { bunEnv, bunExe, isDebug, tempDir } from "harness"; // Bun's GarbageCollectionController used to sample `blockBytesAllocated + // extraMemorySize` on every event-loop tick and arm a 16 ms one-shot whenever @@ -55,6 +55,77 @@ async function countEdenCollections( return { eden }; } +// Bun used to request a collection (`perform_gc()`) right before waiting on the +// entry point's promise, once more per preload, and again for a worker's entry +// point. The heap holds little more than the fresh global object at that point, +// so JSC served each request as an eden collection that freed nothing, on the +// main thread, before the first line of the program ran. These programs are too +// small to reach JSC's own allocation budget, so any eden collection JSC logs +// was requested by Bun. The full collections Bun runs on purpose are left out: +// tearing the VM down at exit (BUN_DESTRUCT_VM_ON_EXIT, which the ASAN lanes +// set), and, for a worker, once after its entry point ran and once at teardown. +describe.concurrent("no collection is requested while starting up", () => { + const env = { + ...bunEnv, + // The startup requests and the idle timer both go through + // GarbageCollectionController::perform_gc(), so BUN_GC_TIMER_DISABLE would + // hide the requests too. Instead keep the timer from firing while a slow + // (debug, ASAN) child is still starting its worker. + BUN_GC_TIMER_DISABLE: undefined, + BUN_GC_TIMER_INTERVAL: String(2 ** 31 - 1), + // The CI runner sets 1, which makes some test-runner paths request collections. + BUN_GARBAGE_COLLECTOR_LEVEL: "0", + BUN_JSC_logGC: "true", + }; + + // `ran` is what the program prints once the code under test has run. + async function edenCollectionsLoggedBy(cmd: string[], cwd?: string, ran = "entry ran") { + await using proc = Bun.spawn({ cmd, cwd, env, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const log = stdout + stderr; + expect(log).toContain(ran); + expect(exitCode, log).toBe(0); + return log.match(/=> EdenCollection/g) ?? []; + } + + test("running a script", async () => { + expect(await edenCollectionsLoggedBy([bunExe(), "-e", `console.log("entry ran")`])).toEqual([]); + }); + + test("running a script with a preload", async () => { + using dir = tempDir("gc-startup-preload", { + "preload.js": `globalThis.preloaded = true;`, + "entry.js": `console.log("entry ran", globalThis.preloaded);`, + }); + const cmd = [bunExe(), "--preload", "./preload.js", "entry.js"]; + expect(await edenCollectionsLoggedBy(cmd, String(dir), "entry ran true")).toEqual([]); + }); + + test("running a test file", async () => { + using dir = tempDir("gc-startup-test", { + "entry.test.js": ` + import { test } from "bun:test"; + test("entry ran", () => {}); + `, + }); + expect(await edenCollectionsLoggedBy([bunExe(), "test", "./entry.test.js"], String(dir))).toEqual([]); + }); + + test("starting a worker", async () => { + using dir = tempDir("gc-startup-worker", { + "entry.js": ` + const worker = new Worker(new URL("./worker.js", import.meta.url).href); + worker.onmessage = ({ data }) => { + console.log(data); + worker.terminate(); + }; + `, + "worker.js": `postMessage("entry ran");`, + }); + expect(await edenCollectionsLoggedBy([bunExe(), "entry.js"], String(dir))).toEqual([]); + }); +}); + describe.skipIf(isDebug)("GarbageCollectionController eden cadence", () => { // 100 ticks allocating ~50 KB each is ~5 MB total over ~2 s. Before the fix // this produced ~128 eden collections (one per ~16 ms of wall time). With the From 861bce4e7e191e6051ae721246ed9abf5983ddba Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:58:21 +0000 Subject: [PATCH 3/4] abort test: collect before counting the AbortSignal wrappers heapStats() runs a full collection itself when nothing has collected yet in the process. With no collection at startup, its first call in this test collected the wrappers while counting them, so the count taken before Bun.gc(true) was already 0. --- test/js/web/abort/abort.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/js/web/abort/abort.test.ts b/test/js/web/abort/abort.test.ts index c07b5856bd83..3c1885f5d26d 100644 --- a/test/js/web/abort/abort.test.ts +++ b/test/js/web/abort/abort.test.ts @@ -309,6 +309,10 @@ describe.concurrent("AbortSignal.timeout() still fires after its observers go aw const { heapStats } = require("bun:jsc"); const wrappers = () => heapStats().objectTypeCounts.AbortSignal ?? 0; const N = 32; + // Nothing has collected yet in a fresh process, and heapStats() collects + // itself in that case, which would collect the wrappers in the middle of + // counting them. Collect up front so the two counts below are comparable. + Bun.gc(true); // A full GC of the debug heap takes ~100ms under ASAN; the deadline only // has to come after it. const deadline = 1000; From 4c509636d011d7ddb1d5d9db5dbf9060efd5b334 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:03:26 +0000 Subject: [PATCH 4/4] process.memoryUsage(): count the whole heap as used until the first collection heapUsed comes from the figure JSC measured at the end of the most recent collection (#39593). With no collection requested at startup there is no figure until the first one, and nothing has been freed yet either, so report heapTotal until then. external is only measured by a collection and stays 0 until then, which is the one line of test-memory-usage.js that cannot pass without a collection, so that node test is removed. --- src/jsc/bindings/BunProcess.cpp | 11 +++-- test/js/node/process/process.test.js | 9 ++++ .../node/test/parallel/test-memory-usage.js | 49 ------------------- 3 files changed, 17 insertions(+), 52 deletions(-) delete mode 100644 test/js/node/test/parallel/test-memory-usage.js diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index f46c807b5700..3e5239d28319 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -4103,12 +4103,17 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionMemoryUsage, (JSC::JSGlobalObject * glo // arrayBuffers: 9386 // } + size_t heapTotal = vm.heap.blockBytesAllocated(); result->putDirectOffset(vm, 0, JSC::jsNumber(current_rss)); - result->putDirectOffset(vm, 1, JSC::jsNumber(vm.heap.blockBytesAllocated())); + result->putDirectOffset(vm, 1, JSC::jsNumber(heapTotal)); // heap.size() walks every block of the heap, so report the size JSC measured - // at the end of the most recent collection instead. - result->putDirectOffset(vm, 2, JSC::jsNumber(WebCore::clientData(vm)->heapSizeAfterLastCollection())); + // at the end of the most recent collection instead. Nothing requests a + // collection while Bun starts up, and until the first one nothing has been + // freed either, so the whole heap counts as used. (external is measured by + // collections too and stays 0 until then.) + size_t heapUsed = WebCore::clientData(vm)->heapSizeAfterLastCollection(); + result->putDirectOffset(vm, 2, JSC::jsNumber(heapUsed ? heapUsed : heapTotal)); result->putDirectOffset(vm, 3, JSC::jsNumber(vm.heap.extraMemorySize() + vm.heap.externalMemorySize())); diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 8e73923f2f47..ed68ef859258 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -1095,6 +1095,15 @@ describe.concurrent(() => { expect(full).toBeGreaterThan(0); expect(heapUsed).toBe(full); }); + + // Nothing requests a collection while Bun starts up, so there is no figure + // yet. Nothing has been freed yet either, so the whole heap counts as used. + it("counts the whole heap as used before the first collection", async () => { + const { heapTotal, heapUsed } = await reportedBy(`console.log(JSON.stringify(process.memoryUsage()))`); + + expect(heapTotal).toBeGreaterThan(0); + expect(heapUsed).toBe(heapTotal); + }); }); describe("process.cpuUsage", () => { diff --git a/test/js/node/test/parallel/test-memory-usage.js b/test/js/node/test/parallel/test-memory-usage.js deleted file mode 100644 index 8e5ea4de5bf5..000000000000 --- a/test/js/node/test/parallel/test-memory-usage.js +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright Joyent, Inc. and other Node contributors. -// -// Permission is hereby granted, free of charge, to any person obtaining a -// copy of this software and associated documentation files (the -// "Software"), to deal in the Software without restriction, including -// without limitation the rights to use, copy, modify, merge, publish, -// distribute, sublicense, and/or sell copies of the Software, and to permit -// persons to whom the Software is furnished to do so, subject to the -// following conditions: -// -// The above copyright notice and this permission notice shall be included -// in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN -// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -// USE OR OTHER DEALINGS IN THE SOFTWARE. - -// Flags: --predictable-gc-schedule -'use strict'; -const common = require('../common'); -const assert = require('assert'); - -const r = process.memoryUsage(); -// On IBMi, the rss memory always returns zero -if (!common.isIBMi) { - assert.ok(r.rss > 0); - assert.ok(process.memoryUsage.rss() > 0); -} - -assert.ok(r.heapTotal > 0); -assert.ok(r.heapUsed > 0); -assert.ok(r.external > 0); - -assert.strictEqual(typeof r.arrayBuffers, 'number'); -if (r.arrayBuffers > 0) { - const size = 10 * 1024 * 1024; - // eslint-disable-next-line no-unused-vars - const ab = new ArrayBuffer(size); - - const after = process.memoryUsage(); - assert.ok(after.external - r.external >= size, - `${after.external} - ${r.external} >= ${size}`); - assert.strictEqual(after.arrayBuffers - r.arrayBuffers, size, - `${after.arrayBuffers} - ${r.arrayBuffers} === ${size}`); -}