diff --git a/test/js/bun/glob/leak.test.ts b/test/js/bun/glob/leak.test.ts index b9795bf80e70..554972464856 100644 --- a/test/js/bun/glob/leak.test.ts +++ b/test/js/bun/glob/leak.test.ts @@ -1,103 +1,63 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isASAN, tempDir } from "harness"; +import { bunEnv, bunExe, isASAN, isDebug, isMacOS, tempDir } from "harness"; -// ASAN's quarantine retains freed allocations (default 256 MB) so RSS deltas -// run far higher under bun-asan; widen the threshold there. -const thresholdMB = isASAN ? 400 : 100; -const timeout = 60_000; +// Each scan()/scanSync() allocates a Box whose dominant owned +// allocation is a PathBuffer (4 KB on Linux, 1 KB on macOS, ~96 KB on +// Windows). When #29379 regresses, 10k iterations leak ~75-90 MB under a +// Linux ASAN build (quarantine disabled) and 30k leak ~40 MB on macOS +// release, versus a ~15 MB / ~7 MB noise floor with the fix in place. +// macOS keeps 30k even under debug/ASAN: at 1 KB per iteration a 10k run +// would only leak ~13-26 MB and slip under the 30 MB bound locally. +const iterations = (isASAN || isDebug) && !isMacOS ? 10_000 : 30_000; +const warmup = 500; +const thresholdMB = 30; -async function run(dir: string, code: string) { - await using proc = Bun.spawn({ - cmd: [bunExe(), "--smol", "-e", code], - cwd: dir, - env: bunEnv, - stdio: ["inherit", "inherit", "inherit"], - }); - expect(await proc.exited).toBe(0); -} +const cases = [ + { name: "scanSync", pattern: "**/*", files: { "a.txt": "", "b.txt": "", "sub/c.txt": "" }, sync: true }, + { name: "scan", pattern: "**/*", files: { "a.txt": "", "b.txt": "", "sub/c.txt": "" }, sync: false }, + { name: "scanSync does not leak GlobWalker struct", pattern: "*.txt", files: { "a.txt": "" }, sync: true }, + { name: "scan does not leak GlobWalker struct", pattern: "*.txt", files: { "a.txt": "" }, sync: false }, +] as const; describe("leaks", () => { - test.concurrent( - "scanSync", - async () => { - using dir = tempDir("glob-leak-scansync", { "a.txt": "", "b.txt": "", "sub/c.txt": "" }); - await run( - String(dir), - /* ts */ ` - const glob = new Bun.Glob("**/*"); - for (let i = 0; i < 1000; i++) Array.from(glob.scanSync()); - Bun.gc(true); - const before = process.memoryUsage.rss(); - for (let i = 0; i < 100000; i++) Array.from(glob.scanSync()); - Bun.gc(true); - const growthMB = (process.memoryUsage.rss() - before) / 1024 / 1024; - if (growthMB > ${thresholdMB}) throw new Error("leaked " + growthMB.toFixed(2) + "MB"); - `, - ); - }, - timeout, - ); - - test.concurrent( - "scan", - async () => { - using dir = tempDir("glob-leak-scan", { "a.txt": "", "b.txt": "", "sub/c.txt": "" }); - await run( - String(dir), - /* ts */ ` - const glob = new Bun.Glob("**/*"); - for (let i = 0; i < 1000; i++) await Array.fromAsync(glob.scan()); - Bun.gc(true); - const before = process.memoryUsage.rss(); - for (let i = 0; i < 100000; i++) await Array.fromAsync(glob.scan()); - Bun.gc(true); - const growthMB = (process.memoryUsage.rss() - before) / 1024 / 1024; - if (growthMB > ${thresholdMB}) throw new Error("leaked " + growthMB.toFixed(2) + "MB"); - `, - ); - }, - timeout, - ); - - test.concurrent( - "scanSync does not leak GlobWalker struct", - async () => { - using dir = tempDir("glob-struct-leak-sync", { "a.txt": "" }); - await run( - String(dir), - /* ts */ ` - const glob = new Bun.Glob("*.txt"); - for (let i = 0; i < 1000; i++) Array.from(glob.scanSync()); - Bun.gc(true); - const before = process.memoryUsage.rss(); - for (let i = 0; i < 100000; i++) Array.from(glob.scanSync()); - Bun.gc(true); - const growthMB = (process.memoryUsage.rss() - before) / 1024 / 1024; - if (growthMB > ${thresholdMB}) throw new Error("leaked " + growthMB.toFixed(2) + "MB"); - `, - ); - }, - timeout, - ); - - test.concurrent( - "scan does not leak GlobWalker struct", - async () => { - using dir = tempDir("glob-struct-leak-async", { "a.txt": "" }); - await run( - String(dir), - /* ts */ ` - const glob = new Bun.Glob("*.txt"); - for (let i = 0; i < 1000; i++) await Array.fromAsync(glob.scan()); - Bun.gc(true); - const before = process.memoryUsage.rss(); - for (let i = 0; i < 100000; i++) await Array.fromAsync(glob.scan()); - Bun.gc(true); - const growthMB = (process.memoryUsage.rss() - before) / 1024 / 1024; - if (growthMB > ${thresholdMB}) throw new Error("leaked " + growthMB.toFixed(2) + "MB"); - `, - ); - }, - timeout, - ); + for (const { name, pattern, files, sync } of cases) { + test.concurrent( + name, + async () => { + using dir = tempDir(`glob-leak-${name.replace(/\W+/g, "-")}`, files); + const drain = sync ? "Array.from(glob.scanSync())" : "await Array.fromAsync(glob.scan())"; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--smol", + "-e", + /* ts */ ` + const glob = new Bun.Glob(${JSON.stringify(pattern)}); + for (let i = 0; i < ${warmup}; i++) ${drain}; + Bun.gc(true); + const before = process.memoryUsage.rss(); + for (let i = 0; i < ${iterations}; i++) ${drain}; + Bun.gc(true); + console.log(((process.memoryUsage.rss() - before) / 1024 / 1024).toFixed(2)); + `, + ], + cwd: String(dir), + env: { + ...bunEnv, + // ASAN parks freed allocations in a quarantine (~256 MB by default) + // which swamps the RSS signal; disable it so one threshold holds for + // every build. Harmless when the binary is not ASAN-instrumented. + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "quarantine_size_mb=0"].filter(Boolean).join(":"), + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const growthMB = Number(stdout.trim()); + expect({ stderr, growthMB, exitCode }).toEqual({ stderr: "", growthMB: expect.any(Number), exitCode: 0 }); + expect(growthMB).toBeLessThan(thresholdMB); + }, + 60_000, + ); + } });