From e8eda880520be37046612fb700d60f7983c75657 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 04:19:27 +0000 Subject: [PATCH 1/7] mimalloc: what is freed during a purge pass is purged by the next pass Bumps mimalloc to 707d90be (oven-sh/mimalloc#39, which is stacked on oven-sh/mimalloc#38). The allocator's purge thread (the scavenger) hands freed arena memory back to the OS 100 ms after the free. What a thread freed while the scavenger was in the middle of a pass could be left out for good: the arena was marked as scheduled, the scavenger was not, and no later free into that arena scheduled it again. The memory stayed resident until the event loop went idle or something forced a collection. It took a pass in which each arena had something to purge. JSC's structure heap is a mimalloc arena of its own that rarely has, which hides most of it in bun. With one arena (Malloc=1) each such free was lost. The pass now resets the schedule before it looks at the arenas, so a free behind it schedules the next pass, and puts back what is still pending at its end. The second commit of oven-sh/mimalloc#39 (707d90be) releases the purge guard in mi_process_done on Windows, where the process detach callback runs after the system terminated the purge thread and the forced collect at exit would wait for that guard without end. bun builds mimalloc with MI_NO_PROCESS_DETACH and never runs mi_process_done, so that part is for the fork's own tests. --- scripts/build/deps/mimalloc.ts | 2 +- test/js/bun/jsc/heapStats-mimalloc.test.ts | 49 +++++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/scripts/build/deps/mimalloc.ts b/scripts/build/deps/mimalloc.ts index 5e491a9abde6..14244e2c9cc0 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 = "707d90be8a0cd283c4fb1e8318c3ef5c715c9352"; export const mimalloc: Dependency = { name: "mimalloc", diff --git a/test/js/bun/jsc/heapStats-mimalloc.test.ts b/test/js/bun/jsc/heapStats-mimalloc.test.ts index b6e6ac1d78bb..3e613de4b693 100644 --- a/test/js/bun/jsc/heapStats-mimalloc.test.ts +++ b/test/js/bun/jsc/heapStats-mimalloc.test.ts @@ -1,6 +1,6 @@ import { heapStats } from "bun:jsc"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isMacOS } from "harness"; +import { bunEnv, bunExe, isASAN, isLinux, isMacOS } from "harness"; describe("heapStats() mimalloc integration", () => { test("mimalloc aggregate stats are present", () => { @@ -110,4 +110,51 @@ describe("heapStats() mimalloc integration", () => { expect(appTag).toBeGreaterThan(64); expect(exitCode).toBe(0); }); + + // The allocator's purge thread takes back what was freed 100 ms after the free. What a thread freed while the purge thread + // was in the middle of a pass was left out for good: it stayed resident until the event loop went idle or something forced + // a collection. A script that keeps its thread busy does neither. It took a pass in which each of the allocator's arenas + // had something to hand back. JSC's structure heap is an arena of its own that rarely has, so Malloc=1 here: JSC then + // allocates through malloc (mimalloc as well) and there is one arena. Linux only: the wait reads RSS. Not ASAN: malloc is + // not mimalloc there. + test.skipIf(!isLinux || isASAN)( + "memory freed while the purge thread is at work is purged without an idle event loop", + async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + import { heapStats } from "bun:jsc"; + const rss = () => process.memoryUsage.rss() / 1048576; + // the bytes the allocator handed back to the OS so far + const purged = () => heapStats().mimalloc.purged / 1048576; + // The allocator starts its purge thread the first time a thread blocks. + await Bun.sleep(1); + const first = [], second = []; + for (let i = 0; i < 32; i++) first.push(new Uint8Array(8 * 1024 * 1024).fill(1)); + for (let i = 0; i < 16; i++) second.push(new Uint8Array(8 * 1024 * 1024).fill(1)); + const held = rss(), purgedBefore = purged(); + // transfer(0) frees the 8 MB here and now, no collection involved. No await from here on. + for (const array of first) array.buffer.transfer(0); + // Spin until the purge thread is in the middle of its pass over them (heapStats() would run that pass itself). + let deadline = performance.now() + 1000; + while (rss() > held - 64 && performance.now() < deadline); + for (const array of second) array.buffer.transfer(0); + // The next pass comes 100 ms later. + deadline = performance.now() + 2000; + while (rss() > held - 352 && performance.now() < deadline); + console.log(JSON.stringify({ released: held - rss(), purged: purged() - purgedBefore })); + `, + ], + env: { ...bunEnv, Malloc: "1" }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + // 384 MB were freed. The first pass alone takes the first 256 MB, and up to 32 MB of the rest. + expect(JSON.parse(stdout).purged, stdout).toBeGreaterThanOrEqual(352); + expect(exitCode).toBe(0); + }, + ); }); From d686cfe9cf855e1d873d6e9670e480b4566ee79a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 06:48:16 +0000 Subject: [PATCH 2/7] test: read how far RSS fell before heapStats() polls the allocator again heapStats() calls mi_collect(false), and that poll runs a purge pass that is due by itself. The drop in RSS is now taken in the wait loop, before the last heapStats() call, and asserted first. The counter is asserted after it. --- test/js/bun/jsc/heapStats-mimalloc.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/js/bun/jsc/heapStats-mimalloc.test.ts b/test/js/bun/jsc/heapStats-mimalloc.test.ts index 3e613de4b693..72ce61be4507 100644 --- a/test/js/bun/jsc/heapStats-mimalloc.test.ts +++ b/test/js/bun/jsc/heapStats-mimalloc.test.ts @@ -141,10 +141,12 @@ describe("heapStats() mimalloc integration", () => { let deadline = performance.now() + 1000; while (rss() > held - 64 && performance.now() < deadline); for (const array of second) array.buffer.transfer(0); - // The next pass comes 100 ms later. + // The next pass comes 100 ms later. What RSS fell by is taken before heapStats() runs again: that call polls the + // allocator, and a poll runs a pass that is due by itself. deadline = performance.now() + 2000; - while (rss() > held - 352 && performance.now() < deadline); - console.log(JSON.stringify({ released: held - rss(), purged: purged() - purgedBefore })); + let released; + while ((released = held - rss()) < 336 && performance.now() < deadline); + console.log(JSON.stringify({ released, purged: purged() - purgedBefore })); `, ], env: { ...bunEnv, Malloc: "1" }, @@ -153,7 +155,9 @@ describe("heapStats() mimalloc integration", () => { }); const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); // 384 MB were freed. The first pass alone takes the first 256 MB, and up to 32 MB of the rest. - expect(JSON.parse(stdout).purged, stdout).toBeGreaterThanOrEqual(352); + const { released, purged } = JSON.parse(stdout); + expect(released, stdout).toBeGreaterThanOrEqual(336); + expect(purged, stdout).toBeGreaterThanOrEqual(336); expect(exitCode).toBe(0); }, ); From 97d5bc8953e3a11a730d96d9eff2032efd056589 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 13 Sep 2026 10:00:48 +0000 Subject: [PATCH 3/7] test: Bun.gc(true) returns what is free while the allocator's purge thread is in a pass The mimalloc bump in this branch makes a forced collect wait for a purge pass in progress instead of being dropped. This is the test from #42543, which bumped to the first of the two mimalloc commits only. No-Verification-Needed: test-only commit (test/js/bun/gc/gc-controller-cadence.test.ts) --- test/js/bun/gc/gc-controller-cadence.test.ts | 40 ++++++++++++++++++++ 1 file changed, 40 insertions(+) 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); + }, +); From 308125b3d2822ebf78622af8e548fcd683448ef8 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Sun, 13 Sep 2026 13:28:57 +0000 Subject: [PATCH 4/7] deps: mimalloc to the upstream dev3 sync (oven-sh/mimalloc#37) Pins ab13501334a8, the merge of oven-sh/mimalloc#37 on bun-dev3-v2. On top of the two purge fixes this branch already carried (#38, #39) it brings: - microsoft/mimalloc dev3 through v3.5.2 (301 commits): always-on statistics (MI_STATS=1), sampled profiler hooks (mi_profiler_t, MI_PROFILE=1; the fork's own prof.c and mi_prof_* are gone, bun used none of it), codegen work in free/zalloc/memzero, double-free detection through the padding canary - fork(): no arena purge pass runs across it any more (the macOS Debug SIGBUS of a forked child, and a whole class of torn purge state in the child) - mi_heap_destroy of a heap with a block still on a page's thread-free list no longer reports corrupted meta-data; two slips in upstream's free.c fixed (mi_usable_size always took its slow path; an overflow could be reported as a double free) - malloc/free fast paths update the packed used count with one instruction: 25 M malloc + 25 M free with bun's flags execute 2.2403 G instructions in this configuration, 2.2463 G with the old pin MI_FREE_USE_PAGEMAP=1 keeps the page-map lookup in mi_free. Upstream's new default puts page meta data at 256 MiB boundaries and needs every arena to start on one; mi_manage_os_memory_ex then cannot take JSC's structure heap when its reservation is 256 MiB or less, and bun aborted on startup under `ulimit -v 3000000` or BUN_JSC_structureHeapSizeInKB<=262144. With the define that works down to 64 MiB as before. The aligned layout is worth another 1.1% of instructions on that benchmark (2.2153 G) once WebKit hands mimalloc an aligned region. Every mi_* function and mi_option number bun declares is unchanged (options 0..46; 47 is collect_merges_stats now, snapshot_on_exit 48). Release build, old pin against this one, 5 interleaved runs, user-mode instructions / peak RSS: bun -e 1 9.17 M / 25.9 MB 9.16 M / 25.7 MB Bun.serve hello 51748 / request 51787 / request (+0.08%), 48.2 MB / 48.5 MB JSON round trips 5102.3 M / 42.6 MB 5102.9 M / 42.6 MB string concat 1939.8 M / 58.9 MB 1904.3 M / 57.8 MB (run to run spread 2%) --- scripts/build/deps/mimalloc.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/build/deps/mimalloc.ts b/scripts/build/deps/mimalloc.ts index 14244e2c9cc0..008ac3857472 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 = "707d90be8a0cd283c4fb1e8318c3ef5c715c9352"; +const MIMALLOC_COMMIT = "ab13501334a8da2b64ab2e5f4f552c3a39b96350"; export const mimalloc: Dependency = { name: "mimalloc", @@ -55,6 +55,12 @@ export const mimalloc: Dependency = { // free(). MI_SKIP_COLLECT_ON_EXIT only skips the heap walk inside it. MI_NO_PROCESS_DETACH: 1, + // mi_free finds a block's page through the page map. Without this, page + // meta data sits at 256 MiB boundaries, every arena must start on one, and + // mi_manage_os_memory_ex refuses JSC's structure heap once its reservation + // is 256 MiB or less (JSC halves it under `ulimit -v`): abort on startup. + MI_FREE_USE_PAGEMAP: 1, + ...(cfg.release && { MI_BUILD_RELEASE: true }), }; From 4c5fafd916d65c9f8e6aa341c94140b5338fedc2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:56:27 +0000 Subject: [PATCH 5/7] test: a Bun.gc(true) round counts only when the purge thread was seen at work The wait for the purge thread ends when RSS fell by 32 MB or after one second. A round that ran into the deadline passed as well, although Bun.gc(true) then purged all of it by itself and the round said nothing about the two at once. Each round now records whether the drop was seen before Bun.gc(true) ran, and the test asserts it. --- test/js/bun/gc/gc-controller-cadence.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/js/bun/gc/gc-controller-cadence.test.ts b/test/js/bun/gc/gc-controller-cadence.test.ts index 238c37f7c46a..15c1d242c668 100644 --- a/test/js/bun/gc/gc-controller-cadence.test.ts +++ b/test/js/bun/gc/gc-controller-cadence.test.ts @@ -310,11 +310,13 @@ test.skipIf(!isLinux || isASAN)( 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. + // Wait for the purge thread to start on them, which it does once the purge delay (100 ms) has passed. A round in + // which it was not seen at work says nothing about the two at once, so it does not count as passed. const deadline = performance.now() + 1000; - while (rss() > held - 32 && performance.now() < deadline); + let started = false; + while (!(started = rss() <= held - 32) && performance.now() < deadline); Bun.gc(true); - rounds.push({ held, released: held - rss() }); + rounds.push({ held, started, released: held - rss() }); } console.log(JSON.stringify(rounds)); `, @@ -324,7 +326,10 @@ test.skipIf(!isLinux || isASAN)( 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); + for (const { started, released } of JSON.parse(stdout)) { + expect(started, stdout).toBe(true); + expect(released, stdout).toBeGreaterThan(300); + } expect(exitCode).toBe(0); }, ); From 6d690f9f1781b44399687d6fe84ff0f5de9f68dc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:56:27 +0000 Subject: [PATCH 6/7] test: bun starts with a small structure heap reservation JSC hands its structure heap to mimalloc as an arena of its own and halves the reservation when address space is short. Without MI_FREE_USE_PAGEMAP the allocator refuses a reservation of 256 MiB or less and bun aborts on startup. BUN_JSC_structureHeapSizeInKB=131072 takes that path: a release build without the define exits with 134 (abort() called), with it the script runs. --- test/js/bun/jsc/heapStats-mimalloc.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/test/js/bun/jsc/heapStats-mimalloc.test.ts b/test/js/bun/jsc/heapStats-mimalloc.test.ts index 72ce61be4507..ee7d43bd7f61 100644 --- a/test/js/bun/jsc/heapStats-mimalloc.test.ts +++ b/test/js/bun/jsc/heapStats-mimalloc.test.ts @@ -111,6 +111,21 @@ describe("heapStats() mimalloc integration", () => { expect(exitCode).toBe(0); }); + // JSC hands its structure heap to mimalloc as an arena of its own (`mi_manage_os_memory_ex`), and it halves that + // reservation when address space is short (`ulimit -v`). mimalloc has to take a small one as well: when it refused + // 256 MiB and less (page meta data at 256 MiB boundaries, without MI_FREE_USE_PAGEMAP), bun aborted on startup. + test("starts with a small structure heap reservation", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "console.log(typeof {})"], + env: { ...bunEnv, BUN_JSC_structureHeapSizeInKB: "131072" }, + stdout: "pipe", + stderr: "inherit", + }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toBe("object\n"); + expect(exitCode).toBe(0); + }); + // The allocator's purge thread takes back what was freed 100 ms after the free. What a thread freed while the purge thread // was in the middle of a pass was left out for good: it stayed resident until the event loop went idle or something forced // a collection. A script that keeps its thread busy does neither. It took a pass in which each of the allocator's arenas From 7e83bb8a4b37d4fb12faecaeb40f3cf5d93ee36c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:27:29 +0000 Subject: [PATCH 7/7] ci: retrigger