From 521bcaabb1cfacacd78b12040776fe58524d9860 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:03:25 +0000 Subject: [PATCH 1/8] Bun.build({ compile }): produce the executable on the bundle thread The bundle completion task ran do_compilation (to_executable: copying and rewriting the bun binary, and downloading it first for a cross target) on the JS thread before settling the promise, so the event loop stalled for the whole write. Run it on the bundle thread right before the completion is posted back; on_complete now only reports the result. to_executable and download_to_path only read the env loader, so they take it by shared reference. The bundle thread gets the same stack size as bun's other threads since this path now runs there too. --- src/bundler/BundleThread.rs | 5 ++ src/options_types/compile_target.rs | 2 +- src/runtime/api/js_bundle_completion_task.rs | 87 +++++++++++-------- src/runtime/cli/build_command.rs | 2 +- src/standalone_graph/StandaloneModuleGraph.rs | 4 +- test/bundler/bun-build-compile.test.ts | 86 ++++++++++++++++++ 6 files changed, 144 insertions(+), 42 deletions(-) diff --git a/src/bundler/BundleThread.rs b/src/bundler/BundleThread.rs index c5a405b1549a..ffb2f05366b0 100644 --- a/src/bundler/BundleThread.rs +++ b/src/bundler/BundleThread.rs @@ -150,6 +150,11 @@ impl BundleThread { let ptr = SendPtr(instance); let thread = std::thread::Builder::new() .name("Bundler".into()) + // The same stack as bun's other threads rather than Rust's 2 MiB + // default: `Bun.build({ compile })` writes its executable on this + // thread, and on Windows that path holds well over half a + // megabyte of path buffers on the 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` diff --git a/src/options_types/compile_target.rs b/src/options_types/compile_target.rs index 1875cab88df8..4bac2b3e34cd 100644 --- a/src/options_types/compile_target.rs +++ b/src/options_types/compile_target.rs @@ -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() { diff --git a/src/runtime/api/js_bundle_completion_task.rs b/src/runtime/api/js_bundle_completion_task.rs index 27657f0da198..58afabcde587 100644 --- a/src/runtime/api/js_bundle_completion_task.rs +++ b/src/runtime/api/js_bundle_completion_task.rs @@ -287,7 +287,9 @@ impl JSBundleCompletionTask { Ok(()) } - /// Port of `JSBundleCompletionTask.doCompilation`. + /// Port of `JSBundleCompletionTask.doCompilation`. Runs on the bundle + /// thread (see `compile_on_bundle_thread`): file system and task-owned + /// state only, nothing JS-affine. fn do_compilation(&mut self, output_files: &mut Vec) -> CompileResult { let compile_options = self .config @@ -427,9 +429,10 @@ impl JSBundleCompletionTask { 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 }, + // construction; the VM frees it only after this build reports + // finished (`embedded_work_finished`, after this returns). Shared + // access only, like the bundler's own reads of it on this thread. + unsafe { &*self.env }, self.config.format, &WindowsOptions { hide_console: compile_options.windows_hide_console, @@ -559,6 +562,44 @@ impl JSBundleCompletionTask { result } + /// Bundle thread, right before the result is posted back. Producing the + /// executable copies and rewrites the whole bun binary (downloading it + /// first for a cross target); done from `on_complete`, that stalled the JS + /// thread's event loop for the duration. The env loader read here is the + /// one bundling just used on this thread, and the VM keeps it alive until + /// `complete_on_bundle_thread` reports the build finished. A cancelled + /// build's VM is tearing down and no longer wants the result, so it is not + /// made to wait for an executable either. + 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); + // `to_executable` and the sourcemap write report their failures to + // stderr, and nothing else flushes this thread's buffered stderr. + 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. @@ -644,7 +685,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 @@ -654,39 +695,8 @@ 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` 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() }, - } - } - } + // `Bun.build({ compile })` already produced the executable (or turned + // `result` into `Err`) on the bundle thread — see `compile_on_bundle_thread`. // `to_js_error` borrows `&mut self`, which would overlap a // `&mut this.result` match scrutinee. Dispatch the pending/err arms @@ -1115,6 +1125,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 diff --git a/src/runtime/cli/build_command.rs b/src/runtime/cli/build_command.rs index 92003b6f7c64..775c5ff7b0aa 100644 --- a/src/runtime/cli/build_command.rs +++ b/src/runtime/cli/build_command.rs @@ -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 diff --git a/src/standalone_graph/StandaloneModuleGraph.rs b/src/standalone_graph/StandaloneModuleGraph.rs index 20e2851fcfa8..eab65b5ee59b 100644 --- a/src/standalone_graph/StandaloneModuleGraph.rs +++ b/src/standalone_graph/StandaloneModuleGraph.rs @@ -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, dest_z: &ZStr, ) -> crate::Result<()> { bun_http::http_thread::init(&Default::default()); @@ -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], diff --git a/test/bundler/bun-build-compile.test.ts b/test/bundler/bun-build-compile.test.ts index 3020baa43de3..9812be91869f 100644 --- a/test/bundler/bun-build-compile.test.ts +++ b/test/bundler/bun-build-compile.test.ts @@ -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(); + 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(); + const giveUp = setTimeout(gaveUp.resolve, 4_000, "did not answer the ping"); + proc.send("ping"); + try { + 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": ` From 94088ded61a8ba3fc2b8aa3255fd0a99467771dd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:40:47 +0000 Subject: [PATCH 2/8] dotenv: make the NODE_TLS_REJECT_UNAUTHORIZED memo atomic download_to_path now runs on the bundle thread for Bun.build({ compile }) cross targets, while fetch and TLS sockets on the JS thread consult the same memo, so it can no longer be a plain Cell. --- src/dotenv/env_loader.rs | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index 0ae6c8e6a18d..86fa0e60146f 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -1,4 +1,3 @@ -use core::cell::Cell; use core::ffi::c_char; use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering}; @@ -126,12 +125,25 @@ pub struct Loader { pub quiet: bool, pub(crate) did_load_process: bool, - pub(crate) reject_unauthorized: Cell>, + /// Atomic because the bundle thread asks too, when a cross-target + /// `Bun.build({ compile })` downloads its target (see `download_to_path`). + pub(crate) reject_unauthorized: bun_core::AtomicCell, // Local POD mirror of `bun_s3_signing::S3Credentials` — see type doc above. aws_credentials: Option, } +/// 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