From f00c69cf0d4c4025cb6e8b3527eb22470dd4465a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:44:07 +0000 Subject: [PATCH 1/4] test(require-cache): sample RSS over mimalloc's idle sweep window After #34009 moved JSC onto mimalloc, pages freed by gc(true) are not necessarily returned to the OS before the next instruction; mimalloc's idle sweep is rate-limited. The two await-import leak fixtures read RSS immediately after a single gc(true), so on macOS 26 arm64 the 'via import() with a lot of long export names' case now reports 70-97 MB against a 64 MB threshold and fails. Measured on macOS 26 arm64 against a post-#34009 release build: the diff plateaus at ~65 MB and stays flat across 1000+ further iterations, so it is allocator retention, not a leak. (1.3.13 on the same hardware actually kept growing past 130 MB over the same extended run; the first 250 iterations just happened to land below the line.) Match the settledRss() pattern #34009 introduced for the sibling leak tests (min RSS over 5 gc+50ms rounds), and raise the non-ASAN bound for the long-export-names import() case from 64 to 128 MB. The source-code leak this test guards against is ~290 MB (250 iterations x 1.17 MB), so the new bound still catches it with margin. --- test/cli/run/require-cache.test.ts | 39 ++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/test/cli/run/require-cache.test.ts b/test/cli/run/require-cache.test.ts index 027bf515b479..be6c3cd4e40b 100644 --- a/test/cli/run/require-cache.test.ts +++ b/test/cli/run/require-cache.test.ts @@ -124,19 +124,28 @@ describe.concurrent("require.cache", () => { function bust() { delete require.cache[path]; } + // Floor over rounds spanning the 100ms sweep rate limit; one + // gc() + immediate read races mimalloc's idle sweep. + async function settledRss() { + let min = Infinity; + for (let i = 0; i < 5; i++) { + gc(true); + await Bun.sleep(50); + min = Math.min(min, process.memoryUsage.rss()); + } + return min; + } for (let i = 0; i < 100; i++) { await import(path); bust(); } - gc(true); - const baseline = process.memoryUsage.rss(); + const baseline = await settledRss(); for (let i = 0; i < 400; i++) { await import(path); bust(path); } - gc(true); - const rss = process.memoryUsage.rss(); + const rss = await settledRss(); const diff = rss - baseline; console.log("RSS diff", (diff / 1024 / 1024) | 0, "MB"); console.log("RSS", (diff / 1024 / 1024) | 0, "MB"); @@ -173,25 +182,35 @@ describe.concurrent("require.cache", () => { function bust() { delete require.cache[path]; } + // Floor over rounds spanning the 100ms sweep rate limit; one + // gc() + immediate read races mimalloc's idle sweep. + async function settledRss() { + let min = Infinity; + for (let i = 0; i < 5; i++) { + gc(true); + await Bun.sleep(50); + min = Math.min(min, process.memoryUsage.rss()); + } + return min; + } for (let i = 0; i < 50; i++) { await import(path); bust(); } - gc(true); - const baseline = process.memoryUsage.rss(); + const baseline = await settledRss(); for (let i = 0; i < 250; i++) { await import(path); bust(path); } - gc(true); - const rss = process.memoryUsage.rss(); + const rss = await settledRss(); const diff = rss - baseline; console.log("RSS diff", (diff / 1024 / 1024) | 0, "MB"); console.log("RSS", (diff / 1024 / 1024) | 0, "MB"); - if (diff > ${isASAN ? 320 : 64} * 1024 * 1024) { + if (diff > ${isASAN ? 320 : 128} * 1024 * 1024) { // Bun v1.1.21 reported 423 MB here on macoS arm64. - // Bun v1.1.22 reported 4 MB here on macoS arm64. + // Bun v1.4.0 (#34009, JSC uses mimalloc) plateaus at ~65 MB on + // macOS 26 arm64 and stays flat for 1000+ further iterations. throw new Error("Memory leak detected"); } From 497603e8620ac195b75b3d4f06d20550cb3b25c1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:30:14 +0000 Subject: [PATCH 2/4] Drop the sleep; match warmup to the measured round instead Per review: no Bun.sleep(). Revert the sibling fixture to its original shape and for the long-export-names import() case, raise the warmup from 50 to 250 iterations so baseline is captured at mimalloc's working-set plateau rather than mid-climb. Verified on macOS 26 arm64 with a post-#34009 release binary (25 runs, 0-46 MB, median ~6); the 128 MB bound stays as headroom over allocator variance and is still well under the ~290 MB a real source-code leak would produce. --- test/cli/run/require-cache.test.ts | 43 ++++++++++-------------------- 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/test/cli/run/require-cache.test.ts b/test/cli/run/require-cache.test.ts index be6c3cd4e40b..bed583fb8528 100644 --- a/test/cli/run/require-cache.test.ts +++ b/test/cli/run/require-cache.test.ts @@ -124,28 +124,19 @@ describe.concurrent("require.cache", () => { function bust() { delete require.cache[path]; } - // Floor over rounds spanning the 100ms sweep rate limit; one - // gc() + immediate read races mimalloc's idle sweep. - async function settledRss() { - let min = Infinity; - for (let i = 0; i < 5; i++) { - gc(true); - await Bun.sleep(50); - min = Math.min(min, process.memoryUsage.rss()); - } - return min; - } for (let i = 0; i < 100; i++) { await import(path); bust(); } - const baseline = await settledRss(); + gc(true); + const baseline = process.memoryUsage.rss(); for (let i = 0; i < 400; i++) { await import(path); bust(path); } - const rss = await settledRss(); + gc(true); + const rss = process.memoryUsage.rss(); const diff = rss - baseline; console.log("RSS diff", (diff / 1024 / 1024) | 0, "MB"); console.log("RSS", (diff / 1024 / 1024) | 0, "MB"); @@ -182,35 +173,29 @@ describe.concurrent("require.cache", () => { function bust() { delete require.cache[path]; } - // Floor over rounds spanning the 100ms sweep rate limit; one - // gc() + immediate read races mimalloc's idle sweep. - async function settledRss() { - let min = Infinity; - for (let i = 0; i < 5; i++) { - gc(true); - await Bun.sleep(50); - min = Math.min(min, process.memoryUsage.rss()); - } - return min; - } - for (let i = 0; i < 50; i++) { + // Same warmup and measured rounds: after #34009 mimalloc's working + // set for this loop settles after ~200-250 iterations, so a 50-iter + // warmup captures baseline below the plateau. + for (let i = 0; i < 250; i++) { await import(path); bust(); } - const baseline = await settledRss(); + gc(true); + const baseline = process.memoryUsage.rss(); for (let i = 0; i < 250; i++) { await import(path); bust(path); } - const rss = await settledRss(); + gc(true); + const rss = process.memoryUsage.rss(); const diff = rss - baseline; console.log("RSS diff", (diff / 1024 / 1024) | 0, "MB"); console.log("RSS", (diff / 1024 / 1024) | 0, "MB"); if (diff > ${isASAN ? 320 : 128} * 1024 * 1024) { // Bun v1.1.21 reported 423 MB here on macoS arm64. - // Bun v1.4.0 (#34009, JSC uses mimalloc) plateaus at ~65 MB on - // macOS 26 arm64 and stays flat for 1000+ further iterations. + // Bun v1.4.0 (#34009) plateaus ~200 MB with heapSize flat + // (allocator retention); leaking the source is ~290 MB/250 iters. throw new Error("Memory leak detected"); } From 7cd128a690111a7e7d3b096e16eb44ac594494ec Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:32:16 +0000 Subject: [PATCH 3/4] Gate on mimalloc live-page growth instead of RSS heapStats({ dump: true }) walks the live page list, so its count is independent of OS page reclamation and the allocator's idle sweep. macOS 26 arm64 (post-#34009, 25 runs): -10 to +26 pages; retaining the module namespaces is +2666. Linux release: -1; Linux debug+ASAN: 0. --- test/cli/run/require-cache.test.ts | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/test/cli/run/require-cache.test.ts b/test/cli/run/require-cache.test.ts index bed583fb8528..f0d1963cbad8 100644 --- a/test/cli/run/require-cache.test.ts +++ b/test/cli/run/require-cache.test.ts @@ -168,34 +168,38 @@ describe.concurrent("require.cache", () => { const dir = tempDirWithFiles("require-cache-bug-leak-4", { "index.js": text, "require-cache-bug-leak-fixture.js": ` + import { heapStats } from "bun:jsc"; const path = require.resolve("./index.js"); const gc = global.gc || globalThis?.Bun?.gc || (() => {}); function bust() { delete require.cache[path]; } + // Live page count from the heap walk (not the aggregate counter, + // which can under-count across threads). + function livePages() { + let n = 0; + for (const h of heapStats({ dump: true }).mimallocDump.heaps) n += h.pages.length; + return n; + } - // Same warmup and measured rounds: after #34009 mimalloc's working - // set for this loop settles after ~200-250 iterations, so a 50-iter - // warmup captures baseline below the plateau. - for (let i = 0; i < 250; i++) { + for (let i = 0; i < 50; i++) { await import(path); bust(); } gc(true); - const baseline = process.memoryUsage.rss(); + const baseline = livePages(); for (let i = 0; i < 250; i++) { await import(path); bust(path); } gc(true); - const rss = process.memoryUsage.rss(); - const diff = rss - baseline; - console.log("RSS diff", (diff / 1024 / 1024) | 0, "MB"); - console.log("RSS", (diff / 1024 / 1024) | 0, "MB"); - if (diff > ${isASAN ? 320 : 128} * 1024 * 1024) { - // Bun v1.1.21 reported 423 MB here on macoS arm64. - // Bun v1.4.0 (#34009) plateaus ~200 MB with heapSize flat - // (allocator retention); leaking the source is ~290 MB/250 iters. + const after = livePages(); + const diff = after - baseline; + console.log("mimalloc page diff", diff, "baseline", baseline, "after", after); + console.log("RSS", (process.memoryUsage.rss() / 1024 / 1024) | 0, "MB"); + if (diff > 100) { + // Bun v1.1.21 leaked the transpiled source here (RSS +423 MB on + // macOS arm64); retaining the module namespace is +2666 pages. throw new Error("Memory leak detected"); } From 92d4661e3ea1409c3fbaccfc06a503a533e2cc7d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 19 Jul 2026 23:55:26 +0000 Subject: [PATCH 4/4] Keep the RSS bound under ASAN The mimalloc heap walk is vacuous under cfg(bun_asan) (global allocator is System and MI_MALLOC_OVERRIDE is off, so SourceProvider storage goes through system malloc). Keep the original 320 MB RSS bound there; use the live-page count on every other build. --- test/cli/run/require-cache.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/test/cli/run/require-cache.test.ts b/test/cli/run/require-cache.test.ts index f0d1963cbad8..e42e739d8f57 100644 --- a/test/cli/run/require-cache.test.ts +++ b/test/cli/run/require-cache.test.ts @@ -187,19 +187,19 @@ describe.concurrent("require.cache", () => { bust(); } gc(true); - const baseline = livePages(); + const baselinePages = livePages(); + const baselineRss = process.memoryUsage.rss(); for (let i = 0; i < 250; i++) { await import(path); bust(path); } gc(true); - const after = livePages(); - const diff = after - baseline; - console.log("mimalloc page diff", diff, "baseline", baseline, "after", after); - console.log("RSS", (process.memoryUsage.rss() / 1024 / 1024) | 0, "MB"); - if (diff > 100) { - // Bun v1.1.21 leaked the transpiled source here (RSS +423 MB on - // macOS arm64); retaining the module namespace is +2666 pages. + const pageDiff = livePages() - baselinePages; + const rssDiff = process.memoryUsage.rss() - baselineRss; + console.log("mimalloc page diff", pageDiff, "RSS diff", (rssDiff / 1024 / 1024) | 0, "MB"); + // ASAN routes SourceProvider storage through system malloc, so fall + // back to RSS there. Retaining the source is ~+290 MB / +2666 pages. + if (${isASAN} ? rssDiff > 320 * 1024 * 1024 : pageDiff > 100) { throw new Error("Memory leak detected"); }