From ca8e44ecf684fedc4fa16ec57a33cec5b1170b38 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:56:08 +0000 Subject: [PATCH 1/4] test(require-cache): measure allocator-live bytes in the source code leak fixtures The four "don't leak the output source code" fixtures compared RSS before and after the measured loop. Each load of index.js creates a ~2 MB CodeBlock metadata table that is reported to the GC while collection is deferred, so whether a collection runs between loads depends on concurrent JIT timing, and mimalloc keeps freed pages mapped for a while afterwards. On a loaded machine the require() fixture reported RSS diffs of 60-180 MB against a 64 MB bound with nothing retained. Measure the bytes mimalloc currently has handed out (heapStats({ dump: true })) instead, through one prelude/check shared by the four fixtures. A retained copy of the transpiled output per load shows up as 44 MB or more; the noise floor is about 3 MB; the bound is 16 MB. ASAN builds allocate through the system allocator and keep the previous RSS bounds. Drop the todoIf on the require() function-calls fixture: the macOS and musl-aarch64 failures it named were RSS readings of the same kind. --- test/cli/run/require-cache.test.ts | 180 +++++++++++++---------------- 1 file changed, 80 insertions(+), 100 deletions(-) diff --git a/test/cli/run/require-cache.test.ts b/test/cli/run/require-cache.test.ts index 1163eb9804bd..a60e02a39a04 100644 --- a/test/cli/run/require-cache.test.ts +++ b/test/cli/run/require-cache.test.ts @@ -1,19 +1,55 @@ import { describe, expect, test } from "bun:test"; -import { - bunEnv, - bunExe, - isArm64, - isASAN, - isBroken, - isCI, - isIntelMacOS, - isMacOS, - isMusl, - isWindows, - tempDir, -} from "harness"; +import { bunEnv, bunExe, isArm64, isASAN, isBroken, isIntelMacOS, isWindows, tempDir } from "harness"; import { join } from "path"; +// Shared by the "don't leak the output source code" fixtures below. +// +// The leak they guard against (the transpiled source, or the whole module, kept +// alive once per load) is measured as the bytes mimalloc currently has handed +// out, summed over every heap; JSC allocates through mimalloc as well. RSS is +// not a usable signal for these loops: each load of index.js creates a ~2 MB +// CodeBlock metadata table that is reported to the GC while collection is +// deferred, so whether a collection actually runs between loads depends on +// concurrent JIT timing, and mimalloc keeps pages mapped for a while after the +// blocks in them are freed. Together those moved RSS by 60-180 MB between two +// gc(true) calls with nothing retained (the alpine x64 and macOS CI failures of +// this file), while liveBytes() stayed within 4 MB. +// +// ASAN builds allocate through the system allocator, which the heap walk does +// not see, so they keep the RSS bound. +const leakFixturePrelude = ` + const { heapStats } = require("bun:jsc"); + const gc = global.gc || globalThis?.Bun?.gc || (() => {}); + const rss = process.platform === "darwin" && typeof Bun !== "undefined" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint : process.memoryUsage.rss; + function liveBytes() { + let bytes = 0; + for (const heap of heapStats({ dump: true }).mimallocDump.heaps) { + for (const page of heap.pages) bytes += page.block_size * page.used; + } + return bytes; + } + function measure() { + gc(true); + return { live: liveBytes(), rss: rss() }; + } +`; + +// Leaking one copy of index.js's output per load adds at least ~44 MB in the +// smallest fixture (100 KB x 400 loads; the long-export-name fixtures add +// hundreds of MB). The noise floor is the most recently loaded module's +// unlinked bytecode occasionally surviving gc(true): ~3 MB. +function leakFixtureCheck(asanRssMB: number) { + return ` + const after = measure(); + const liveDiff = after.live - baseline.live; + const rssDiff = after.rss - baseline.rss; + console.log("live diff", (liveDiff / 1024 / 1024).toFixed(1), "MB,", "RSS diff", (rssDiff / 1024 / 1024) | 0, "MB"); + if (${isASAN} ? rssDiff > ${asanRssMB} * 1024 * 1024 : liveDiff > 16 * 1024 * 1024) { + throw new Error("Memory leak detected"); + } +`; +} + describe.concurrent("require.cache", () => { test("require.cache is not an empty object literal when inspected", () => { const inspected = Bun.inspect(require.cache); @@ -62,9 +98,8 @@ describe.concurrent("require.cache", () => { await using dir = tempDir("require-cache-bug-leak-1", { "index.js": text, "require-cache-bug-leak-fixture.js": ` + ${leakFixturePrelude} const path = require.resolve("./index.js"); - const gc = global.gc || globalThis?.Bun?.gc || (() => {}); - const rss = process.platform === "darwin" && typeof Bun !== "undefined" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint : process.memoryUsage.rss; const noChildren = module.children = { indexOf() { return 0; } }; // disable children tracking function bust() { const mod = require.cache[path]; @@ -79,22 +114,12 @@ describe.concurrent("require.cache", () => { require(path); bust(); } - gc(true); - const baseline = rss(); + const baseline = measure(); for (let i = 0; i < 500; i++) { require(path); bust(path); } - gc(true); - const after = rss(); - const diff = after - baseline; - console.log("RSS diff", (diff / 1024 / 1024) | 0, "MB"); - console.log("RSS", (diff / 1024 / 1024) | 0, "MB"); - if (diff > ${isASAN ? 400 : 100} * 1024 * 1024) { - // Bun v1.1.21 reported 844 MB here on macOS arm64. - throw new Error("Memory leak detected"); - } - + ${leakFixtureCheck(400)} exports.abc = 123; `, }); @@ -121,9 +146,8 @@ describe.concurrent("require.cache", () => { await using dir = tempDir("require-cache-bug-leak-3", { "index.js": text, "require-cache-bug-leak-fixture.js": ` + ${leakFixturePrelude} const path = require.resolve("./index.js"); - const gc = global.gc || globalThis?.Bun?.gc || (() => {}); - const rss = process.platform === "darwin" && typeof Bun !== "undefined" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint : process.memoryUsage.rss; function bust() { delete require.cache[path]; } @@ -132,23 +156,12 @@ describe.concurrent("require.cache", () => { await import(path); bust(); } - gc(true); - const baseline = rss(); + const baseline = measure(); for (let i = 0; i < 400; i++) { await import(path); bust(path); } - gc(true); - const after = rss(); - const diff = after - 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) { - // Bun v1.1.22 reported 1 MB here on macoS arm64. - // Bun v1.1.21 reported 257 MB here on macoS arm64. - throw new Error("Memory leak detected"); - } - + ${leakFixtureCheck(320)} export default 123; `, }); @@ -171,9 +184,8 @@ describe.concurrent("require.cache", () => { await using dir = tempDir("require-cache-bug-leak-4", { "index.js": text, "require-cache-bug-leak-fixture.js": ` + ${leakFixturePrelude} const path = require.resolve("./index.js"); - const gc = global.gc || globalThis?.Bun?.gc || (() => {}); - const rss = process.platform === "darwin" && typeof Bun !== "undefined" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint : process.memoryUsage.rss; function bust() { delete require.cache[path]; } @@ -182,23 +194,12 @@ describe.concurrent("require.cache", () => { await import(path); bust(); } - gc(true); - const baseline = rss(); + const baseline = measure(); for (let i = 0; i < 250; i++) { await import(path); bust(path); } - gc(true); - const after = rss(); - const diff = after - 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) { - // Bun v1.1.21 reported 423 MB here on macoS arm64. - // Bun v1.1.22 reported 4 MB here on macoS arm64. - throw new Error("Memory leak detected"); - } - + ${leakFixtureCheck(320)} export default 124; `, }); @@ -213,28 +214,20 @@ describe.concurrent("require.cache", () => { expect(exitCode).toBe(0); }, 60000); - test.todoIf( - // Flaky specifically on macOS CI, and on musl-aarch64 under ThinLTO + - // -Zshare-generics where RSS reports ~280 MB for the same workload - // that measures under 64 MB elsewhere (intermittent). - isBroken && isCI && (isMacOS || (isMusl && isArm64)), - )( - "via require() with a lot of function calls", - async () => { - let text = "function i() { return 1; }\n"; - for (let i = 0; i < 20000; i++) { - text += `i();\n`; - } - text += "exports.forceCommonJS = true;\n"; + test("via require() with a lot of function calls", async () => { + let text = "function i() { return 1; }\n"; + for (let i = 0; i < 20000; i++) { + text += `i();\n`; + } + text += "exports.forceCommonJS = true;\n"; - console.log("Text length:", text.length); + console.log("Text length:", text.length); - await using dir = tempDir("require-cache-bug-leak-2", { - "index.js": text, - "require-cache-bug-leak-fixture.js": ` + await using dir = tempDir("require-cache-bug-leak-2", { + "index.js": text, + "require-cache-bug-leak-fixture.js": ` + ${leakFixturePrelude} const path = require.resolve("./index.js"); - const gc = global.gc || globalThis?.Bun?.gc || (() => {}); - const rss = process.platform === "darwin" && typeof Bun !== "undefined" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint : process.memoryUsage.rss; function bust() { const mod = require.cache[path]; if (mod) { @@ -248,37 +241,24 @@ describe.concurrent("require.cache", () => { require(path); bust(); } - gc(true); - const baseline = rss(); + const baseline = measure(); for (let i = 0; i < 400; i++) { require(path); bust(path); } - gc(true); - const after = rss(); - const diff = after - 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) { - // Bun v1.1.22 reported 4 MB here on macoS arm64. - // Bun v1.1.21 reported 248 MB here on macoS arm64. - throw new Error("Memory leak detected"); - } - + ${leakFixtureCheck(320)} exports.abc = 123; `, - }); - await using proc = Bun.spawn({ - cmd: [bunExe(), "run", "--smol", join(dir, "require-cache-bug-leak-fixture.js")], - env: bunEnv, - stdio: ["inherit", "inherit", "inherit"], - }); + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", "--smol", join(dir, "require-cache-bug-leak-fixture.js")], + env: bunEnv, + stdio: ["inherit", "inherit", "inherit"], + }); - const exitCode = await proc.exited; - expect(exitCode).toBe(0); - }, - 60000, - ); // takes 4s on an M1 in release build + const exitCode = await proc.exited; + expect(exitCode).toBe(0); + }, 60000); // takes 4s on an M1 in release build }); describe("files transpiled and loaded don't leak the AST", () => { From 8c08df7cda724263c3ec6ce8db396ee12fa6f2ba Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:37:16 +0000 Subject: [PATCH 2/4] test(require-cache): widen the live-bytes bound to 24 MB for the aarch64 noise floor The first CI run showed the concurrent JIT holding up to 6.4 MB of the last load's compilation state on the aarch64 lanes at the second measurement (3.5 MB on x64). The smallest leak these fixtures catch adds 44 MB. --- test/cli/run/require-cache.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test/cli/run/require-cache.test.ts b/test/cli/run/require-cache.test.ts index a60e02a39a04..a34c7a08a52c 100644 --- a/test/cli/run/require-cache.test.ts +++ b/test/cli/run/require-cache.test.ts @@ -11,9 +11,9 @@ import { join } from "path"; // CodeBlock metadata table that is reported to the GC while collection is // deferred, so whether a collection actually runs between loads depends on // concurrent JIT timing, and mimalloc keeps pages mapped for a while after the -// blocks in them are freed. Together those moved RSS by 60-180 MB between two -// gc(true) calls with nothing retained (the alpine x64 and macOS CI failures of -// this file), while liveBytes() stayed within 4 MB. +// blocks in them are freed. Together those moved RSS by 60-500 MB between two +// gc(true) calls with nothing retained (the alpine and macOS CI failures of this +// file), while liveBytes() stayed within 7 MB. // // ASAN builds allocate through the system allocator, which the heap walk does // not see, so they keep the RSS bound. @@ -36,15 +36,17 @@ const leakFixturePrelude = ` // Leaking one copy of index.js's output per load adds at least ~44 MB in the // smallest fixture (100 KB x 400 loads; the long-export-name fixtures add -// hundreds of MB). The noise floor is the most recently loaded module's -// unlinked bytecode occasionally surviving gc(true): ~3 MB. +// hundreds of MB). The noise floor is one load's worth of compilation state +// that the concurrent JIT thread has not let go of yet when the second +// measurement is taken (it never shows up with BUN_JSC_useConcurrentJIT=0): +// ~3.5 MB on x64 and up to ~6.5 MB on aarch64 in CI. function leakFixtureCheck(asanRssMB: number) { return ` const after = measure(); const liveDiff = after.live - baseline.live; const rssDiff = after.rss - baseline.rss; console.log("live diff", (liveDiff / 1024 / 1024).toFixed(1), "MB,", "RSS diff", (rssDiff / 1024 / 1024) | 0, "MB"); - if (${isASAN} ? rssDiff > ${asanRssMB} * 1024 * 1024 : liveDiff > 16 * 1024 * 1024) { + if (${isASAN} ? rssDiff > ${asanRssMB} * 1024 * 1024 : liveDiff > 24 * 1024 * 1024) { throw new Error("Memory leak detected"); } `; From 8fdeb81a9bc83f0585b1e3d181d6aff9eb50671c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:31:32 +0000 Subject: [PATCH 3/4] test(require-cache): probe whether the allocator walk sees JSC before trusting it Whether JSC allocates through the mimalloc heaps that heapStats({ dump: true }) walks depends on the WebKit build: release prebuilts do, ASAN builds and WebKit built without USE_MIMALLOC (the debug prebuilts, local builds) do not. On the latter the live-bytes comparison reads 0.0 MB no matter what is retained. Instead of keying the fallback on isASAN, the prelude allocates a flat 32 MB JS string and checks that the walk saw it; if not, the fixture compares RSS at the bound those builds were already using and says so in its output. The check also prints the absolute live figures next to the diff. --- test/cli/run/require-cache.test.ts | 55 +++++++++++++++++++----------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/test/cli/run/require-cache.test.ts b/test/cli/run/require-cache.test.ts index a34c7a08a52c..99691db3d555 100644 --- a/test/cli/run/require-cache.test.ts +++ b/test/cli/run/require-cache.test.ts @@ -1,26 +1,39 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isArm64, isASAN, isBroken, isIntelMacOS, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, isArm64, isBroken, isIntelMacOS, isWindows, tempDir } from "harness"; import { join } from "path"; // Shared by the "don't leak the output source code" fixtures below. // // The leak they guard against (the transpiled source, or the whole module, kept // alive once per load) is measured as the bytes mimalloc currently has handed -// out, summed over every heap; JSC allocates through mimalloc as well. RSS is -// not a usable signal for these loops: each load of index.js creates a ~2 MB -// CodeBlock metadata table that is reported to the GC while collection is -// deferred, so whether a collection actually runs between loads depends on -// concurrent JIT timing, and mimalloc keeps pages mapped for a while after the -// blocks in them are freed. Together those moved RSS by 60-500 MB between two -// gc(true) calls with nothing retained (the alpine and macOS CI failures of this -// file), while liveBytes() stayed within 7 MB. +// out, summed over every heap; in release builds JSC allocates through mimalloc +// as well. RSS is not a usable signal for these loops: each load of index.js +// creates a ~2 MB CodeBlock metadata table that is reported to the GC while +// collection is deferred, so whether a collection actually runs between loads +// depends on concurrent JIT timing, and mimalloc keeps pages mapped for a while +// after the blocks in them are freed. Together those moved RSS by 60-500 MB +// between two gc(true) calls with nothing retained (the alpine and macOS CI +// failures of this file), while liveBytes() stayed within 7 MB. // -// ASAN builds allocate through the system allocator, which the heap walk does -// not see, so they keep the RSS bound. +// Leaking one copy of index.js's output per load adds at least ~44 MB in the +// smallest fixture (100 KB x 400 loads; the long-export-name fixtures add +// hundreds of MB). The noise floor is one load's worth of compilation state +// that the concurrent JIT thread has not let go of yet when the second +// measurement is taken (it never shows up with BUN_JSC_useConcurrentJIT=0): +// ~3.5 MB on x64 and up to ~6.5 MB on aarch64 in CI. +// +// Whether JSC's allocations are in the heaps the walk sees is a property of the +// WebKit build (ASAN builds and WebKit built without USE_MIMALLOC, e.g. the +// debug prebuilts, use another allocator), so the prelude checks it with a flat +// JS string of 32 MB, the same kind of allocation as a retained source. Where +// that string is invisible the fixture falls back to comparing RSS, at the +// looser bound those builds were already using. const leakFixturePrelude = ` const { heapStats } = require("bun:jsc"); const gc = global.gc || globalThis?.Bun?.gc || (() => {}); const rss = process.platform === "darwin" && typeof Bun !== "undefined" && typeof Bun.unsafe.memoryFootprint === "function" ? Bun.unsafe.memoryFootprint : process.memoryUsage.rss; + const MB = 1024 * 1024; + const maxLiveGrowth = 24 * MB; function liveBytes() { let bytes = 0; for (const heap of heapStats({ dump: true }).mimallocDump.heaps) { @@ -28,25 +41,27 @@ const leakFixturePrelude = ` } return bytes; } + const liveBeforeProbe = liveBytes(); + let probe = "a".repeat(32 * MB); + const walkSeesJSC = liveBytes() - liveBeforeProbe >= maxLiveGrowth; + probe = undefined; function measure() { gc(true); return { live: liveBytes(), rss: rss() }; } `; -// Leaking one copy of index.js's output per load adds at least ~44 MB in the -// smallest fixture (100 KB x 400 loads; the long-export-name fixtures add -// hundreds of MB). The noise floor is one load's worth of compilation state -// that the concurrent JIT thread has not let go of yet when the second -// measurement is taken (it never shows up with BUN_JSC_useConcurrentJIT=0): -// ~3.5 MB on x64 and up to ~6.5 MB on aarch64 in CI. -function leakFixtureCheck(asanRssMB: number) { +function leakFixtureCheck(fallbackRssMB: number) { return ` const after = measure(); const liveDiff = after.live - baseline.live; const rssDiff = after.rss - baseline.rss; - console.log("live diff", (liveDiff / 1024 / 1024).toFixed(1), "MB,", "RSS diff", (rssDiff / 1024 / 1024) | 0, "MB"); - if (${isASAN} ? rssDiff > ${asanRssMB} * 1024 * 1024 : liveDiff > 24 * 1024 * 1024) { + console.log( + "live", (baseline.live / MB).toFixed(1), "->", (after.live / MB).toFixed(1), "MB (diff", (liveDiff / MB).toFixed(1), "MB),", + "RSS diff", (rssDiff / MB) | 0, "MB,", + walkSeesJSC ? "checking live bytes" : "allocator walk does not see JSC allocations in this build, checking RSS", + ); + if (walkSeesJSC ? liveDiff > maxLiveGrowth : rssDiff > ${fallbackRssMB} * MB) { throw new Error("Memory leak detected"); } `; From 69194d47924a60cc0203526f3a5118a3a522b7ff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 10:47:04 +0000 Subject: [PATCH 4/4] test(require-cache): say why the probe uses a single-character repeat --- test/cli/run/require-cache.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/cli/run/require-cache.test.ts b/test/cli/run/require-cache.test.ts index 99691db3d555..5dff21d6a798 100644 --- a/test/cli/run/require-cache.test.ts +++ b/test/cli/run/require-cache.test.ts @@ -42,6 +42,8 @@ const leakFixturePrelude = ` return bytes; } const liveBeforeProbe = liveBytes(); + // Not Buffer#toString(): that string does not register in the walk even where a source + // would. A single-character repeat is a flat WTF string and a memset, also in debug builds. let probe = "a".repeat(32 * MB); const walkSeesJSC = liveBytes() - liveBeforeProbe >= maxLiveGrowth; probe = undefined;