bundler: stop the plugin hops from reaching back into the pass that posted them - #37709
bundler: stop the plugin hops from reaching back into the pass that posted them#37709robobun wants to merge 8 commits into
Conversation
…n thread Resolve::run_on_js_thread, Load::run_on_js_thread and DeferredBatchTask::run_on_js_thread run on the plugins' JS thread. For Bun.build the BundleV2 they point at is owned by the bundle thread, which is inside wait_for_parse reborrowing it as &mut on every is_done while these run. All three dereferenced their backref to a &mut BundleV2 (or walked back to one with from_field_ptr!) only to call plugins_mut(). They now read the plugin handle through the raw pointer with BundleV2::plugins_on_js_thread(*const Self); plugins (and completion, for the deferred batch) are Copy fields set before the pass starts. plugins_mut() has no callers left and is removed. DeferredBatchTask ::schedule takes the pass it is embedded in instead of recovering it, and init() is folded into it (both only reset the debug flag). A source lint keeps the three hop bodies free of borrows of the pass.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (7)
💤 Files with no reviewable changes (1)
WalkthroughChangesThe PR refactors deferred plugin scheduling to use arena-allocated tasks and cached plugin pointers. It removes completion-error inspection from completion dispatch and makes deferred promise draining resolve with Suggested reviewers: Possibly related PRs
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Status: ready for review (current revision 9f569d0). Reproduced as a source-level finding rather than a runtime failure: on main, Revisions since the first review: the hops carry a copy of the plugin handle and the always-false Related: #37746 and #37740 touch the same lines (different bugs, compose with this one; whichever lands second needs a small rebase, see the comment below). |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it reworks cross-thread unsafe access to BundleV2 (raw-pointer field reads under Stacked/Tree Borrows reasoning) and is one of a family of related aliasing fixes (#37691, #37626), a human look at the memory-model claims would still be worthwhile.
What was reviewed:
- Confirmed
BundleV2::plugins_muthad no other callers (the hit injs_bundle_completion_task.rsis a different type's method);get_bundle_v2/initare fully removed. - Checked
pluginsandcompletionare bothCopyfields, so(*this).fieldreads them without forming a&BundleV2;.is_some_andis equivalent to the old.map().unwrap_or(false). - Verified the folded
schedule()preserves the exact debug-flag sequence of the oldinit()+schedule()pair;drain_defer_taskispub(crate)and reachable. - The new source-lint follows the established
test/internal/source-lints/pattern and guards against the hop list drifting.
Extended reasoning...
Overview
This PR removes three sites where the bundler's plugin-thread hops (Resolve::run_on_js_thread, Load::run_on_js_thread, DeferredBatchTask::run_on_js_thread) formed &mut BundleV2 on a thread that does not own the pass, while the bundle thread concurrently holds and mutates through its own &mut BundleV2. It replaces plugins_mut(&mut self) with plugins_on_js_thread(this: *const Self), which reads the immutable-for-the-pass plugins field through a raw pointer and returns the opaque ZST handle. DeferredBatchTask::get_bundle_v2 (the &mut-returning container-of walk-back) and init() are removed; schedule() now takes the &mut BundleV2 its sole caller already has. Four files touched: three Rust sources in src/bundler/ and one new source-lint test.
Security risks
None. This is an aliasing-model correctness change with no observable runtime behavior difference; no user input handling, auth, crypto, or permissions are involved.
Level of scrutiny
High. This is native memory-safety code — the most-blocked category per REVIEW.md — with new unsafe blocks whose SAFETY comments make specific claims about cross-thread field-read races, Stacked/Tree Borrows provenance, and ZST &mut overlap. The reasoning is careful and I believe correct ((*raw_ptr).copy_field is a place-expression read that forms no reference; both fields are Copy and set once before the pass; JSBundlerPlugin is an opaque_ffi! ZST so &mut to it covers zero bytes), but these are exactly the subtle claims a maintainer tracking the #37691 / #37626 family should sign off on.
Other factors
- Verified no orphaned callers:
plugins_mutinjs_bundle_completion_task.rsis an unrelated inherent method on the completion task, notBundleV2::plugins_mut;get_bundle_v2anddrain_defer_task.init()/.schedule()have zero remaining hits. - The
init()+schedule()fold is behavior-preserving:initdiddebug_assert!(!running)thenmem::take(self)(only field is the debugrunning: bool, defaultfalse);schedulediddebug_assert!(!running)thenrunning = false. The newscheduledoes the same assert + assignment. The odd-lookingrunning = false(rather thantrue) is pre-existing and preserved verbatim. CompletionHandleis#[derive(Copy, Clone)](bundle_v2.rs:1451) andplugins: Option<NonNull<JSBundlerPlugin>>isCopy, so both raw-pointer field reads are valid copy-outs.- The source-lint test matches sibling lints (
fn-long-mut-reborrow.test.ts,frozen-nonnull-reborrow.test.ts) in structure, usesglobAllSourcesfrom the shared helper, guards against vacuous passes (scanned > 0, hop-location assertion), and strips comments before matching so prose about the banned shapes doesn't false-positive. - Existing plugin/defer/bake test suites were run on a debug ASAN build per the PR description; clippy and rustfmt clean.
|
For whoever takes the human look the review above asks for, the change rests on three facts, each checkable in one place:
Source lints, clippy and Format are green on this push; the rest of CI is still running. |
|
Updated 8:32 AM PT - Aug 12th, 2026
✅ @robobun, your commit 9f569d0d99b93a50a5d1503106ecd03cbae7aa95 passed in 🧪 To try this PR locally: bunx bun-pr 37709That installs a local version of the PR into your bun-37709 --bun |
…erred reject path Resolve, Load and DeferredBatchTask now carry BundleV2::plugins, copied when they are built on the bundle thread, the way Resolve and Load already copy everything else their plugin-thread body needs. The bodies read only their own fields, so the raw-pointer accessor and the from_field_ptr! walk-back from the previous commit go away, along with DeferredBatchTask's never-set debug flag. The deferred batch also read completion.result_is_err() to decide whether to reject the .defer() promises. result stays Pending until after the pass returns, and the batch only ever runs while the pass is waiting on the deferred loads, so it was always false (bake passes have no completion at all); it was also a read of a struct the bundle thread holds &mut to for the whole pass. Remove it end to end: the vtable entry, the handle method, and drainDeferred's rejected parameter. The lint now bans any route from a hop body back to the pass.
…ng it in the pass Embedded in BundleV2, the task's storage was part of the pass, so the &mut DeferredBatchTask the dispatch arm forms on the plugin thread still pointed into the struct the bundle thread holds &mut to (now that the task has a real field). Allocate it per drain from the pass's arena, as Resolve and Load are, and drop the BundleV2 field.
|
107eac1 addresses the remaining review finding: |
There was a problem hiding this comment.
The embedded-field concern from my earlier comment is resolved by 107eac1 — DeferredBatchTask is now arena-allocated per drain and the drain_defer_task field is gone from BundleV2, so the dispatch-site cast! no longer points into the pass. Also verified the removed rejected path was unreachable: completion.result is set only after run_from_js_in_new_thread returns (js_bundle_completion_task.rs:1261), and the drain runs inside is_done while it is still Pending, so result_is_err() was always false.
Extended reasoning...
My prior inline comment (2026-08-12T13:38) flagged that copying plugins into an embedded DeferredBatchTask still left the dispatch-site &mut pointing 8 bytes into BundleV2; commit 107eac1 addressed that by arena-allocating the task and removing the drain_defer_task field, matching the Resolve/Load shape. I also checked that enqueue_on_js_loop_for_plugins (the other call schedule makes through transpiler) reads only plugins/completion/js_poster — no graph.* beyond the heap read the inline nit already covers. The JSBundlerPlugin__drainDeferred signature change is consistent end-to-end (Rust FFI decl, C++ definition, sole caller). Leaving the approval decision to a human given this is cross-thread unsafe/aliasing code.
… is called with drain_deferred_tasks holds &mut Graph while it passes the pass along; reaching the arena through bv2.arena() would read graph.heap through the second alias, which the SAFETY comment at the is_done call site rules out. Pass self.heap instead.
|
c536f60 picks up the follow-up nit: |
There was a problem hiding this comment.
Both earlier findings are addressed at d4ade19: DeferredBatchTask is now arena-allocated per drain (no longer a subobject of BundleV2, so the dispatch arm's &mut no longer points into the pass), and schedule takes the arena as a separate argument so drain_deferred_tasks no longer re-projects graph through transpiler — the SAFETY comment at the is_done call site holds as written. Grepped for drain_defer_task / result_is_err: no remaining references in src/ beyond the one stale trait doc noted inline.
Extended reasoning...
The two aliasing concerns raised in earlier passes were re-checked against the current head (d4ade19). grep -rn 'drain_defer_task' src/ returns nothing — the embedded field, get_bundle_v2(), init(), and the debug running flag are all gone, and DeferredBatchTask is a separate arena allocation like Resolve/Load. schedule(bv2, arena) reads only bv2.plugins (direct BundleV2 field) and calls bv2.enqueue_on_js_loop_for_plugins; nothing reaches bv2.graph. The remaining inline finding is a doc-only nit in a file this PR does not touch.
|
Two notes for whoever merges this:
|
There was a problem hiding this comment.
I reviewed this revision and found no issues — all three prior findings (embedded DeferredBatchTask storage, schedule re-projecting graph.heap through transpiler, stale result == .err trait doc) are addressed in 107eac1, c536f60, and 9f569d0 respectively. Because the change turns on cross-thread &mut aliasing reasoning in the bundler and drops the rejected arm of JSBundlerPlugin__drainDeferred, a human look would still be worthwhile.
The open comment-cop note on BundleThread.rs:82 is the same pre-existing trait-doc case as the resolved ones on bundle_v2.rs — the edit shortens the comment.
What was reviewed:
- Verified
DeferredBatchTaskis now arena-allocated (not embedded), so the dispatch-site&mutno longer points intoBundleV2. - Checked
schedule(bv2, arena)takes the arena fromdrain_deferred_tasks'sself.heap, keeping theis_doneSAFETY comment accurate. - Confirmed
rejectedwas alwaysfalse(resultisPendinguntilrun_from_js_in_new_threadreturns; bake has nocompletion), so removing the reject branch is behavior-preserving. grepforresult_is_err/plugins_mutonBundleV2— no remaining callers.
Extended reasoning...
Overview
This PR removes cross-thread &mut BundleV2 aliasing from the three plugin hops (Resolve, Load, DeferredBatchTask) that a Bun.build pass posts to the plugins' JS thread. Each hop now carries a copy of the plugins handle instead of reaching back into the pass via &mut *self.bv2 / from_field_ptr!. DeferredBatchTask moves from an embedded field of BundleV2 to a per-drain arena allocation. The always-false rejected path of JSBundlerPlugin__drainDeferred is removed end to end (Rust FFI decl, C++ body, CompletionDispatch::result_is_err vtable entry). A source-lint test guards the three hop bodies against reintroducing routes back to the pass.
Security risks
None. No user-controlled input handling, no auth/crypto, no new external surface. The change narrows an existing unsound aliasing pattern.
Level of scrutiny
High. This is memory-safety reasoning about Rust's aliasing model across threads in the bundler core, with an FFI signature change and an arena-lifetime argument for the newly heap-allocated DeferredBatchTask. The reasoning is detailed and I found it sound after three review rounds, but it is exactly the kind of subtle invariant a maintainer should confirm — particularly the claim that completion.result is always Pending during the drain window (which justifies dropping the reject branch) and that the pass's arena outlives every posted DeferredBatchTask including on the cancel path.
Other factors
All three findings I raised in earlier passes were addressed with targeted commits and the author's replies match the code. The bug-hunting system found nothing on this revision. The plugin test suites (bundler_plugin, bundler_defer, bake plugins.test.ts) are reported passing on a debug ASAN build. The one open comment-cop note on BundleThread.rs:82 is a false positive on a trait doc comment this PR shortens. Given the complexity and the critical-path location, deferring rather than approving.
|
The comment-cop note on BundleThread.rs mentioned above was already answered and resolved (pre-existing trait doc, only shortened here); there are no open review threads on 9f569d0. Format, source lints, clippy and comment-cop are green on this revision; the Buildkite build is still running. |
|
Overlap note: #37883 (under review by alii) now has |
Problem
Bun.buildpass posts three kinds of task to the plugins' JS thread (one per onResolve request, one per onLoad request, one to settle.defer()promises). Each of them formed a fresh&mut BundleV2back into the pass just to fetch the plugin handle.&mut BundleV2, and parse workers hold&BundleV2. A second live&mutto a struct another thread is mutating is a false claim under Stacked/Tree Borrows and to LLVM'snoalias, even though these bodies only read one pointer..defer()task also read the build's completion result from the plugin thread to decide whether to reject the promises. That result is still pending whenever the task runs, so the flag was alwaysfalse, and the read touched state the bundle thread holds&mutto.Fix
plugins_mutaccessor is deleted. This is sound becausepluginsis set before the pass starts and never written again, so a copy taken at construction cannot go stale..defer()task used to be embedded inside the pass and walked back to it withfrom_field_ptr!. It is now allocated per drain from the pass's arena, the same shape as the resolve and load tasks.rejectedflag is removed end to end; the drain always resolves the promises, which is what happened before as well.Background
BundleV2is one bundle pass. ForBun.buildit lives on a dedicated bundle thread, which sits inwait_for_parseholding&mut BundleV2and updating the module graph through it until every file is parsed.bv2backref because it runs on the pass's own loop.args.defer()inside an onLoad plugin hands out a promise the pass settles later in one batch;DeferredBatchTaskis the hop that performs that batch.Plugin::opaque_mutturns the raw pointer into a reference to it directly, so a hop does not need the pass to reach it.&mutasserts exclusive access for as long as it is live. Rust's aliasing models (Stacked/Tree Borrows) and LLVM'snoaliasoptimisations rely on that assertion, so a second&mutis wrong even when no bytes actually race.Original description
What does this PR do?
Resolve::run_on_js_threadandLoad::run_on_js_thread(src/bundler/bundle_v2.rs) andDeferredBatchTask::run_on_js_threadare the three tasks a bundle pass posts to the plugins' JS thread throughenqueue_on_js_loop_for_plugins; the runtime'srun_taskdispatches them there. ForBun.buildtheBundleV2they point back at lives on and is driven by the bundle thread, which while they run is insidewait_for_parse->tick_raw, reborrowing the whole struct as&mut BundleV2on everyis_doneand writinggraph.*through it (parse workers hold&BundleV2throughWorker.ctxat the same time). All three hops nevertheless formed their own&mut BundleV2on the plugin thread:That is a second live
&mutto a struct another thread is mutating through its own, created only to satisfyplugins_mut(&mut self), whose SAFETY comment ("&mut selfensures no other projection overlaps") is also not true across threads: the bundle thread and the parse workers callplugins_ref()during the same window. Nothing misbehaves today (the bodies read one pointer-sized field, and the plugin handle is anopaque_ffi!ZST), but under Stacked/Tree Borrows and for LLVM'snoaliasthe&mutis a false claim, the same family as the cross-thread posting fixes in #37691 and #37626.The only thing the hops need from the pass is
plugins, which is set before the pass starts (init_and_run/BundleV2::init) and never written again.ResolveandLoadalready copy everything else their plugin-thread body needs when they are built (MiniImportRecord,path,namespace,default_loader), sopluginsnow goes the same way:ResolveandLoadget aplugins: Option<NonNull<Plugin>>field, copied from the pass ininit;DeferredBatchTaskgets the same field, copied inschedule. The threerun_on_js_threadbodies read their own field and call the FFI through the safePlugin::opaque_mut; they contain nounsafeand do not name the pass.BundleV2::plugins_muthas no callers left and is removed.DeferredBatchTaskused to be embedded in the pass (BundleV2::drain_defer_task), which is why it walked back to it withfrom_field_ptr!, and also why the&mut DeferredBatchTaskthe dispatch arm forms on the plugin thread would still have pointed into the pass once the struct carried a real field. It is now allocated from the pass's arena per drain, likeResolve/Load(scheduletakes the pass and the arena from its only caller,Graph::drain_deferred_tasks, which holds&mut Graphat that point and so passesself.heaprather than havingscheduleread it back through the pass), and the field is gone fromBundleV2, along withget_bundle_v2(),init()and the debugrunningflag (which was never set to true). One pointer-sized allocation per drain round;release_unrunstays a no-op for the same reason asResolve's.completion.result_is_err()to decide whether to reject the.defer()promises.resultisPendinguntilrun_from_js_in_new_threadhas returned, and the batch only runs while the pass is still waiting on the deferred loads, so it was alwaysfalse(bake passes have nocompletionat all); it was also a read, from the plugin thread, of the completion task the bundle thread holds&mutto for the whole pass. It is removed end to end:CompletionDispatch::result_is_err,CompletionHandle::result_is_err, and therejectedparameter ofJSBundlerPlugin__drainDeferred, which now always resolves. No observable change: the promises were always resolved before as well.Not changed: the
bv2backref itself stays onResolve/Loadfordispatch()and for delivering the answer, which run on the loop that owns the pass (on_load_from_js_loop,on_notify_defer), as do the same-thread&mut *x.bv2sites there. The runtime-side answer thunks insrc/runtime/api/JSBundler.rs(bv2_mutinonResolveAsync/onLoadAsync/addError/on_defer) are the other half of this and are handled by #37691; the two apply independently, and with this PRbv2_pluginthere could read the copied field later.DeferredBatchTaskis kept as the posted task rather than posting the pass pointer itself: something tagged has to be posted, and a separately allocated task that carries what its body needs is the same shape asResolve/Load.How did you verify your code works?
There is nothing to observe at runtime, so the test is a source lint,
test/internal/source-lints/bundler-plugin-hops-no-pass.test.ts: it extracts the bodies of the threerun_on_js_threadfns insrc/bundler/and fails on any route back to the pass (bv2,BundleV2, an accessor named after the pass, the parse task'sctx,from_field_ptr!/container_of,plugins_ref()/plugins_mut()), with a guard that the three hops are still where it looks. On main it reports all three hops (nine hits); it passes here.cargo clippy -p bun_bundler -p bun_runtime --no-depsclean;rustfmt --checkandclang-formatclean.test/bundler/bundler_plugin.test.ts(53 pass),bundler_plugin_chain.test.ts(13 pass),bundler_defer.test.ts(10 pass, covers the drain) exercise all three hops on theBun.buildbundle thread;test/bake/dev/plugins.test.ts(3 pass) covers the bake arm, where the hops run on the loop that owns the pass. Intest/js/web/workers/worker-terminate-funnels.test.tsthepoolcase (a build cancelled while plugin requests are outstanding) passes; the whole file passed on the earlier revision, and on the final one the unrelated timers/messaging/net/http/fs cases hit their 30s timeouts on a heavily loaded box, so those are left to CI.While testing this I noticed that an
onLoadplugin callingargs.defer()without awaiting it underflows the scan counter (debug panic inon_parse_task_complete, hang on the released build); that is pre-existing and unrelated to these hops, and has been reported separately.