From 60f6858a747311e5f41ee7d5a865e590129dcfdd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:49:54 +0000 Subject: [PATCH] Bun.gc(true) no longer skips its purge when the allocator's purge thread is at work Bumps mimalloc to 8565de27 (oven-sh/mimalloc#38). `Bun.gc(true)` ends with `mi_collect(true)` (#42428). Only one thread purges the arenas at a time, and `_mi_arenas_try_purge` dropped the call when another thread held the guard, forced or not. The scavenger thread holds it for tens of milliseconds while it purges a few hundred MB, and it starts such a pass each time the event loop blocks after a lot was freed. A `Bun.gc(true)` in that window returned with nothing handed back to the OS. A forced purge now waits for the pass in progress and then runs its own. test/js/bun/spawn/spawn-pipe-leak.test.ts compares the peak RSS after `Bun.gc(true)` over batches. One batch in which the purge was skipped read 250 to 800 MB against a 50 MB floor and failed the test on main. --- scripts/build/deps/mimalloc.ts | 2 +- test/js/bun/gc/gc-controller-cadence.test.ts | 40 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/scripts/build/deps/mimalloc.ts b/scripts/build/deps/mimalloc.ts index 5e491a9abde6..76554e300364 100644 --- a/scripts/build/deps/mimalloc.ts +++ b/scripts/build/deps/mimalloc.ts @@ -12,7 +12,7 @@ import type { Dependency, DirectBuild } from "../source.ts"; -const MIMALLOC_COMMIT = "6a64e1ba7f5b2130d4efccb67ec87fd0003f0f6a"; +const MIMALLOC_COMMIT = "8565de27fbfecafbfc1ea27e95493d39ea454ca0"; export const mimalloc: Dependency = { name: "mimalloc", diff --git a/test/js/bun/gc/gc-controller-cadence.test.ts b/test/js/bun/gc/gc-controller-cadence.test.ts index 983c8fd2d0bf..238c37f7c46a 100644 --- a/test/js/bun/gc/gc-controller-cadence.test.ts +++ b/test/js/bun/gc/gc-controller-cadence.test.ts @@ -288,3 +288,43 @@ test.skipIf(!isLinux || isASAN)("Bun.gc(true) returns what it freed to the OS be expect(JSON.parse(stdout).released).toBeGreaterThan(200); expect(exitCode).toBe(0); }); + +// One thread at a time hands the allocator's free ranges back, and its own purge thread is often the one: it starts on what +// was freed once the purge delay has passed, and a few hundred MB keep it busy for tens of milliseconds. Bun.gc(true) in the +// middle of that found the purge taken, skipped its own, and returned with all of it still resident. +test.skipIf(!isLinux || isASAN)( + "Bun.gc(true) returns what is free to the OS while the purge thread is at work", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const rss = () => process.memoryUsage.rss() / 1048576; + // The allocator starts its purge thread the first time a thread blocks. + await Bun.sleep(1); + const rounds = []; + for (let round = 0; round < 3; round++) { + const arrays = []; + for (let i = 0; i < 48; i++) arrays.push(new Uint8Array(8 * 1024 * 1024).fill(1)); + const held = rss(); + // transfer(0) frees the 8 MB here and now, no collection involved. They stay resident until they are purged. + for (const array of arrays) array.buffer.transfer(0); + // Wait for the purge thread to start on them. If it never does, Bun.gc(true) has all of it to return by itself. + const deadline = performance.now() + 1000; + while (rss() > held - 32 && performance.now() < deadline); + Bun.gc(true); + rounds.push({ held, released: held - rss() }); + } + console.log(JSON.stringify(rounds)); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + for (const { released } of JSON.parse(stdout)) expect(released, stdout).toBeGreaterThan(300); + expect(exitCode).toBe(0); + }, +);