Files
bun-src/test/cli/run/transpiler-cache.test.ts
2026-08-27 21:09:14 +00:00

407 lines
16 KiB
TypeScript

import { Subprocess } from "bun";
import { beforeEach, describe, expect, test } from "bun:test";
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs";
import { bunEnv, bunExe, bunRun, tmpdirSync } from "harness";
import { join } from "path";
function dummyFile(size: number, cache_bust: string, value: string | { code: string }) {
const data = Buffer.alloc(size);
data.write("/*" + cache_bust);
const end = `*/\nconsole.log(${(value as any).code ?? JSON.stringify(value)});`;
data.fill("*", 2 + cache_bust.length, size - end.length, "utf-8");
data.write(end, size - end.length, "utf-8");
return data;
}
let temp_dir: string = "";
let cache_dir = "";
const env = {
...bunEnv,
BUN_RUNTIME_TRANSPILER_CACHE_PATH: cache_dir,
BUN_DEBUG_ENABLE_RESTORE_FROM_TRANSPILER_CACHE: "1",
};
let prev_cache_count = 0;
function newCacheCount() {
let new_count = readdirSync(cache_dir).length;
let delta = new_count - prev_cache_count;
prev_cache_count = new_count;
return delta;
}
function removeCache() {
prev_cache_count = 0;
try {
rmSync(cache_dir, { recursive: true, force: true });
} catch (error) {
chmodSync(cache_dir, 0o777);
readdirSync(cache_dir).forEach(item => {
chmodSync(join(cache_dir, item), 0o777);
});
rmSync(cache_dir, { recursive: true, force: true });
}
}
beforeEach(() => {
if (cache_dir) {
rmSync(temp_dir, { recursive: true, force: true });
removeCache();
}
temp_dir = tmpdirSync();
mkdirSync(temp_dir, { recursive: true });
temp_dir = realpathSync(temp_dir);
cache_dir = join(temp_dir, ".cache");
env.BUN_RUNTIME_TRANSPILER_CACHE_PATH = cache_dir;
});
describe("transpiler cache", () => {
test("works", async () => {
writeFileSync(join(temp_dir, "a.js"), dummyFile((50 * 1024 * 1.5) | 0, "1", "a"));
expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("a");
expect(existsSync(cache_dir)).toBeTrue();
expect(newCacheCount()).toBe(1);
expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("a");
expect(newCacheCount()).toBe(0);
});
test("works with empty files", async () => {
writeFileSync(join(temp_dir, "a.js"), "//" + "a".repeat(50 * 1024 * 1.5));
expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("");
expect(existsSync(cache_dir)).toBeTrue();
expect(newCacheCount()).toBe(1);
expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("");
expect(newCacheCount()).toBe(0);
});
test("ignores files under the minimum cache size", async () => {
// MINIMUM_CACHE_SIZE is 4 KiB (src/jsc/RuntimeTranspilerCache.rs); files
// below it skip the cache entirely so a stat+open+read can't be slower than
// just re-transpiling.
writeFileSync(join(temp_dir, "a.js"), dummyFile(4 * 1024 - 1, "1", "a"));
expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("a");
expect(!existsSync(cache_dir)).toBeTrue();
});
test("it is indeed content addressable", async () => {
writeFileSync(join(temp_dir, "a.js"), dummyFile(50 * 1024, "1", "b"));
expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("b");
expect(newCacheCount()).toBe(1);
writeFileSync(join(temp_dir, "a.js"), dummyFile(50 * 1024, "1", "c"));
expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("c");
expect(newCacheCount()).toBe(1);
writeFileSync(join(temp_dir, "b.js"), dummyFile(50 * 1024, "1", "b"));
expect(await bunRun(join(temp_dir, "b.js"), env)).toSpawn("b");
expect(newCacheCount()).toBe(0);
});
test("doing 50 buns at once does not crash", async () => {
writeFileSync(join(temp_dir, "a.js"), dummyFile(50 * 1024, "1", "b"));
writeFileSync(join(temp_dir, "b.js"), dummyFile(50 * 1024, "2", "b"));
const remover = Bun.spawn({
cmd: [bunExe(), join(import.meta.dir, "transpiler-cache-aggressive-remover.js"), cache_dir],
env,
cwd: temp_dir,
});
let processes: Subprocess<"ignore", "pipe", "inherit">[] = [];
let killing = false;
for (let i = 0; i < 50; i++) {
processes.push(
Bun.spawn({
cmd: [bunExe(), i % 2 == 0 ? "a.js" : "b.js"],
env,
cwd: temp_dir,
onExit(subprocess, exitCode, signalCode, error) {
if (exitCode != 0 && !killing) {
killing = true;
processes.forEach(x => x.kill(9));
remover.kill(9);
}
},
}),
);
}
await Promise.all(processes.map(x => x.exited));
expect(!killing).toBeTrue();
remover.kill(9);
for (const proc of processes) {
expect(proc.exitCode).toBe(0);
expect(await proc.stdout.text()).toBe("b\n");
}
}, 99999999);
test("disables the cache instead of falling back to the shared temp directory", async () => {
writeFileSync(join(temp_dir, "a.js"), dummyFile((50 * 1024 * 1.5) | 0, "1", "no-tmpdir-cache"));
// Stand-in for the shared, world-writable system temp dir. Pre-create
// bun/@t@ inside it the way another local user could on a multi-user host.
const shared_tmp = join(temp_dir, "shared-tmp");
const shared_cache = join(shared_tmp, "bun", "@t@");
mkdirSync(shared_cache, { recursive: true });
// No per-user cache location is available (no BUN_RUNTIME_TRANSPILER_CACHE_PATH,
// no XDG_CACHE_HOME, no HOME) — the only remaining candidate is the shared
// temp dir, so the cache must be disabled instead of using it.
expect(
await bunRun(join(temp_dir, "a.js"), {
...env,
BUN_RUNTIME_TRANSPILER_CACHE_PATH: undefined,
XDG_CACHE_HOME: undefined,
HOME: undefined,
USERPROFILE: undefined,
BUN_TMPDIR: undefined,
TMPDIR: shared_tmp,
TMP: shared_tmp,
TEMP: shared_tmp,
}),
).toSpawn("no-tmpdir-cache");
// No cache entry may be written into (or read back from) a directory that
// another local user could own and pre-populate.
expect(readdirSync(shared_cache)).toEqual([]);
// A per-user cache location still works.
expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("no-tmpdir-cache");
expect(newCacheCount()).toBe(1);
});
test("works if the cache is not user-readable", async () => {
mkdirSync(cache_dir, { recursive: true });
writeFileSync(join(temp_dir, "a.js"), dummyFile((50 * 1024 * 1.5) | 0, "1", "b"));
expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("b");
expect(newCacheCount()).toBe(1);
const cache_item = readdirSync(cache_dir)[0];
chmodSync(join(cache_dir, cache_item), 0);
expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("b");
expect(newCacheCount()).toBe(0);
chmodSync(join(cache_dir), "0");
try {
expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("b");
} finally {
chmodSync(join(cache_dir), "777");
}
});
test("works if the cache is not user-writable", async () => {
mkdirSync(cache_dir, { recursive: true });
writeFileSync(join(temp_dir, "a.js"), dummyFile((50 * 1024 * 1.5) | 0, "1", "b"));
try {
chmodSync(join(cache_dir), "0");
expect(await bunRun(join(temp_dir, "a.js"), env)).toSpawn("b");
} finally {
chmodSync(join(cache_dir), "777");
}
});
test("does not inline process.env", async () => {
writeFileSync(
join(temp_dir, "a.js"),
dummyFile((50 * 1024 * 1.5) | 0, "1", { code: "process.env.NODE_ENV, process.env.HELLO" }),
);
expect(await bunRun(join(temp_dir, "a.js"), { ...env, NODE_ENV: undefined, HELLO: "1" })).toSpawn("undefined 1");
expect(existsSync(cache_dir)).toBeTrue();
expect(newCacheCount()).toBe(1);
expect(await bunRun(join(temp_dir, "a.js"), { ...env, NODE_ENV: "production", HELLO: "5" })).toSpawn(
"production 5",
);
expect(newCacheCount()).toBe(0);
});
test("--feature flag invalidates cache", () => {
// feature() can only appear in an if/ternary, so wrap it
const code = `import { feature } from "bun:bundle";\nif (feature("SUPER_SECRET")) console.log("enabled"); else console.log("disabled");`;
const filler = Buffer.alloc((50 * 1024 * 1.5) | 0, "/").toString();
writeFileSync(join(temp_dir, "a.js"), code + "\n//" + filler);
const run = (extra: string[]) => {
const result = Bun.spawnSync({
cmd: [bunExe(), ...extra, "a.js"],
cwd: temp_dir,
env,
});
if (!result.success) throw new Error(result.stderr.toString());
return result.stdout.toString().trim();
};
// First run with flag: cache miss, write entry
expect(run(["--feature=SUPER_SECRET"])).toBe("enabled");
expect(newCacheCount()).toBe(1);
// Same flag: cache hit
expect(run(["--feature=SUPER_SECRET"])).toBe("enabled");
expect(newCacheCount()).toBe(0);
// No flag: features_hash differs -> old entry deleted, new entry written
expect(run([])).toBe("disabled");
expect(newCacheCount()).toBe(0); // deleted + written = net 0
// Flag again: another delete + write
expect(run(["--feature=SUPER_SECRET"])).toBe("enabled");
expect(newCacheCount()).toBe(0);
// Multiple flags, different order: same hash, cache hit
expect(run(["--feature=SUPER_SECRET", "--feature=OTHER"])).toBe("enabled");
expect(newCacheCount()).toBe(0); // delete + write
expect(run(["--feature=OTHER", "--feature=SUPER_SECRET"])).toBe("enabled");
expect(newCacheCount()).toBe(0); // cache hit, order doesn't matter
});
// Serving the entry point from the cache must not change how the modules it
// loads are resolved. Both of these are gated on the `has_loaded` flag, which
// used to be set only on the path that runs the printer.
describe("a cached entry point does not change how later modules load", () => {
// Padding so the entry point clears MINIMUM_CACHE_SIZE (4 KiB) and is
// eligible for the cache at all.
const filler = "\n//" + Buffer.alloc(5 * 1024, "f").toString();
test("require.extensions is still consulted", async () => {
writeFileSync(
join(temp_dir, "entry.js"),
`require.extensions[".data"] = (module, filename) => {
module.exports = "custom-loader";
};
console.log(require("./asset.data"));${filler}`,
);
// If the custom loader is skipped, this is transpiled as JS/TS instead.
writeFileSync(join(temp_dir, "asset.data"), `module.exports = "default-loader";`);
expect(await bunRun(join(temp_dir, "entry.js"), env)).toSpawn("custom-loader");
expect(newCacheCount()).toBe(1);
expect(await bunRun(join(temp_dir, "entry.js"), env)).toSpawn("custom-loader");
expect(newCacheCount()).toBe(0);
});
test("unknown extensions still use the file loader", async () => {
writeFileSync(
join(temp_dir, "entry.mjs"),
`import asset from "./asset.someext";
console.log(typeof asset === "string" ? "file-loader" : "???");${filler}`,
);
// Not valid JS/TS, so a non-file loader fails the run outright.
writeFileSync(join(temp_dir, "asset.someext"), `hello world contents\n`);
expect(await bunRun(join(temp_dir, "entry.mjs"), env)).toSpawn("file-loader");
expect(newCacheCount()).toBe(1);
expect(await bunRun(join(temp_dir, "entry.mjs"), env)).toSpawn("file-loader");
expect(newCacheCount()).toBe(0);
});
});
});
test("rejects cached module records containing out-of-range string indices", () => {
// When test isolation is enabled, the runtime transpiler cache stores a
// serialized ES module record ("esm_record") alongside the transpiled
// output. The string indices inside that record are used to index an
// identifier table when the record is converted back into a JSC module
// record, so any index beyond the table length (other than the reserved
// *-default / *-namespace sentinels near u32::MAX) must be rejected.
//
// Cache entry layout (src/jsc/RuntimeTranspilerCache.rs, Metadata::encode):
// 0: cache_version u32, 4: module_type u8, 5: output_encoding u8,
// then twelve u64 fields; esm_record_byte_offset @ 78,
// esm_record_byte_length @ 86, esm_record_hash @ 94. Payload follows @ 102.
// Serialized module record layout (src/bundler/analyze_transpiled_module.rs,
// serialize()):
// [record_kinds_len u32][record_kinds, 1 byte each][pad to 4]
// [buffer_len u32][buffer: u32 string index x buffer_len] ...
const ESM_RECORD_BYTE_OFFSET_AT = 78;
const ESM_RECORD_BYTE_LENGTH_AT = 86;
const ESM_RECORD_HASH_AT = 94;
const METADATA_SIZE = 102;
function corruptModuleRecordStringIndices(file: string): boolean {
const data = readFileSync(file);
if (data.length < METADATA_SIZE) return false;
const esmOff = Number(data.readBigUInt64LE(ESM_RECORD_BYTE_OFFSET_AT));
const esmLen = Number(data.readBigUInt64LE(ESM_RECORD_BYTE_LENGTH_AT));
if (esmLen === 0 || esmOff + esmLen > data.length) return false;
const recordKindsLen = data.readUInt32LE(esmOff);
const pad = (4 - (recordKindsLen % 4)) % 4;
let off = esmOff + 4 + recordKindsLen + pad;
const bufferLen = data.readUInt32LE(off);
off += 4;
if (bufferLen === 0) return false;
// Point every string index in the record buffer far beyond the identifier
// table (but below the reserved sentinel range near u32::MAX).
for (let i = 0; i < bufferLen; i++) {
data.writeUInt32LE(0x7fffffff, off + i * 4);
}
// The cache loader skips esm-record content verification when the stored
// hash field is zero, so whoever writes the cache file controls exactly
// what reaches the module record deserializer.
data.writeBigUInt64LE(0n, ESM_RECORD_HASH_AT);
writeFileSync(file, data);
return true;
}
// An ES module big enough to be eligible for the transpiler cache (>= 4 KiB)
// with imports, exports and top-level variables, so its module record
// contains string indices of every record kind.
const filler = ("// " + "x".repeat(120) + "\n").repeat(120);
writeFileSync(
join(temp_dir, "big-lib.js"),
`import { join } from "node:path";
export const value = 42;
let counter = 0;
export function next() {
counter += 1;
return join("a", String(counter));
}
${filler}`,
);
writeFileSync(
join(temp_dir, "uses-lib.test.js"),
`import { test, expect } from "bun:test";
import { value, next } from "./big-lib.js";
test("cached module still works", () => {
expect(value).toBe(42);
expect(next().length).toBeGreaterThan(0);
});`,
);
const run = () =>
Bun.spawnSync({
// --isolate enables the isolation source-provider cache, which is the
// code path that converts the cached module record back into a JSC
// module record.
cmd: [bunExe(), "test", "--isolate", "./uses-lib.test.js"],
cwd: temp_dir,
env,
});
// First run transpiles the module and writes the cache entry, including the
// serialized module record.
const first = run();
expect(first.stderr.toString() + first.stdout.toString()).toContain("1 pass");
expect(existsSync(cache_dir)).toBeTrue();
expect(first.exitCode).toBe(0);
// Second run restores from the intact cache entry: the legitimate record is
// accepted and the module still works.
const second = run();
expect(second.stderr.toString() + second.stdout.toString()).toContain("1 pass");
expect(second.exitCode).toBe(0);
// Rewrite the stored module record so every string index is out of range.
let corrupted = 0;
for (const name of readdirSync(cache_dir)) {
if (corruptModuleRecordStringIndices(join(cache_dir, name))) corrupted++;
}
expect(corrupted).toBeGreaterThanOrEqual(1);
// Third run: the corrupted record must be rejected with a clean module load
// error and a normal (non-signal) process exit.
const third = run();
expect(third.stderr.toString() + third.stdout.toString()).toContain("parseFromSourceCode failed");
expect(third.signalCode).toBeUndefined();
expect(third.exitCode).toBe(1);
});