Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 57 additions & 97 deletions test/js/bun/glob/leak.test.ts
Original file line number Diff line number Diff line change
@@ -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<GlobWalker> 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,
);
}
});
Loading