Skip to content

Bun.build({ compile }): produce the executable on the bundle thread instead of blocking the event loop - #37507

Open
robobun wants to merge 8 commits into
mainfrom
farm/8ef26a70/compile-off-js-thread
Open

Bun.build({ compile }): produce the executable on the bundle thread instead of blocking the event loop#37507
robobun wants to merge 8 commits into
mainfrom
farm/8ef26a70/compile-off-js-thread

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

What

While await Bun.build({ compile: { ... } }) is pending, the calling thread's event loop stops once bundling finishes and does not resume until the output executable has been fully written: no timers fire, no child exits or pipe reads are observed. Under a debug build (1 GB binary) that is a 3 to 4 second stall; with a release binary it is about 100ms on NVMe and grows with binary size and disk/antivirus speed. For a cross-compile target the download of the target binary happens on the same synchronous path. Observed in #37496, where every test running concurrently with one Bun.build({ compile }) reported the compile's duration.

const gaps = []; let last = performance.now();
setInterval(() => { const now = performance.now(); if (now - last > 200) gaps.push(`${last | 0} -> ${now | 0}`); last = now; }, 1);
await Bun.build({ entrypoints: ["./index.ts"], compile: { outfile: "./app" } });
console.log(gaps);

Before (debug build): build took 4294ms, one timer gap 144 -> 4304, 381 interval ticks in total. After: no gaps, about 4200 ticks during the build, same executable written.

Cause

JSBundleCompletionTask::on_complete runs on the JS thread when the bundle thread posts the finished build back. For compile it called do_compilation right there, i.e. collect_compile_assets, StandaloneModuleGraph::to_executable (cross-target download, copy of the running binary, read back, ELF/Mach-O/PE patch, full rewrite, rename) and the external sourcemap writes, and only then settled the promise.

Fix

complete_on_bundle_thread now calls compile_on_bundle_thread before posting the completion, so the executable is produced on the bundle thread and on_complete only reports the result. The result/log mutations are the same as before (CompileResult::Err is appended to the log and the result becomes Err(CompilationFailed), so to_js_error and the throw: false shape are unchanged); they just happen before the hand-off. A build whose VM is tearing down (cancelled) skips the step, matching the old code, where on_complete returned before compiling.

What the moved step may touch, and how that was made true:

  • It previously read the VM's env loader: download_to_path looked up NODE_TLS_REJECT_UNAUTHORIZED and the proxy variables and handed the HTTP client a proxy URL borrowing the loader's bytes for the whole download. Off the JS thread that is a race with process.env.HTTP_PROXY = ... (which replaces the map entry and frees the old bytes). So CompileTarget::download_options now copies those two values out on the calling thread (Config::from_js for the JS API, so the decision is made when Bun.build() is called, like fetch(); immediately before to_executable for the CLI, i.e. exactly when it was read before), and to_executable / download_to_path take that DownloadOptions instead of a Loader. The step now touches task-owned data and the file system only; self.env is used solely by the pre-existing Transpiler::init call, and bun_standalone_graph no longer depends on bun_dotenv.
  • The download itself is AsyncHTTP::send_sync, a blocking wait on the HTTP thread, which works from any thread (it is how the CLI uses it). On main-thread exit the HTTP thread is parked only after wait_for_embedded_work, and embedded_work_finished is reported after the compile, so a teardown during a download waits for it rather than deadlocking. That wait is not new: before, nothing could even call process.exit() until the download returned.
  • inject() and a failed sourcemap write report their specific cause to stderr. Output buffers stderr per thread and nothing else ever flushes the bundle thread's buffer, so the step flushes after compiling (pool workers flush after each task for the same reason). The new stderr test fails if the flush is removed.
  • stop_for_vm_teardown can still run concurrently with the compile; it only touches atomics and the plugin cell, neither of which do_compilation uses. The loop pointer it may wake is the bundle thread's thread-lifetime uws loop, not the per-build AnyEventLoop.
  • HTML routes and compile + target: "browser" builds never reach the step (config.compile is None by then), exactly as before.
  • The bundle thread gets DEFAULT_THREAD_STACK_SIZE like bun's other threads instead of Rust's 2 MiB default. On Windows do_compilation + to_executable + inject declare well over half a megabyte of PathBuffer / WPathBuffer locals between them; that fits in 2 MiB, this just removes the question.

bun build --compile calls to_executable synchronously on its main thread, which is fine since there is no event loop to block; apart from building the DownloadOptions at the call site it is untouched. #33621 (mmap the source binary instead of copying it) is complementary: it makes the step cheaper, this takes it off the JS thread. #34353 (stop continuing past an inject() failure) is also compatible: it keeps the stderr line the new test asserts and still produces one log entry.

Tests

All in test/bundler/bun-build-compile.test.ts, in a concurrent describe. None of them copies the bun binary: the download is answered with a 404 by a server inside the test (BUN_COMPILE_TARGET_TARBALL_URL plus a fresh BUN_INSTALL_CACHE_DIR, target OS different from the host), or the template is rejected up front. About 0.5s each under a debug build.

  • the event loop keeps running meanwhile: the server withholds the 404 until the building child has answered an IPC ping. A child whose JS thread is inside the compile step cannot answer, so the unfixed build deterministically reports outcome: "did not answer the ping" (the 4s bound only decides how long to wait before saying so; the unblocked child answers in about 50ms under a debug build, 50 to 170ms with every core busy). Also asserts the error produced on the bundle thread comes back through logs and that the child exits on its own.
  • the download uses the proxy settings from when Bun.build() was called, HTTP_PROXY set before / after the call: a second server plays proxy; the request must arrive at the proxy in the first case and at the registry in the second. The "after" case fails on the unfixed build and on the first revision of this PR (both read the environment when the download starts).
  • a template that cannot be patched reports the cause on stderr: executablePath pointing at a text file with a Linux target, so the ELF code rejects it on every host; asserts the exact stderr line, one log entry, and no outfile. Passes on the unfixed build (the JS thread's buffer was flushed at exit) and fails if the bundle-thread flush is removed.

Also run on this branch: the rest of bun-build-compile.test.ts, bun-build-compile-sourcemap.test.ts (sourcemap writes now on the bundle thread), compile-asset-bunfs.test.ts (asset collection and the compile error path), bundler_compile.test.ts -t HelloWorldWithProcessVersionsBunAPI and its --compile-executable-path case (plugins + compile, CLI cross target), compile-windows-metadata.test.ts, standalone.test.ts (compile + browser target), bun-build-api.test.ts; all pass. The only CI failure on the previous revision was worker-transfer-list.test.ts on the x64-asan lane, a known worker-termination flake (#37267, #34655, #34644).


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bun-build-compile.test.ts

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.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Bun.build executable compilation now runs on the bundle thread before VM completion dispatch. Dotenv access is shared, TLS rejection caching is atomic, and a regression test checks event-loop responsiveness during cross-target compilation.

Changes

Bundle compilation flow

Layer / File(s) Summary
Shared environment state
src/dotenv/env_loader.rs, src/options_types/compile_target.rs, src/standalone_graph/StandaloneModuleGraph.rs, src/runtime/cli/build_command.rs, src/runtime/api/js_bundle_completion_task.rs
The TLS rejection cache uses atomic Unknown, No, and Yes states. Executable-generation APIs now accept immutable dotenv loaders.
Bundle-thread compilation and validation
src/bundler/BundleThread.rs, src/runtime/api/js_bundle_completion_task.rs, test/bundler/bun-build-compile.test.ts
Executable compilation runs on the bundle thread before completion dispatch. The test checks IPC responsiveness during cross-target compilation and validates the expected error.

Suggested reviewers: jarred-sumner, dylan-conway

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes moving executable production to the bundle thread to prevent event-loop blocking.
Description check ✅ Passed The description explains the problem, cause, fix, compatibility considerations, and verification results in sufficient detail.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced, fixed, CI green on the current revision (build 92438, 190/190), ready for review.

Reproduced with a debug build: a 1ms setInterval running across await Bun.build({ compile }) shows a single gap from 144ms (bundling done) to 4304ms (executable written); with this branch the interval keeps ticking for the whole build. In test/bundler/bun-build-compile.test.ts, "the event loop keeps running meanwhile" fails on the unfixed binary (outcome: "did not answer the ping") and the "HTTP_PROXY set after the call" case fails on it too; both pass here.

Comment thread src/standalone_graph/StandaloneModuleGraph.rs
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.
Comment thread src/bundler/BundleThread.rs Outdated
Comment thread src/dotenv/env_loader.rs Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/standalone_graph/StandaloneModuleGraph.rs`:
- Line 1657: Update the progress-rendering path around Progress::refresh to
serialize all writes to the shared stderr descriptor across threads. Add and
reuse process-wide synchronization around fd_write_all_quiet, or ensure progress
rendering is dispatched exclusively on the JS thread; do not rely on the
thread-local Source for descriptor safety.

In `@test/bundler/bun-build-compile.test.ts`:
- Around line 108-119: Move proc.send("ping") into the existing try block in the
ping/timeout flow so any IPC send failure reaches the finally block and clears
giveUp. Preserve the current Promise.race behavior and outcome handling.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9f29f409-2705-4e80-a989-dd514d99d695

📥 Commits

Reviewing files that changed from the base of the PR and between da3851e and 94088de.

📒 Files selected for processing (7)
  • src/bundler/BundleThread.rs
  • src/dotenv/env_loader.rs
  • src/options_types/compile_target.rs
  • src/runtime/api/js_bundle_completion_task.rs
  • src/runtime/cli/build_command.rs
  • src/standalone_graph/StandaloneModuleGraph.rs
  • test/bundler/bun-build-compile.test.ts

Comment thread src/standalone_graph/StandaloneModuleGraph.rs Outdated
Comment thread test/bundler/bun-build-compile.test.ts Outdated
Comment thread src/runtime/api/js_bundle_completion_task.rs Outdated
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:16 PM PT - Aug 11th, 2026

@robobun, your commit 73cce2b2825ec1a93f055be0a389e2ce5c9bba04 passed in Build #92438! 🎉


🧪   To try this PR locally:

bunx bun-pr 37507

That installs a local version of the PR into your bun-37507 executable, so you can run:

bun-37507 --bun

…() is called

download_to_path read NODE_TLS_REJECT_UNAUTHORIZED and the proxy variables
from the VM's env loader, and handed the HTTP client a proxy URL borrowing
the loader's bytes for the whole download. Now that the download runs on
the bundle thread, a process.env.HTTP_PROXY assignment on the JS thread
could free those bytes under it. CompileTarget::download_options copies
the two values out on the calling thread (Config::from_js for the JS API,
right before to_executable for the CLI) and to_executable takes that
instead of the loader, so the compile step no longer touches the loader
at all and the env memo can stay a plain Cell.
Both new tests 404 the download or reject the template up front, so
neither copies the bun binary.
Comment thread src/options_types/compile_target.rs Outdated
Comment thread src/options_types/compile_target.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed after 3342892: the DownloadOptions snapshot supersedes my earlier Cell race note and also removes the borrowed proxy-URL held across the download, so the bundle-thread compile step no longer touches the VM's env loader at all. No further issues found. Because this moves file I/O, the cross-target download, and executable patching onto the bundle thread and leans on the embedded_work / stop_for_vm_teardown ordering for teardown safety, a human pass on the concurrency reasoning would still be worthwhile.

Checked: do_compilation now reads only task-owned data (config, log, output_files) and DownloadOptions; self.env is no longer dereferenced there. compile_on_bundle_thread preserves the prior result/log shape (CompileResult::Err → log entry + CompilationFailed) and skips on cancelled, matching the old on_complete early-return. The Output::flush() after compile is covered by the new stderr test; the CLI path is unchanged apart from building DownloadOptions at the call site.

Extended reasoning...

Overview

Moves Bun.build({ compile })'s executable-production step (do_compilationto_executable → possible cross-target download + binary patch + sourcemap writes) from on_complete on the JS thread to compile_on_bundle_thread, which runs inside complete_on_bundle_thread before the completion is posted back. To make that step thread-safe, to_executable/download_to_path now take a DownloadOptions (reject_unauthorized + owned proxy href) that CompileTarget::download_options copies out of the env loader on the calling thread — Config::from_js for the JS API, immediately before to_executable for the CLI. bun_standalone_graph drops its bun_dotenv dependency, exe_path loses its unused _env param, the bundle thread gets DEFAULT_THREAD_STACK_SIZE, and an Output::flush() is added after the compile so bundle-thread stderr reaches the fd. Three new tests cover the event-loop responsiveness, the proxy-snapshot semantics (before/after), and the stderr flush.

Security risks

None identified. DownloadOptions::default() keeps reject_unauthorized: true; the real value is filled in from the loader on the JS thread before the config leaves it (standalone-HTML / browser-target builds that skip the fill-in also clear config.compile, so the default is never consulted for a download). The proxy href is now an owned Box<[u8]> rather than a borrow into the env map, closing a use-after-free window if user JS reassigned process.env.HTTP_PROXY mid-download.

Level of scrutiny

High. This is a thread-affinity change to a path that does blocking file I/O, a synchronous HTTP download, and executable patching, and it interacts with VM-teardown ordering (cancelled, bundle_loop, embedded_work_finished). The PR description enumerates each concern (env-loader access, send_sync off-thread, per-thread stderr buffering, stop_for_vm_teardown concurrency, HTML/browser-target builds, stack size) and the diff addresses each; my earlier data-race finding was resolved by removing the loader access entirely. That said, the correctness argument depends on invariants scattered across BundleThread, LoopHandle, and the HTTP thread's shutdown ordering, which is exactly the kind of reasoning a maintainer should confirm.

Other factors

All prior review threads (my Cell race note, CodeRabbit's timer-cleanup and stderr-interleaving comments, the comment-cop long-comment flags) are resolved in the current revision. The new tests are hermetic (local 404 server via BUN_COMPILE_TARGET_TARBALL_URL, fresh BUN_INSTALL_CACHE_DIR, proxy env cleared), await observable conditions rather than sleeping, and the PR states the "after" proxy case and the ping test fail on the unfixed build. CI on the previous revision was green apart from a known unrelated flake.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant