Skip to content
2 changes: 2 additions & 0 deletions src/bundler/BundleThread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ impl<C: CompletionStruct> BundleThread<C> {
let ptr = SendPtr(instance);
let thread = std::thread::Builder::new()
.name("Bundler".into())
// `Bun.build({ compile })` writes its executable here; use bun's usual stack.
.stack_size(bun_threading::thread_pool::DEFAULT_THREAD_STACK_SIZE as usize)
.spawn(move || {
let ptr = ptr;
// SAFETY: caller guarantees `instance` is valid for 'static; `thread_main`
Expand Down
29 changes: 23 additions & 6 deletions src/dotenv/env_loader.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
use core::cell::Cell;
use core::ffi::c_char;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering};
Expand Down Expand Up @@ -126,12 +125,24 @@ pub struct Loader {
pub quiet: bool,

pub(crate) did_load_process: bool,
pub(crate) reject_unauthorized: Cell<Option<bool>>,
/// Also read by the bundle thread (cross-target `Bun.build({ compile })` download).
pub(crate) reject_unauthorized: bun_core::AtomicCell<TlsRejectUnauthorized>,

// Local POD mirror of `bun_s3_signing::S3Credentials` — see type doc above.
aws_credentials: Option<S3Credentials>,
}

/// Memo of [`Loader::get_tls_reject_unauthorized`].
#[repr(u8)]
#[derive(Copy, Clone)]
pub(crate) enum TlsRejectUnauthorized {
Unknown,
No,
Yes,
}
// SAFETY: `#[repr(u8)]` with payload-free variants: 1 byte, no padding.
bun_core::unsafe_impl_atom!(TlsRejectUnauthorized);

static DID_LOAD_CCACHE_PATH: AtomicBool = AtomicBool::new(false);
// Overwritten on every `load_node_js_config` call. NOT set-once despite the
// name, so RwLock<Option> (not OnceLock) — a 2nd call with an override must update the cache.
Expand Down Expand Up @@ -291,12 +302,18 @@ impl Loader {
///
/// **Prefer VirtualMachine.getTLSRejectUnauthorized()** for JavaScript, as individual workers could have different settings.
pub fn get_tls_reject_unauthorized(&self) -> bool {
if let Some(reject_unauthorized) = self.reject_unauthorized.get() {
return reject_unauthorized;
match self.reject_unauthorized.load() {
TlsRejectUnauthorized::Yes => return true,
TlsRejectUnauthorized::No => return false,
TlsRejectUnauthorized::Unknown => {}
}
// default: true
let result = self.get(b"NODE_TLS_REJECT_UNAUTHORIZED") != Some(b"0");
self.reject_unauthorized.set(Some(result));
self.reject_unauthorized.store(if result {
TlsRejectUnauthorized::Yes
} else {
TlsRejectUnauthorized::No
});
result
}

Expand Down Expand Up @@ -575,7 +592,7 @@ impl Loader {
custom_files_loaded: StringArrayHashMap::default(),
quiet: false,
did_load_process: false,
reject_unauthorized: Cell::new(None),
reject_unauthorized: bun_core::AtomicCell::new(TlsRejectUnauthorized::Unknown),
aws_credentials: None,
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/options_types/compile_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ impl CompileTarget {
&self,
buf: &'a mut PathBuffer,
version_str: &'a ZStr,
_env: &mut bun_dotenv::Loader,
_env: &bun_dotenv::Loader,
needs_download: &mut bool,
) -> &'a ZStr {
if self.is_default() {
Expand Down
76 changes: 36 additions & 40 deletions src/runtime/api/js_bundle_completion_task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ impl JSBundleCompletionTask {
Ok(())
}

/// Port of `JSBundleCompletionTask.doCompilation`.
/// Port of `JSBundleCompletionTask.doCompilation`. Bundle thread: nothing in here may touch JS.
fn do_compilation(&mut self, output_files: &mut Vec<OutputFile>) -> CompileResult {
let compile_options = self
.config
Expand Down Expand Up @@ -426,10 +426,9 @@ 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 },
// SAFETY: the VM frees its loader only after this build reports
// finished (`embedded_work_finished`), which happens after this returns.
unsafe { &*self.env },
self.config.format,
&WindowsOptions {
hide_console: compile_options.windows_hide_console,
Expand Down Expand Up @@ -559,6 +558,36 @@ impl JSBundleCompletionTask {
result
}

/// `Bun.build({ compile })`: the bundle becomes the executable before `on_complete` gets it.
fn compile_on_bundle_thread(&mut self) {
if self.config.compile.is_none()
|| self.cancelled.load(core::sync::atomic::Ordering::Acquire)
{
return;
}
let mut build = match core::mem::replace(&mut self.result, BundleV2Result::Pending) {
BundleV2Result::Value(build) => build,
not_bundled => {
self.result = not_bundled;
return;
}
};
let compile_result = self.do_compilation(&mut build.output_files);
// Nothing else flushes this thread's buffered stderr, which the above reports failures to.
bun_core::Output::flush();
self.result = match compile_result {
CompileResult::Success => BundleV2Result::Value(build),
CompileResult::Err(err) => {
self.log.add_error_fmt(
None,
bun_ast::Loc::EMPTY,
format_args!("{}", bstr::BStr::new(err.slice())),
);
BundleV2Result::Err(bun_bundler::Error::CompilationFailed)
}
};
}

pub(crate) fn on_complete_anytask(ctx: *mut Self) -> bun_event_loop::JsResult<()> {
crate::jsc_hooks::ActiveHandle::Bundle(NonNull::new(ctx).expect("completion")).unregister();
// For the +1 taken by `complete_on_bundle_thread` enqueue.
Expand Down Expand Up @@ -644,7 +673,7 @@ impl JSBundleCompletionTask {
}

// Copy the BackRef out (it is `Copy`) so `global_this` borrows a local
// instead of `*this` — `do_compilation`/`to_js_error` below need `&mut *this`.
// instead of `*this` — `to_js_error` below needs `&mut *this`.
let global_this_ref = this.global_this;
let global_this = global_this_ref.get();
// `Strong::swap` ties the returned `&mut JSPromise` to
Expand All @@ -654,40 +683,6 @@ impl JSBundleCompletionTask {
let promise: *mut JSPromise = this.promise.swap();
let promise = JSPromise::opaque_mut(promise);

// `do_compilation` borrows `&mut self` while needing
// `&mut output_files` from inside `self.result`. Temporarily move the
// Vec out via `take` so the method gets a disjoint `&mut self`.
if matches!(this.result, BundleV2Result::Value(_)) && this.config.compile.is_some() {
let mut output_files = match &mut this.result {
BundleV2Result::Value(build) => core::mem::take(&mut build.output_files),
// SAFETY: arm checked above.
_ => unsafe { core::hint::unreachable_unchecked() },
};
let compile_result = this.do_compilation(&mut output_files);
// `defer compile_result.deinit()` — `CompileResult` is a Rust enum
// with owned `Vec<u8>` payloads; drops at end of scope.

if let CompileResult::Err(err) = &compile_result {
// `bun.handleOom(log.addError(..., bun.handleOom(dupe(..))))`
this.log.add_error_fmt(
None,
bun_ast::Loc::EMPTY,
format_args!("{}", bstr::BStr::new(err.slice())),
);
// `this.result.value.deinit()` — owned fields drop with the
// overwrite below; `output_files` (moved out above) drops here.
drop(output_files);
this.result = BundleV2Result::Err(bun_bundler::Error::CompilationFailed);
} else {
// Put the compacted output_files back.
match &mut this.result {
BundleV2Result::Value(build) => build.output_files = output_files,
// SAFETY: arm checked above.
_ => unsafe { core::hint::unreachable_unchecked() },
}
}
}

// `to_js_error` borrows `&mut self`, which would overlap a
// `&mut this.result` match scrutinee. Dispatch the pending/err arms
// first, then take a fresh `&mut` for Value.
Expand Down Expand Up @@ -1115,6 +1110,7 @@ impl CompletionStruct for JSBundleCompletionTask {
}

fn complete_on_bundle_thread(&mut self) {
self.compile_on_bundle_thread();
// The bundle thread's last touch of this task and of the VM's memory:
// hand it back (always queued — the VM waits for it) and stop counting.
self.bundle_loop
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/cli/build_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,7 @@ impl BuildCommand {
&opt_public_path,
outfile,
// SAFETY: `env` is a process-lifetime singleton.
unsafe { &mut *env_ptr },
unsafe { &*env_ptr },
opt_output_format,
&ctx.bundler_options.windows,
ctx.bundler_options
Expand Down
4 changes: 2 additions & 2 deletions src/standalone_graph/StandaloneModuleGraph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1654,7 +1654,7 @@ pub use bun_options_types::compile_target::CompileTarget;
/// two `download*` fns below in this crate.
pub(crate) fn download_to_path(
target: &CompileTarget,
env: &mut bun_dotenv::Loader,
env: &bun_dotenv::Loader,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
dest_z: &ZStr,
Comment thread
claude[bot] marked this conversation as resolved.
) -> crate::Result<()> {
bun_http::http_thread::init(&Default::default());
Expand Down Expand Up @@ -1818,7 +1818,7 @@ pub fn to_executable(
root_dir: Fd,
module_prefix: &[u8],
outfile: &[u8],
env: &mut bun_dotenv::Loader,
env: &bun_dotenv::Loader,
output_format: Format,
windows_options: &WindowsOptions,
compile_exec_argv: &[u8],
Expand Down
86 changes: 86 additions & 0 deletions test/bundler/bun-build-compile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,92 @@ describe("Bun.build compile", () => {
},
);

test("the event loop keeps running while the executable is being produced", async () => {
// Producing the executable (downloading the target binary for a cross target, then
// copying and rewriting it) used to happen on the JS thread between bundling and the
// promise settling, so nothing else in that process ran meanwhile. Serve the target
// download here and withhold the response until the building process has answered an
// IPC ping, which a process whose JS thread is inside the compile step cannot do.
using dir = tempDir("build-compile-event-loop", {
"app.js": `console.log("hi");`,
"build.js": `
process.on("message", message => process.send(message));
const result = await Bun.build({
entrypoints: [import.meta.dir + "/app.js"],
compile: { target: process.argv[2], outfile: "app" },
throw: false,
});
console.log(JSON.stringify({ success: result.success, logs: result.logs.map(log => log.message) }));
process.exit(0);
`,
});
// Any OS other than the host's has to be downloaded; aarch64 never carries a "-baseline" suffix.
const target = `bun-${isMacOS ? "linux" : "darwin"}-aarch64`;
const [version] = Bun.version.match(/^\d+\.\d+\.\d+/)!;

type Outcome = "never requested the target" | "answered the ping" | "did not answer the ping";
let outcome: Outcome = "never requested the target";
const pong = Promise.withResolvers<Outcome>();
await using server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch() {
// A process stuck inside the compile step emits nothing, so that state can only be
// observed by giving up on the ping. The unblocked process answers within
// milliseconds (about 50ms under a debug build).
const gaveUp = Promise.withResolvers<Outcome>();
const giveUp = setTimeout(gaveUp.resolve, 4_000, "did not answer the ping");
try {
proc.send("ping");
outcome = await Promise.race([
pong.promise,
gaveUp.promise,
proc.exited.then((): Outcome => "did not answer the ping"),
]);
} finally {
clearTimeout(giveUp);
}
return new Response("no such tarball", { status: 404 });
},
});

await using proc = Bun.spawn({
cmd: [bunExe(), "build.js", target],
cwd: String(dir),
env: {
...bunEnv,
BUN_COMPILE_TARGET_TARBALL_URL: `${server.url}bun.tgz`,
// A fresh cache, so the target is never already on disk.
BUN_INSTALL_CACHE_DIR: join(String(dir), "install-cache"),
// The download honors these; keep it pointed at the server above.
HTTP_PROXY: undefined,
HTTPS_PROXY: undefined,
http_proxy: undefined,
https_proxy: undefined,
},
stdout: "pipe",
stderr: "pipe",
ipc(message) {
if (message === "ping") pong.resolve("answered the ping");
},
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

// stderr is only diagnostic: the download reports progress there once the response
// has been outstanding for 500ms, so it is empty or not depending on the ping's speed.
expect({ outcome, stdout, exitCode }, `stderr: ${stderr}`).toEqual({
outcome: "answered the ping",
stdout:
JSON.stringify({
success: false,
logs: [
`Target platform '${target}-v${version}' is not available for download. Check if this version of Bun supports this target.`,
],
}) + "\n",
exitCode: 0,
});
});

test("compile with embedded resources uses correct module prefix", async () => {
using dir = tempDir("build-compile-embedded-resources", {
"app.js": `
Expand Down
Loading