From 6859193c15950710f1d75c5b1dbbfb41d1b012f2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:38:13 +0000 Subject: [PATCH 1/5] Stop inlining process.env dot-reads in Worker-thread transpiles The main thread sets DotEnvBehavior::LoadAllWithoutInlining before configure_defines(), but the Worker-thread VM startup path never did, so workers inlined process.env.X dot-reads as string literals. Those literals were then stored in the shared on-disk runtime transpiler cache (keyed by content only), so later processes with different env values executed the first process's values. Fixes #34210 --- src/jsc/web_worker.rs | 5 +++++ test/cli/run/transpiler-cache.test.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 3124acf81235..e36a86f8dda9 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -976,6 +976,11 @@ impl WebWorker { unsafe { let b = &mut (*vm).transpiler; b.resolver.env_loader = NonNull::new(b.env); + // Match the main thread (run_command.rs): never inline + // `process.env.X` dot-reads as literals — they'd be baked into + // the shared on-disk runtime transpiler cache. + b.options.env.behavior = + bun_options_types::schema::api::DotEnvBehavior::LoadAllWithoutInlining; if let Some(graph) = parent.standalone_module_graph { (hooks.apply_standalone_runtime_flags)(b, graph); diff --git a/test/cli/run/transpiler-cache.test.ts b/test/cli/run/transpiler-cache.test.ts index d9a09d5163d1..798f323f92cf 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -223,6 +223,24 @@ describe("transpiler cache", () => { expect(b.stdout == "production 5"); expect(newCacheCount()).toBe(0); }); + test("does not inline process.env in Worker threads", () => { + // https://github.com/oven-sh/bun/issues/34210 + writeFileSync( + join(temp_dir, "big-env.js"), + dummyFile((50 * 1024 * 1.5) | 0, "1", { code: "process.env.TRANSPILER_CACHE_TEST_ID" }), + ); + writeFileSync(join(temp_dir, "worker-entry.js"), `await import("./big-env.js");`); + writeFileSync(join(temp_dir, "worker-main.js"), `new Worker(new URL("./worker-entry.js", import.meta.url));`); + + const a = bunRun(join(temp_dir, "worker-main.js"), { ...env, TRANSPILER_CACHE_TEST_ID: "first" }); + expect(a.stdout).toBe("first"); + expect(newCacheCount()).toBe(1); + + // A second process with a different env must not observe the first + // process's value through the shared cache entry. + const b = bunRun(join(temp_dir, "worker-main.js"), { ...env, TRANSPILER_CACHE_TEST_ID: "second" }); + expect(b.stdout).toBe("second"); + }); 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");`; From 2f1fe79c3bade154ebe4884e285d98e34c79e6ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:43:35 +0000 Subject: [PATCH 2/5] test(worker_threads): env: {} scrubs literal process.env.X reads The worker transpiler inlined every launch-environ var as a process.env.X define, so a worker spawned with env: {} still returned the real value from a literal dot read even though enumeration/has reported the key absent. Covered by the same LoadAllWithoutInlining fix as #34210; this test exercises the node worker_threads env-option side of it. --- .../worker_threads/worker_threads.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 68d6c6f3f103..8a0d7e0f926e 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -1419,6 +1419,42 @@ test("*Internal introspection methods are DontEnum on Worker.prototype", () => { expect(enumerable).not.toContain("cpuUsageInternal"); }); +test("env: {} scrubs the launch environment from the worker's process.env", async () => { + // Spawn so the launch environ we're scrubbing is known and not the test + // runner's own. The worker reads the vars as literal dot accesses, which is + // the form the worker transpiler was previously inlining from the parent's + // env loader even when `env: {}` installed an empty plain object. + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { Worker } = require("node:worker_threads"); + const src = \`const { parentPort } = require("node:worker_threads"); + parentPort.postMessage({ + keys: Object.keys(process.env), + secret: process.env.LAUNCH_SECRET ?? null, + only: process.env.ONLY ?? null, + inSecret: "LAUNCH_SECRET" in process.env, + node_env: process.env.NODE_ENV ?? null, + });\`; + const w = new Worker(src, { eval: true, env: { ONLY: "1" } }); + w.once("message", m => { console.log(JSON.stringify(m)); w.terminate(); });`, + ], + env: { ...bunEnv, LAUNCH_SECRET: "s3cr3t", NODE_ENV: "production" }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout.trim())).toEqual({ + keys: ["ONLY"], + secret: null, + only: "1", + inSecret: false, + node_env: null, + }); + expect(exitCode).toBe(0); +}); + describe("env: SHARE_ENV shares the spawning thread's env, not a process-wide one", () => { async function run(mode: string) { const proc = Bun.spawn({ From ca27526b72dc5bb8befdfe0e2b47a799fbf59988 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 04:46:28 +0000 Subject: [PATCH 3/5] trim comment --- src/jsc/web_worker.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index e36a86f8dda9..bdc037fb7d85 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -976,9 +976,7 @@ impl WebWorker { unsafe { let b = &mut (*vm).transpiler; b.resolver.env_loader = NonNull::new(b.env); - // Match the main thread (run_command.rs): never inline - // `process.env.X` dot-reads as literals — they'd be baked into - // the shared on-disk runtime transpiler cache. + // Match run_command.rs: no `process.env.X` dot-read inlining at runtime. b.options.env.behavior = bun_options_types::schema::api::DotEnvBehavior::LoadAllWithoutInlining; From 7bca36cfbde52094259321ca33177891f5cedb82 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:05:12 +0000 Subject: [PATCH 4/5] Bump transpiler cache version so pre-fix worker entries are rejected Entries written by a pre-fix worker carry the writing process's env values as string literals, and neither input_hash nor features_hash changes with env.behavior, so a cache hit on an old entry reinstates the bug. Also assert the second worker run reuses the cache entry, and trim test comments. --- src/jsc/RuntimeTranspilerCache.rs | 5 ++++- test/cli/run/transpiler-cache.test.ts | 3 +-- test/js/node/worker_threads/worker_threads.test.ts | 5 +---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/jsc/RuntimeTranspilerCache.rs b/src/jsc/RuntimeTranspilerCache.rs index 1f2447a9ac9e..21acec591e1d 100644 --- a/src/jsc/RuntimeTranspilerCache.rs +++ b/src/jsc/RuntimeTranspilerCache.rs @@ -43,7 +43,10 @@ bun_core::declare_scope!(cache, visible); /// path reinstates the bug for any previously-cached TLA module (#30887). /// Version 23: `jsx.runtime`/`jsx.development` participate in the features hash, /// and tsconfig `"jsx": "react-jsx"` now emits the production runtime (#4227). -const EXPECTED_VERSION: u32 = 23; +/// Version 24: Worker threads no longer inline `process.env.X` dot-reads. +/// Entries written by a pre-fix worker carry the writing process's env values +/// as string literals; a cache hit reinstates the bug (#34210). +const EXPECTED_VERSION: u32 = 24; /// 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 798f323f92cf..f77334df8468 100644 --- a/test/cli/run/transpiler-cache.test.ts +++ b/test/cli/run/transpiler-cache.test.ts @@ -236,10 +236,9 @@ describe("transpiler cache", () => { expect(a.stdout).toBe("first"); expect(newCacheCount()).toBe(1); - // A second process with a different env must not observe the first - // process's value through the shared cache entry. const b = bunRun(join(temp_dir, "worker-main.js"), { ...env, TRANSPILER_CACHE_TEST_ID: "second" }); expect(b.stdout).toBe("second"); + expect(newCacheCount()).toBe(0); }); test("--feature flag invalidates cache", () => { // feature() can only appear in an if/ternary, so wrap it diff --git a/test/js/node/worker_threads/worker_threads.test.ts b/test/js/node/worker_threads/worker_threads.test.ts index 8a0d7e0f926e..e494a9eb6152 100644 --- a/test/js/node/worker_threads/worker_threads.test.ts +++ b/test/js/node/worker_threads/worker_threads.test.ts @@ -1420,10 +1420,7 @@ test("*Internal introspection methods are DontEnum on Worker.prototype", () => { }); test("env: {} scrubs the launch environment from the worker's process.env", async () => { - // Spawn so the launch environ we're scrubbing is known and not the test - // runner's own. The worker reads the vars as literal dot accesses, which is - // the form the worker transpiler was previously inlining from the parent's - // env loader even when `env: {}` installed an empty plain object. + // https://github.com/oven-sh/bun/issues/34210 await using proc = Bun.spawn({ cmd: [ bunExe(), From b2e3ba075bb1261b1a7d982c2d2c34129366f06f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 10:36:30 +0000 Subject: [PATCH 5/5] ci: retrigger