Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/dotenv/env_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,26 @@ impl Loader {
}
}

/// Deep copy of the variables and of the "already loaded" bookkeeping, so
/// `load_process` / `load` on the copy skip exactly what they skip on
/// `self`. The lazily derived caches (S3 credentials, TLS
/// reject-unauthorized) start empty and are rebuilt from the copied map.
///
/// A loader keeps being mutated on the thread that owns it
/// (`process.env.HTTPS_PROXY = ...` replaces map values in place), so work
/// that reads env on another thread takes one of these instead.
Comment thread
robobun marked this conversation as resolved.
Outdated
pub fn clone(&self) -> Result<Loader, AllocError> {
Ok(Loader {
map: self.map.clone_with_allocator()?,
default_files_loaded: self.default_files_loaded,
custom_files_loaded: self.custom_files_loaded.clone()?,
quiet: self.quiet,
did_load_process: self.did_load_process,
reject_unauthorized: Cell::new(None),
aws_credentials: None,
})
}

pub fn load_process(&mut self) -> Result<(), AllocError> {
if self.did_load_process {
return Ok(());
Expand Down
46 changes: 31 additions & 15 deletions src/runtime/api/js_bundle_completion_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,14 @@ pub struct JSBundleCompletionTask {
pub global_this: BackRef<JSGlobalObject>,
pub(crate) promise: jsc::JSPromiseStrong,
pub poll_ref: KeepAlive,
pub(crate) env: *mut bun_dotenv::Loader,
/// The calling VM's env as of the `Bun.build()` call. The build reads env
/// (`configure_defines`, `env: "inline"` defines, the resolver's
/// `NODE_PATH`) on the bundle thread and its workers, while the VM's own
/// loader keeps being mutated on the JS thread (`Bun__setEnvValue` for
/// `process.env.HTTPS_PROXY = ...` frees the value it replaces), so the
/// build must not read that one. Boxed so the pointer `Transpiler::init`
/// keeps is to an allocation of its own, not into this struct.
Comment thread
robobun marked this conversation as resolved.
Outdated
pub(crate) env: Box<bun_dotenv::Loader>,
pub(crate) log: bun_ast::Log,
/// Set by the owner giving up on the result (HTMLBundle route torn down)
/// or by the VM's stop phase; read by `on_complete` (skip delivery) and by
Expand Down Expand Up @@ -120,7 +127,7 @@ impl JSBundleCompletionTask {
// last-ref drop is the only place that releases it.
Plugin::destroy(plugin.as_ptr());
}
// Owned fields (`config`, `log`, `result`, `promise`) drop with the Box.
// Owned fields (`config`, `env`, `log`, `result`, `promise`) drop with the Box.
}
}

Expand All @@ -141,7 +148,14 @@ pub(crate) fn create_and_schedule_completion_task(
global_this: &JSGlobalObject,
) -> crate::Result<*mut JSBundleCompletionTask> {
let vm = global_this.bun_vm_ptr();
let env = global_this.bun_vm().transpiler.env;
// Copied here on this VM's JS thread, the thread `Bun__setEnvValue` mutates
// the loader from, so unlike a Worker's clone (web_worker.rs) this one
// needs no `proxy_env_storage` lock. OOM aborts like the `Box::new` below:
// returning an error would leak the `plugins` Bun.build hands us, which
// only the task's `deinit` releases.
Comment thread
robobun marked this conversation as resolved.
Outdated
let env = Box::new(bun_core::handle_oom(
global_this.bun_vm().env_loader().clone(),
));
let completion = bun_core::heap::into_raw(Box::new(JSBundleCompletionTask {
Comment thread
claude[bot] marked this conversation as resolved.
ref_count: RefCount::init(),
config,
Expand Down Expand Up @@ -173,8 +187,8 @@ pub(crate) fn create_and_schedule_completion_task(
let _ = WorkPool::get();

// Out on the bundle thread from here until it posts the completion: it
// reads this VM's env loader and the plugin cell, so the VM cancels it at
// teardown (registry) and waits for it (embedded work).
// reads this VM's plugin cell and posts into this VM's queue, so the VM
// cancels it at teardown (registry) and waits for it (embedded work).
Comment thread
robobun marked this conversation as resolved.
// SAFETY: `completion` is live (refcount==1), JS thread.
unsafe { (*completion).loop_handle.embedded_work_scheduled() };
crate::jsc_hooks::ActiveHandle::Bundle(NonNull::new(completion).expect("completion"))
Expand Down Expand Up @@ -426,10 +440,7 @@ impl JSBundleCompletionTask {
root_dir.fd,
module_prefix,
outfile_for_executable,
// SAFETY: `self.env` is the per-VM `DotEnv.Loader` stashed at
// construction; valid for the lifetime of the VirtualMachine, and
// nothing inside `to_executable` reaches it otherwise.
unsafe { &mut *self.env },
&mut self.env,
self.config.format,
&WindowsOptions {
hide_console: compile_options.windows_hide_console,
Expand Down Expand Up @@ -909,9 +920,9 @@ impl CompletionStruct for JSBundleCompletionTask {
core::hint::spin_loop();
}
// The VM released everything thread-affine (`stop_for_vm_teardown`);
// what is left config, log, an empty promise slot, a `Done`
// keep-alive, the handle cloneis ours to drop here. The queue held
// the creation reference.
// what is left (config, the env copy, log, an empty promise slot, a
// `Done` keep-alive, the handle clone) is ours to drop here. The
// queue held the creation reference.
Comment thread
robobun marked this conversation as resolved.
// SAFETY: dequeued ⇒ sole owner; nothing JS-affine remains.
drop(unsafe { bun_core::heap::take(this) });
}
Expand Down Expand Up @@ -1105,8 +1116,9 @@ impl CompletionStruct for JSBundleCompletionTask {
.conditions
.append_slice(&[b"development"])?;
}
// `transpiler.env` is the dotenv loader installed by
// `Transpiler::init`; non-null and valid for `'a`.
// `transpiler.env` is this build's env copy (`self.env`), installed by
// `create_and_configure_transpiler`; the resolver's workers read
// `NODE_PATH` through it.
Comment thread
robobun marked this conversation as resolved.
Outdated
transpiler.resolver.env_loader = NonNull::new(transpiler.env);
// `Resolver.opts` is the resolver-crate subset
// — re-project from the now-mutated `transpiler.options`.
Expand Down Expand Up @@ -1191,7 +1203,11 @@ impl CompletionStruct for JSBundleCompletionTask {
};

let log: *mut bun_ast::Log = &raw mut self.log;
let t = Transpiler::init(bump, log, opts, Some(self.env))?;
// The build's own env copy (see the field doc). Same lifetime argument
// as `log`: every read through the transpiler happens before
// `complete_on_bundle_thread` hands this task back to the JS thread.
Comment thread
robobun marked this conversation as resolved.
Outdated
let env: *mut bun_dotenv::Loader = &raw mut *self.env;
let t = Transpiler::init(bump, log, opts, Some(env))?;
let transpiler: &'a mut Transpiler<'a> = bump.alloc(t);

// Post-init field wiring.
Expand Down
99 changes: 98 additions & 1 deletion test/bundler/bundler_env.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe } from "bun:test";
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
import { itBundled } from "./expectBundled";

for (let backend of ["api", "cli"] as const) {
Expand Down Expand Up @@ -118,3 +119,99 @@ for (let backend of ["api", "cli"] as const) {
});
});
}

// A build inlines the env as of the call that started it. The bundler thread
// reads env from its own copy: `process.env.HTTPS_PROXY = ...` (one of the
// few assignments that write through to the native env map, replacing the
// stored value in place) while a build is in flight must not change, or free
// out from under, what the build inlines.
describe.concurrent("env is copied when the build is scheduled", () => {
const atCall = "http://proxy-when-the-build-was-scheduled.example:1111/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const duringBuild = "http://proxy-assigned-while-bundling.example:2222/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";

// The plugin's onLoad runs on the JS thread while the bundle is in flight,
// after the bundler has built its defines.
const pluginSource = (filter: string) => /* ts */ `
export default {
name: "assign-proxy-env-while-bundling",
setup(build) {
build.onLoad({ filter: ${filter} }, () => {
process.env.HTTPS_PROXY = ${JSON.stringify(duringBuild)};
return { loader: "ts", contents: "console.log(process.env.HTTPS_PROXY);" };
});
},
};
`;

test.each(["inline", "HTTPS_*"] as const)("Bun.build({ env: %j })", async env => {
using dir = tempDir("bun-build-env-copy", {
"entry.ts": "export {};",
"plugin.ts": pluginSource("/entry\\.ts$/"),
"build-fixture.ts": /* ts */ `
import plugin from "./plugin.ts";
process.env.HTTPS_PROXY = ${JSON.stringify(atCall)};
const result = await Bun.build({
entrypoints: ["./entry.ts"],
env: ${JSON.stringify(env)},
plugins: [plugin],
});
process.stdout.write(await result.outputs[0].text());
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "build-fixture.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stdout).toContain(`console.log(${JSON.stringify(atCall)})`);
expect(stdout).not.toContain(duringBuild);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});

// Bun.serve's HTML routes (without HMR) are built through the same bundler
// thread, with env behavior and plugins coming from bunfig.
test('Bun.serve HTML route with [serve.static] env = "inline"', async () => {
using dir = tempDir("bun-serve-html-env-copy", {
"bunfig.toml": /* toml */ `
[serve.static]
env = "inline"
plugins = ["./plugin.ts"]
`,
"index.html": /* html */ `<!DOCTYPE html><html><body><script type="module" src="./app.ts"></script></body></html>`,
"app.ts": "export {};",
"plugin.ts": pluginSource("/app\\.ts$/"),
"serve-fixture.ts": /* ts */ `
import index from "./index.html";
process.env.HTTPS_PROXY = ${JSON.stringify(atCall)};
using server = Bun.serve({
port: 0,
development: false,
routes: { "/": index },
});
const html = await (await fetch(server.url)).text();
const script = html.match(/src="([^"]+\\.js)"/)![1];
process.stdout.write(await (await fetch(new URL(script, server.url))).text());
`,
});

await using proc = Bun.spawn({
cmd: [bunExe(), "serve-fixture.ts"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stdout).toContain(`console.log(${JSON.stringify(atCall)})`);
expect(stdout).not.toContain(duringBuild);
expect(stderr).toBe("");
expect(exitCode).toBe(0);
});
});