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); });