bundler: join in-flight pool tasks before tearing the bundle down - #37480
bundler: join in-flight pool tasks before tearing the bundle down#37480robobun wants to merge 1 commit into
Conversation
A bundle pass that fails between scheduling work on the thread pool and the step that normally joins it reached deinit_without_freeing_arena with tasks still running: an entry point that does not resolve returns BuildFailed while the runtime's and the other entry points' parse tasks are in flight, and a link error with source maps enabled returns while the source-map tasks scheduled by link() are in flight. Teardown then iterated workers_assignments while a pool thread was still initializing its Worker (SEGV in Worker::deinit_soon on the uninitialized thread pointer), or missed a Worker created after the map was walked, whose thread exit later tripped a mimalloc assertion while the process was exiting. Make deinit_without_freeing_arena drain what is still pending (wait_for_parse for synchronous passes, plus the two source-map wait groups) before touching the workers, so every driver (bun build, bake production, Bun.build) is covered by the one teardown path. The explicit wait-group waits on Bun.build's error path are now redundant and removed.
|
Status: reproduced on a debug build of main ( |
|
Warning Review limit reached
Next review available in: 4 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Comment |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes thread-pool teardown ordering across every bundler driver, a human look at the !asynchronous guard and the assumption that every synchronous caller has an event loop set would still be worthwhile.
What was reviewed:
WaitGroup::wait()is a no-op on a default (count 0) or already-joined group, so the unconditional waits indeinit_without_freeing_arenaare safe on paths that never scheduled source-map tasks or already joined them ingenerateChunksInParallel.- All three synchronous drivers (
generate_from_cli,generate_from_bake_production_cli,init_and_run) pass anevent_looptoBundleV2::init, sowait_for_parse()'sr#loop().expect(...)won't panic when reached from teardown. - The removed error-path waits in
init_and_runare subsumed by the new waits insidedeinit_without_freeing_arena, which now runs on both arms.
Extended reasoning...
Overview
The PR moves the join of in-flight parse and source-map thread-pool tasks into BundleV2::deinit_without_freeing_arena so that every driver (CLI, bake production, Bun.build) drains outstanding work before tearing down the workers. Previously the CLI error paths (entry-point resolution failure, link failure with --sourcemap) reached teardown with tasks still running Worker::get, causing SEGV/abort in debug builds. Production change is ~14 lines in bundle_v2.rs plus a simplification in js_bundle_completion_task.rs that removes now-redundant error-path waits; a comment update in BundleThread.rs; and three subprocess regression tests in test/bundler/cli.test.ts.
Security risks
None. The change affects internal teardown sequencing of the bundler thread pool; no user-controlled input handling, auth, crypto, or trust boundaries are touched.
Level of scrutiny
High. This is a concurrency/memory-safety fix in a critical path — bundler thread-pool teardown — where the invariant being restored ("no task references this bundle when workers are destroyed") depends on subtle reasoning about which drivers are synchronous vs asynchronous, when pending_items can be nonzero, and whether wait_for_parse() can safely tick the event loop from inside teardown. The !self.asynchronous guard deliberately excludes the dev server path on the basis that it only reaches deinit once is_done(); that reasoning is stated in the doc comment and PR description but merits a human check by someone familiar with the bake dev-server lifecycle.
Other factors
I verified: WaitGroup::wait() locks, checks raw_count > 0, and returns immediately when zero, so the two unconditional waits are safe no-ops on paths that never scheduled source-map work or already joined it. All three synchronous callers pass a non-None event_loop to BundleV2::init, so the r#loop().expect(...) inside wait_for_parse() will not panic when invoked from teardown. The refactor in init_and_run collapses the Ok/Err match into a single deinit_without_freeing_arena() call on both paths, which is behavior-preserving now that the waits live inside deinit. The new tests follow harness conventions (tempDir, bunEnv, concurrent pipe drain, exact stderr assertions, describe.concurrent) and cover all three repro shapes from the PR description. The PR also notes a user-visible side effect (CLI now reports errors from resolved entry points when a sibling fails to resolve), which is a behavior change worth a maintainer ack.
|
On the two points flagged above, for whoever takes the human look:
Event loop: every caller that can reach the |
|
Updated 4:52 AM PT - Aug 11th, 2026
✅ @robobun, your commit 7655228968385d777932929d862205f4f24f3800 passed in 🧪 To try this PR locally: bunx bun-pr 37480That installs a local version of the PR into your bun-37480 --bun |
|
I ended up at the same crash from the Measured on main at f89d370 (debug/ASAN build):
|
|
#39855 carries this teardown join (the two wait groups in |
|
Closing in favor of #39855. #39855 carries this change: the two source map waits in |
Repro
All three should print the build error and exit 1. On a debug (ASAN) build of main they die during teardown instead, in one of two ways:
(the second one aborts on a
Bun Pool Nthread while the main thread is already insideexit). Release builds have the same race without the diagnostics; the usual visible outcome there is just the error line.Cause
Every entry point is handed to the thread pool as soon as it resolves (the runtime's own parse task even earlier), and
link()schedules the source-map tasks before any of its error returns. A pass that fails after either point (generate_from_cli,generate_from_bake_production_cli, and the enqueue failure path ofrun_from_js_in_new_thread) went straight todeinit_without_freeing_arenawith those tasks still runningWorker::geton the pool:ThreadPool::get_worker_slowpublishes theWorkerpointer inworkers_assignmentsbefore writing the struct, so teardown walking the map read thethreadfield of an uninitialized allocation (ASAN's0xbefill, hence the address above) and pushed onto it.Workercreated after teardown walked the map was never torn down; the work its thread has left to do on exit then races the main thread'sexit()once the error is printed, which is the mimalloc assertion.The Zig version never freed anything on the CLI error path, so this appeared with the port's teardown-on-every-exit.
run_from_js_in_new_threadalready waited for the parse stage even on error, andBun.build's caller waited on the two source-map wait groups before deinit; the CLI drivers did neither.Fix
deinit_without_freeing_arenanow joins whatever is still on the pool before it touches the workers:wait_for_parse()whenpending_items > 0(synchronous passes only), then the two source-map wait groups (both are no-ops when nothing was scheduled or the join already happened). Teardown is the one place every driver funnels through and the point where the "no task is still running against this bundle" invariant is actually needed, so enforcing it there coversbun build, bake production,Bun.build, and the exits no driver handled (the dependency scanner return afterlink, enqueue failures) in one place. The now redundant wait-group waits ininit_and_runare removed.The dev server's asynchronous pass is excluded on purpose: it is driven by the JS event loop and only reaches this function once
is_done(), and ticking that loop from teardown would be wrong. Tearing a dev server or VM down with tasks in flight is a different bug (#31702).A user-visible consequence for the CLI: when one entry point does not resolve, errors from the entry points that did resolve are now reported as well, which is what
Bun.build()already did.Verification
test/bundler/cli.test.tsgains the three repros above, asserting the exact stderr and exit code. On the unfixed debug build the first fails on every run (exit 134 plus the mimalloc lines), the other two on most runs (ASAN report); all pass with the fix. The existingBun.build"sourcemap + build error crash case" covers the JS API side now relying on the join in teardown.Rates on the unfixed and fixed debug build (20 sequential / 24 runs at 8-way concurrency each)
./missing.js ./app.js./app.js ./nodir/x.js./entry.js --sourcemap(bad named import)Also run with the fix:
bun-build-api.test.ts,bundler_html.test.ts,bake/dev/production.test.ts,bake/dev/bundle.test.ts,bake/deinitialization.test.ts,cli/test/test-changed.test.ts.