From 08898ff238aedc9671a1ea364d9874873d81bdae Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sun, 27 Sep 2026 20:26:20 +0700 Subject: [PATCH] fix(runtime): avoid caching runtime plugin resolutions --- CHANGELOG.md | 1 + src/bundler/linker.rs | 2 + src/jsc/RuntimeTranspilerCache.rs | 3 +- test/cli/run/transpiler-cache.test.ts | 133 +++++++++++++++++++++++++- test/cli/test/isolation.test.ts | 18 +++- 5 files changed, 152 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cdc85894163b..e453349faf81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Keep runtime plugin resolution out of the content-addressed transpiler cache so changed plugin answers and temporary module generations cannot reuse stale import paths. - Mark inherited POSIX descriptors close-on-exec at startup like Node, preventing native-spawned children from keeping stdio pipes open after Bun exits. - Integrate upstream [#40005](https://github.com/oven-sh/bun/pull/40005) at `900eae3`: `node:sqlite` `DatabaseSync.close()` and `Symbol.dispose()` finalize outstanding statements, so WAL/shared-memory files, file locks, and descriptors are released immediately. Virtual-table modules such as FTS5 and sqlite-vec keep ownership of their private statements. - Publish tagged `main` commits as GitHub releases: release builds for darwin-arm64, darwin-x64, linux-x64 and linux-arm64, smoke-tested on their own platforms, with SHA-256 checksums, a pinnable `manifest.json` naming the fork commit and WebKit revision, and build provenance attestations. See `.github/OPENCLAW_RELEASE.md`. diff --git a/src/bundler/linker.rs b/src/bundler/linker.rs index c3cf45861683..3ff56f0c4f36 100644 --- a/src/bundler/linker.rs +++ b/src/bundler/linker.rs @@ -459,6 +459,8 @@ impl Linker { BunPluginTarget::Node }, )? { + // Plugin answers are not part of the content-addressed cache key. + result.runtime_transpiler_cache = None; import_record.path = self.generate_import_path( source_dir, path.text, diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 1ba5bf2d76bb..10a1272c85ae 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -63,7 +63,8 @@ bun_core::declare_scope!(cache, visible); /// Version 33: The cached ESM record keeps the namespace of an import that a plugin /// `onResolve` rewrote (`namespace:path`). Older entries request the bare path, and /// the cache-HIT path reinstates #33904 for them. -const EXPECTED_VERSION: u32 = 33; +/// Version 34: Discard output containing a previous runtime plugin's resolved import paths. +const EXPECTED_VERSION: u32 = 34; /// Source files smaller than this are not written to / read from the on-disk /// transpiler cache. Originally 50 KiB, which excluded almost every file in a diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index 606582c1f2d3..98996543c7bd 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -12,7 +12,7 @@ import { unlinkSync, writeFileSync, } from "fs"; -import { bunEnv, bunExe, bunRun, isWindows, tmpdirSync } from "harness"; +import { bunEnv, bunExe, bunRun, isWindows, tempDir, tmpdirSync } from "harness"; import { mkfifo } from "mkfifo"; import { join } from "path"; @@ -136,6 +136,137 @@ describe("transpiler cache", () => { expect(await bunRun(join(temp_dir, "b.js"), env)).toSpawn("b"); expect(newCacheCount()).toBe(0); }); + + describe("runtime plugins", () => { + const filler = "\n//" + Buffer.alloc(5 * 1024, "f").toString(); + + test.each([ + ["import", "entry.mjs", 'import value from "./dependency.js"; export default value;'], + ["reexport", "entry.mjs", 'export { default } from "./dependency.js";'], + ["require", "entry.cjs", 'module.exports = require("./dependency.js");'], + ])("does not retain a previous onResolve answer (%s)", async (_, entry, source) => { + using dir = tempDir("transpiler-cache-plugin", { + "package.json": '{"type":"module"}', + [entry]: source + filler, + "a.js": 'export default "A";', + "b.js": 'export default "B";', + "main.js": ` + import { resolve } from "node:path"; + let seen = false; + Bun.plugin({ name: "redirect", setup(build) { + build.onResolve({ filter: /^\\.\\/dependency\\.js$/ }, () => { + seen = true; + return { path: resolve(import.meta.dir, process.argv[2] + ".js") }; + }); + }}); + const { default: value } = require(${JSON.stringify("./" + entry)}); + console.log(JSON.stringify({ value, seen })); + `, + }); + const cache = join(String(dir), ".cache"); + const childEnv = { ...env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: cache }; + const run = (target: string) => bunRun([join(String(dir), "main.js"), target], childEnv); + + expect(await run("a")).toSpawn(JSON.stringify({ value: "A", seen: true })); + expect(await run("b")).toSpawn(JSON.stringify({ value: "B", seen: true })); + unlinkSync(join(String(dir), "a.js")); + expect(await run("b")).toSpawn(JSON.stringify({ value: "B", seen: true })); + expect(existsSync(cache) ? readdirSync(cache) : []).toEqual([]); + }); + + test("resolves identical source in a new module generation", async () => { + const source = 'import value from "./dependency.js"; export default value;' + filler; + using dir = tempDir("transpiler-cache-generation", { + "package.json": '{"type":"module"}', + "a/entry.mjs": source, + "a/dependency.js": 'export default "A";', + "b/entry.mjs": source, + "b/dependency.js": 'export default "B";', + "main.js": ` + import { dirname, resolve } from "node:path"; + let seen = false; + Bun.plugin({ name: "generation", setup(build) { + build.onResolve({ filter: /^\\.\\/dependency\\.js$/ }, args => { + seen = true; + return { path: resolve(dirname(args.importer), args.path) }; + }); + }}); + const { default: value } = require("./" + process.argv[2] + "/entry.mjs"); + console.log(JSON.stringify({ value, seen })); + `, + }); + const childEnv = { ...env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: join(String(dir), ".cache") }; + const run = (generation: string) => bunRun([join(String(dir), "main.js"), generation], childEnv); + expect(await run("a")).toSpawn(JSON.stringify({ value: "A", seen: true })); + rmSync(join(String(dir), "a"), { recursive: true }); + expect(await run("b")).toSpawn(JSON.stringify({ value: "B", seen: true })); + }); + + test.each(["absent", "declined"])("a cache populated with an %s hook uses the current resolver", async mode => { + using dir = tempDir("transpiler-cache-ordinary", { + "package.json": '{"type":"module"}', + "entry.mjs": 'import value from "./dependency.js"; export default value;' + filler, + "dependency.js": 'export default "A";', + "other.js": 'export default "B";', + "main.js": ` + import { resolve } from "node:path"; + let seen = false; + if (process.argv[2] !== "absent") { + Bun.plugin({ name: "optional", setup(build) { + build.onResolve({ filter: /^\\.\\/dependency\\.js$/ }, () => { + seen = true; + if (process.argv[2] !== "declined") return { path: resolve(import.meta.dir, "other.js") }; + }); + }}); + } + const { default: value } = require("./entry.mjs"); + console.log(JSON.stringify({ value, seen })); + `, + }); + const cache = join(String(dir), ".cache"); + const childEnv = { ...env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: cache }; + const run = (mode: string) => bunRun([join(String(dir), "main.js"), mode], childEnv); + expect(await run(mode)).toSpawn(JSON.stringify({ value: "A", seen: mode === "declined" })); + const entries = readdirSync(cache); + expect(entries).toHaveLength(1); + const filename = join(cache, entries[0]); + const original = readFileSync(filename); + expect(await run("claimed")).toSpawn(JSON.stringify({ value: "B", seen: true })); + expect(readdirSync(cache)).toEqual(entries); + expect(readFileSync(filename).equals(original)).toBeTrue(); + }); + + test("does not cache an accepted onResolve answer that leaves the path unchanged", async () => { + using dir = tempDir("transpiler-cache-identity", { + "dependency.mjs": 'export default "A";', + "main.mjs": ` + import { resolve } from "node:path"; + const target = resolve(import.meta.dir, "dependency.mjs"); + let seen = false; + Bun.plugin({ name: "identity", setup(build) { + build.onResolve({ filter: /.*/ }, args => { + if (args.path === target) { + seen = true; + return { path: args.path }; + } + }); + }}); + const { default: value } = require("./entry.mjs"); + console.log(JSON.stringify({ value, seen })); + `, + }); + writeFileSync( + join(String(dir), "entry.mjs"), + `export { default } from ${JSON.stringify(join(String(dir), "dependency.mjs"))};${filler}`, + ); + const cache = join(String(dir), ".cache"); + expect(await bunRun(join(String(dir), "main.mjs"), { ...env, BUN_RUNTIME_TRANSPILER_CACHE_PATH: cache })).toSpawn( + JSON.stringify({ value: "A", seen: true }), + ); + expect(existsSync(cache) ? readdirSync(cache) : []).toEqual([]); + }); + }); + 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")); diff --git a/test/cli/test/isolation.test.ts b/test/cli/test/isolation.test.ts index d31bdcb63b9b..a94839ea72e3 100644 --- a/test/cli/test/isolation.test.ts +++ b/test/cli/test/isolation.test.ts @@ -446,12 +446,24 @@ describe.concurrent("bun test --isolate", () => { expect(exitCode).toBe(0); }); - // The on-disk transpiler cache stores the module record next to the output. reexport-clause.ts - // is padded past the 4 KiB floor of that cache, so the second run rebuilds its record from the entry. - test("with --isolate, the on-disk transpiler cache keeps that namespace in the stored module record", async () => { + // Only the source-written namespace is cacheable; runtime onResolve answers must be linked afresh. + test("with --isolate, the disk cache preserves source namespaces but excludes plugin-rewritten imports", async () => { using dir = tempDir("isolate-plugin-namespace-disk-cache", { ...pluginNamespaceFixture, "reexport-clause.ts": `export { named } from "./data.bar?custom";\n//${Buffer.alloc(5 * 1024, "f").toString()}\n`, + "cache-namespace.ts": `export { named } from "cache-only:stable";\n//${Buffer.alloc(5 * 1024, "f").toString()}\n`, + "plugin.ts": `${pluginNamespaceFixture["plugin.ts"]} + Bun.plugin({ name: "cache-namespace", setup(build) { + build.onLoad({ filter: /.*/, namespace: "cache-only" }, () => ({ + contents: 'export const named = "CACHED_NAMESPACE";', + loader: "js", + })); + }}); + `, + "a.test.ts": `${pluginNamespaceTestFile} + import { named as cached } from "./cache-namespace.ts"; + expect(cached).toBe("CACHED_NAMESPACE"); + `, }); const cacheDir = join(String(dir), ".cache"); const env = {