Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
15 changes: 15 additions & 0 deletions src/dotenv/env_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,21 @@ impl Loader {
}
}

/// Copies the variables and the loaded-files bookkeeping (so `load_process`
/// / `load` on the copy are the same no-ops as on `self`); the lazily
/// derived caches are rebuilt from the copied map on demand.
Comment thread
robobun marked this conversation as resolved.
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
35 changes: 20 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,10 @@ pub struct JSBundleCompletionTask {
pub global_this: BackRef<JSGlobalObject>,
pub(crate) promise: jsc::JSPromiseStrong,
pub poll_ref: KeepAlive,
pub(crate) env: *mut bun_dotenv::Loader,
/// This build's copy of the calling VM's env, read on the bundle thread
/// while the VM's own loader keeps changing on the JS thread
/// (`Bun__setEnvValue`). Boxed: the build's transpiler holds a pointer to it.
Comment thread
robobun marked this conversation as resolved.
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 +123,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 +144,11 @@ 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;
// Same thread as `Bun__setEnvValue`, so no `proxy_env_storage` lock (unlike
// web_worker.rs). An error return here would leak `plugins`, so OOM aborts.
Comment thread
robobun marked this conversation as resolved.
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 +180,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 +433,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 +913,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 +1109,7 @@ 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 copy (`self.env`), not the VM's loader.
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 +1194,9 @@ impl CompletionStruct for JSBundleCompletionTask {
};

let log: *mut bun_ast::Log = &raw mut self.log;
let t = Transpiler::init(bump, log, opts, Some(self.env))?;
// Like `log`: used only until `complete_on_bundle_thread` hands `self` back.
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);
});
});
Loading