111 lines
4.5 KiB
TypeScript
111 lines
4.5 KiB
TypeScript
import { Archive, spawn, write } from "bun";
|
||
import { beforeAll, describe, expect, test } from "bun:test";
|
||
import { rm } from "fs/promises";
|
||
import { bunEnv, bunExe, isMacOS, isWindows, readdirSorted, tempDir } from "harness";
|
||
import { join } from "path";
|
||
|
||
// Installing a package with many files must not exhaust the file-descriptor
|
||
// table. Runs the linker × backend matrix from a local tarball (no registry),
|
||
// then re-installs after invalidating the existing copy to cover the
|
||
// uninstall-then-reinstall path.
|
||
|
||
const NUM_FILES = 1500;
|
||
const FD_LIMIT = 1024;
|
||
|
||
const LINKERS = ["isolated", "hoisted"] as const;
|
||
const BACKENDS = (["copyfile", "hardlink"] as const).concat(isMacOS ? (["clonefile"] as const) : []);
|
||
|
||
// `ulimit` isn't available on Windows; skip the matrix there.
|
||
describe.skipIf(isWindows)("install does not leak file descriptors", () => {
|
||
let tgz: string;
|
||
|
||
beforeAll(async () => {
|
||
const pkgFiles: Record<string, string> = {
|
||
"package/package.json": JSON.stringify({ name: "many-files-pkg", version: "1.0.0" }),
|
||
};
|
||
for (let i = 0; i < NUM_FILES; i++) {
|
||
pkgFiles[`package/f${i}.js`] = `module.exports=${i};`;
|
||
}
|
||
|
||
// Don't `using` here: the tarball must outlive beforeAll.
|
||
const tgzDir = tempDir("fd-leak-tgz", {});
|
||
tgz = join(String(tgzDir), "many-files.tgz");
|
||
await Archive.write(tgz, pkgFiles, { compress: "gzip" });
|
||
});
|
||
|
||
for (const linker of LINKERS) {
|
||
for (const backend of BACKENDS) {
|
||
test.concurrent(`--linker=${linker} --backend=${backend}`, async () => {
|
||
using projDir = tempDir(`fd-leak-${linker}-${backend}`, {
|
||
"package.json": JSON.stringify({
|
||
name: "fd-leak-proj",
|
||
dependencies: { "many-files-pkg": `file:${tgz}` },
|
||
}),
|
||
});
|
||
// Per-test cache so cache state can't leak between matrix entries, and
|
||
// so cache → node_modules stays on the same filesystem (otherwise
|
||
// hardlink/clonefile silently fall back to copyfile and the matrix
|
||
// doesn't actually vary).
|
||
using cacheDir = tempDir(`fd-leak-cache-${linker}-${backend}`, {});
|
||
|
||
const installed =
|
||
linker === "isolated"
|
||
? async () => {
|
||
const storeDir = (await readdirSorted(join(String(projDir), "node_modules", ".bun"))).find(d =>
|
||
d.startsWith("many-files-pkg@"),
|
||
);
|
||
expect(storeDir).toBeDefined();
|
||
return join(String(projDir), "node_modules", ".bun", storeDir!, "node_modules", "many-files-pkg");
|
||
}
|
||
: async () => join(String(projDir), "node_modules", "many-files-pkg");
|
||
|
||
const install = async () => {
|
||
// Run with a low hard NOFILE limit. Bun raises the soft limit up to
|
||
// the hard limit at startup, so the hard limit is what bounds us.
|
||
// Without per-file fd cleanup, ~750 copyfile copies (2 fds each)
|
||
// exceed it.
|
||
await using proc = spawn({
|
||
cmd: [
|
||
"bash",
|
||
"-c",
|
||
`ulimit -Sn ${FD_LIMIT} && ulimit -Hn ${FD_LIMIT} && exec "$1" install --linker="$2" --backend="$3"`,
|
||
"bash",
|
||
bunExe(),
|
||
linker,
|
||
backend,
|
||
],
|
||
cwd: String(projDir),
|
||
env: { ...bunEnv, BUN_INSTALL_CACHE_DIR: String(cacheDir) },
|
||
stdout: "pipe",
|
||
stderr: "pipe",
|
||
});
|
||
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
|
||
expect(stderr).not.toContain("EMFILE");
|
||
expect(exitCode).toBe(0);
|
||
// Confirm the copy actually happened — every file must land.
|
||
const files = await readdirSorted(await installed());
|
||
expect(files.length).toBe(NUM_FILES + 1); // + package.json
|
||
};
|
||
|
||
// Cold install: extract tarball → cache → node_modules.
|
||
await install();
|
||
|
||
// Invalidate the existing install so the next install must delete and
|
||
// re-copy from the warm cache. The verifier looks at different things
|
||
// per linker.
|
||
if (linker === "isolated") {
|
||
await rm(join(String(projDir), "node_modules", ".bun"), { recursive: true, force: true });
|
||
} else {
|
||
await write(
|
||
join(await installed(), "package.json"),
|
||
JSON.stringify({ name: "many-files-pkg", version: "0.0.0-stale" }),
|
||
);
|
||
}
|
||
|
||
// Warm reinstall: cache → uninstall → node_modules.
|
||
await install();
|
||
});
|
||
}
|
||
}
|
||
});
|