Skip to content

bundler: allocate, link and post a plugin request through one pointer instead of a &mut receiver - #37732

Open
robobun wants to merge 6 commits into
mainfrom
farm/a48ea255/bundler-plugin-dispatch-raw-request
Open

bundler: allocate, link and post a plugin request through one pointer instead of a &mut receiver#37732
robobun wants to merge 6 commits into
mainfrom
farm/a48ea255/bundler-plugin-dispatch-raw-request

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Nothing misbehaves today. This is an aliasing-contract fix in the same family as bundler: hand a finished Bun.build back through its pointer, not a &mut receiver #37723 and bundler: make Worker::deinit_soon take the worker pointer instead of &mut self #37685.
  • When a bundle pass hands an onResolve or onLoad request to the plugin thread, dispatch(&mut self) makes two pointers to the request from the same receiver: one kept in the pass's outstanding list, one posted to the JS thread as the task.
  • Dispatching the next request writes the previous request's list link through the list's pointer. Under Rust's aliasing model that invalidates the posted pointer, and consuming the answer through it is UB. This happens as soon as two requests are outstanding, the normal state of a build whose plugins answer asynchronously.
  • A reduction with the real list shape fails under Miri with both Tree Borrows and Stacked Borrows at the answer's reborrow (Undefined Behavior: reborrow through <4058> at alloc1960[0x0] is forbidden), and passes once the list and the task hold one and the same pointer.

Fix

  • The two dispatch bodies become one function on the pass that takes the request by value, allocates it in the arena, links the *mut the arena hands back, and posts that same *mut. The old names stay as one-line forwarders; callers no longer preallocate a default slot.
  • Correct because the arena's pointer is now the only pointer to the request that ever exists, so the list, the JS thread and the answer all go through it. Order of operations is unchanged, including the cancelled path, and no unsafe remains on this path.
  • Verification: a new source lint pins this shape; it reports the two old push(self) sites with src/ at main and passes here. The Miri reduction fails before and passes after. The bundler plugin, bake plugin and worker-terminate suites pass on the ASAN build; none is claimed to fail without the fix.
  • Sequencing: lands after bundler: hand a finished Bun.build back through its pointer, not a &mut receiver #37723, whose lint carries a ratchet entry of 2 for these two sites; on rebase this PR deletes that entry. If this one merges first, bundler: hand a finished Bun.build back through its pointer, not a &mut receiver #37723 has to drop the entry instead.

Background

  • A plugin request (Resolve or Load) is a struct the bundle thread allocates in the pass's arena for one onResolve or onLoad call. The JS thread runs the plugin chain, writes the answer into the struct, and posts the same pointer back to the bundle thread.
  • The outstanding lists are intrusive doubly linked lists in the pass's Graph, one per request type. Each request carries its own link, so pushing or unlinking a neighbour writes into this request's memory, and cancellation walks the list to fail every pending request.
  • Under Rust's aliasing model (Stacked Borrows and Tree Borrows, what Miri checks and what the optimizer is allowed to assume), each reborrow of a &mut gets its own tag, and a write through one tag invalidates the others. Copies of one raw pointer share a tag, which is why handing the same *mut to both places is fine.
  • Task::init wraps a raw pointer to a Taskable so it can be posted to another thread's event loop. OutstandingNode is the trait that maps a request type to its link field, and now also to its list.
  • Miri interprets a Rust program and reports UB under those models; bun run rust:miri uses Tree Borrows.
Original description

Lands after #37723: that PR's publish lint carries an exact-count ratchet entry of 2 for bundle_v2.rs, for the two sites converted here. Once it is in, this PR gets rebased and deletes the entry; that lint's own ratchet test fails on the rebase until it is. If this one is merged first instead, #37723 has to drop the entry before it merges.

Problem

Resolve::dispatch and Load::dispatch (src/bundler/bundle_v2.rs) are how a bundle pass hands an onResolve / onLoad request to the thread that runs the plugins. Both took &mut self and used it twice:

pub(crate) fn dispatch(&mut self) {
    unsafe {
        let bv2 = &mut *self.bv2;
        bv2.graph.outstanding_resolves.push(self);                        // pointer 1, kept by the list
        ...
        let task = ConcurrentTask::create(Task::init(std::ptr::from_mut::<Self>(self)));  // pointer 2, posted
        bv2.enqueue_on_js_loop_for_plugins(task);
    }
}

From then on the request is reached through both pointers. The list's pointer is written through whenever a neighbouring request is dispatched or answered (OutstandingList::push sets the previous head's prev; unlink fixes up both neighbours) and is what fail_outstanding_plugin_requests uses on cancellation. The task's pointer is what the JS thread writes value through and what comes back to on_resolve / on_load, which reborrow it and unlink the request. Under the aliasing model the two are separate reborrows of the receiver, so the link write made by dispatching the next request invalidates the posted pointer, and consuming the answer through it is UB. This is deterministic as soon as two requests are outstanding at once, which is the normal state of a build whose plugin answers asynchronously; it does not depend on the JS thread's timing. Separately, &mut self is a protected (noalias) argument for the whole call, while for Bun.build the JS thread may already be writing the request before dispatch returns. Nothing misbehaves today (the reads that would be affected are not optimized across the post), so this is a contract fix, same family as #37723 (the completion hand-back, which frees) and #37685.

A reduction with the real OutstandingList shape fails under Miri with Tree Borrows (the model bun run rust:miri uses) and with Stacked Borrows, in both cases at the answer's reborrow, naming the link write as what invalidated the pointer:

error: Undefined Behavior: reborrow through <4058> at alloc1960[0x0] is forbidden
    |     let request = unsafe { &mut *answered };
help: the accessed tag <4058> was created here, in the initial state Reserved
    |         post.send(SendPtr(std::ptr::from_mut::<Self>(self))).unwrap();
help: the accessed tag <4058> later transitioned to Disabled due to a foreign write access at offsets [0x0..0x8]
    |                 (*self.head).outstanding.prev = node;

The same program passes under both models when the list and the task are given one and the same pointer.

Reduction (cargo miri run -- before fails, -- after passes; MIRIFLAGS=-Zmiri-tree-borrows or default)
use std::sync::mpsc;
use std::thread;

struct SendPtr(*mut Request);
unsafe impl Send for SendPtr {}

struct Link { prev: *mut Request, next: *mut Request, linked: bool }
impl Default for Link {
    fn default() -> Self { Link { prev: std::ptr::null_mut(), next: std::ptr::null_mut(), linked: false } }
}

/// `Resolve` / `Load`: the answer lands in `value`; the pass keeps the request
/// in its list through `outstanding`.
#[derive(Default)]
struct Request { value: u32, outstanding: Link }

/// `Graph::OutstandingList`, same shape.
struct OutstandingList { head: *mut Request }
impl OutstandingList {
    fn push(&mut self, node: *mut Request) {
        unsafe {
            let l = &mut (*node).outstanding;
            assert!(!l.linked);
            l.linked = true;
            l.prev = std::ptr::null_mut();
            l.next = self.head;
            if !self.head.is_null() {
                (*self.head).outstanding.prev = node;
            }
        }
        self.head = node;
    }
    fn unlink(&mut self, node: &mut Request) {
        let node_ptr: *mut Request = node;
        let l = &mut node.outstanding;
        if !l.linked { return; }
        l.linked = false;
        let (prev, next) = (l.prev, l.next);
        l.prev = std::ptr::null_mut();
        l.next = std::ptr::null_mut();
        unsafe {
            if prev.is_null() { assert!(std::ptr::eq(self.head, node_ptr)); self.head = next; }
            else { (*prev).outstanding.next = next; }
            if !next.is_null() { (*next).outstanding.prev = prev; }
        }
    }
}

impl Request {
    // main
    fn dispatch_before(&mut self, list: &mut OutstandingList, post: &mpsc::Sender<SendPtr>) {
        list.push(self);
        post.send(SendPtr(std::ptr::from_mut::<Self>(self))).unwrap();
    }
    // this PR: one pointer for both (here the slot's pointer; in the tree it is
    // the one `arena_create` hands back inside `dispatch_plugin_request`)
    unsafe fn dispatch_after(this: *mut Self, list: &mut OutstandingList, post: &mpsc::Sender<SendPtr>) {
        list.push(this);
        post.send(SendPtr(this)).unwrap();
    }
}

/// `on_resolve_from_js_loop_raw` -> `BundleV2::on_resolve`.
fn consume_answer(list: &mut OutstandingList, answered: *mut Request) -> u32 {
    let request = unsafe { &mut *answered };
    list.unlink(request);
    std::mem::take(&mut request.value)
}

fn main() {
    let after = std::env::args().nth(1).as_deref() == Some("after");
    let (post, plugin_queue) = mpsc::channel::<SendPtr>();
    let (answer, answers) = mpsc::channel::<SendPtr>();
    // The plugins' thread writes the answer into the request and posts it back.
    let js_thread = thread::spawn(move || {
        for (i, SendPtr(request)) in plugin_queue.into_iter().enumerate() {
            unsafe { (*request).value = 100 + i as u32 };
            answer.send(SendPtr(request)).unwrap();
        }
    });

    // Bundle thread: two requests outstanding at once. Leaked boxes stand in for the arena.
    let mut list = OutstandingList { head: std::ptr::null_mut() };
    let a: *mut Request = Box::into_raw(Box::default());
    let b: *mut Request = Box::into_raw(Box::default());
    for slot in [a, b] {
        if after { unsafe { Request::dispatch_after(slot, &mut list, &post) } }
        else { unsafe { &mut *slot }.dispatch_before(&mut list, &post) }
    }
    drop(post);
    js_thread.join().unwrap();

    let values: Vec<u32> = answers.into_iter().map(|SendPtr(p)| consume_answer(&mut list, p)).collect();
    assert_eq!(values, [100, 101]);
    assert!(list.head.is_null());
    unsafe { drop(Box::from_raw(a)); drop(Box::from_raw(b)); }
    println!("ok");
}

The threads are sequenced through the channels, so the only thing Miri can report is an aliasing violation. Stacked Borrows reports the same site: trying to retag from <4198> for Unique permission ... <4198> was later invalidated at offsets [0x0..0x8] by a write access at the same prev store.

Fix

The two bodies were the same code apart from which list they push to, so they become one function on the pass:

pub(crate) fn dispatch_plugin_request<T: Taskable + OutstandingNode>(&mut self, request: T) {
    let request: *mut T = self.arena_create(request);
    T::outstanding(&mut self.graph).push(request);
    if self.graph.cancelled { self.wake_own_loop(); return; }
    self.enqueue_on_js_loop_for_plugins(ConcurrentTask::create(Task::init(request)));
}

It takes the request by value, so the *mut that arena_create hands back is the only pointer to it that ever exists, and that one goes both into the list and into the task; push and Task::init already took raw pointers. OutstandingNode (src/bundler/Graph.rs, which already maps a request type to its link) gains outstanding(), mapping it to its list. Resolve::dispatch / Load::dispatch become one-line forwarders taking self by value plus the pass the callers already hold; they are kept under those names because the runtime's thunks in src/runtime/api/JSBundler.rs document their pointers as coming from Resolve::dispatch / Load::dispatch, in comments that #37691 and the consuming-side fix are editing, so rewording them here would only buy conflicts. The callers build the request and hand it over, which also means the two Resolve callers no longer pre-allocate a Resolve::default() slot to assign into, so that Default impl goes away. There is no unsafe left on this path (main had two blocks), and nothing the callers can do with the request after dispatching it. Order of operations is unchanged: the scan counter is incremented before the dispatch, and a cancelled pass still links the request and leaves it for is_done to fail.

Adjacent work, not changed here: #37709 changes the run_on_js_thread bodies next to these and leaves the dispatch side alone; the consuming side, where the JS thread holds the whole request as &mut Load / &mut Resolve while the bundle thread keeps writing its link through this same pointer, is a different shape (Miri flags it as a foreign write to a protected tag) and has been reported separately.

Tests

test/internal/source-lints/bundler-plugin-dispatch-raw-request.test.ts checks two things across src/bundler/: dispatch_plugin_request takes the request by value, binds one arena_create result and passes that same local to .push(..) and Task::init(..) without forming a reference to it; and no other code links a request into an outstanding list (outstanding_*.push(..) or T::outstanding(..).push(..) outside the helper's body). Linking is what makes something a dispatched request, so this is keyed on the shape rather than on a method name: a method of any name that links its receiver again is reported, and an unrelated fn dispatch somewhere in the crate is not. It self-tests the audit against main's shape and against near misses (request taken as *mut / &mut, a second arena_create, the slot reborrowed, a redispatch(&mut self) re-adding a push, and non-link uses of the lists), and pins that there is exactly one helper and exactly one link site, so the stray-link check cannot go vacuous. With src/ at main it reports the two push(self) sites (bundle_v2.rs:1133 and :1267, links a request outside dispatch_plugin_request) and the missing helper; it passes here.

On the debug (ASAN) build: test/bundler/bundler_plugin.test.ts (53 pass), bundler_plugin_chain.test.ts (13), bundler_defer.test.ts (10), which dispatch both request types from the Bun.build bundle thread with several outstanding at once; test/bake/dev/plugins.test.ts (3) for the arm where the posting loop is the plugins' own loop; the pool family of test/js/web/workers/worker-terminate-funnels.test.ts, which terminates a worker with a Bun.build onLoad pending (the cancelled path popping the request back out of the list). bun test test/internal/source-lints/ passes (19 files); cargo clippy -p bun_bundler and cargo fmt --check are clean.

Resolve::dispatch and Load::dispatch took &mut self, linked the request
into Graph::outstanding_* through one reborrow of it and posted a second
one (ptr::from_mut(self)) to the plugins' JS thread. The next dispatch
writes the request's link through the list's pointer, which invalidates
the posted one under the aliasing model before the answer is consumed
through it; the &mut self argument also claims exclusive access for a
call during which the JS thread may already be writing the request.

Both functions now take the request as `this: *mut Self` and pass that
same pointer to the list and to Task::init. The three callers bind the
arena slot as a raw pointer; Resolve no longer needs a Default impl to
pre-allocate the slot.

The source lint checks the receiver shape and that the body links and
posts the same pointer.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 60330799-09bd-4f7a-9b95-dc88778a4888

📥 Commits

Reviewing files that changed from the base of the PR and between f59f705 and 3fd2e18.

📒 Files selected for processing (3)
  • src/bundler/Graph.rs
  • src/bundler/bundle_v2.rs
  • test/internal/source-lints/bundler-plugin-dispatch-raw-request.test.ts

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

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced as an aliasing-model violation, not a crash. The reduction in the description (main's dispatch(&mut self) shape with the real OutstandingList) fails under Miri with Tree Borrows and with Stacked Borrows at the answer's reborrow, and passes once the list and the task share one pointer. The source lint reports main's two push(self) sites with src/ at main and passes on this branch; the bundler plugin suites listed in the description pass on the debug build.

Current shape (after two self-review rounds): one by-value BundleV2::dispatch_plugin_request does the allocation, the link and the post, so there is no unsafe on this path at all; Resolve::dispatch / Load::dispatch just forward to it. The lint is keyed on linking a request outside that helper, not on a method name.

Landing order: after #37723 (its publish lint ratchets these two sites at exactly 2; this PR deletes that entry on its rebase, which that lint's own ratchet test enforces). The consuming side (the JS thread holding the whole request as &mut) is reported separately.

CI: every bundler and plugin lane has been green on each push. The red entries on earlier builds were worker_threads/worker-transfer-terminate-stress.test.ts and cli/run/require-cache.test.ts on x64-asan only, neither involving the bundler, both reported for triage on main; one build also lost two darwin 26 shards that never got an agent.

@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 reviewed this PR and didn't find any bugs. The transformation is mechanical and the aliasing argument is sound, but since it reworks the unsafe cross-thread hand-off in the bundler's plugin dispatch path, a human look at the pointer-provenance reasoning would still be worthwhile.

What was reviewed:

  • Confirmed arena_create returns &mut T, immediately coerced to *mut at each of the three callers with no further use of the reference — the list and the task now share one provenance.
  • Verified no remaining Resolve::default() or .dispatch() call sites in src/; the removed Default impl is dead.
  • Checked that ordering is preserved at all three callers (increment_scan_counter before dispatch; cancelled path still links then early-returns).
  • The source-lint test guards against vacuous pass, self-tests its audit on both the old and several near-miss shapes, and pins the expected count of fn dispatch in src/bundler/.
Extended reasoning...

Overview

This PR changes Resolve::dispatch and Load::dispatch in src/bundler/bundle_v2.rs from &mut self methods to unsafe fn dispatch(this: *mut Self) associated functions, so the outstanding-list pointer and the posted-task pointer are the same raw pointer rather than two independent reborrows of a &mut receiver. The three callers (enqueue_on_resolve_plugin_if_needed, the entry-point resolve path, and the load path) now build the request, place it in the arena, bind the slot as *mut, and call dispatch(ptr). The now-unused impl Default for Resolve is removed. A new source-lint test in test/internal/source-lints/ enforces the shape going forward.

Security risks

None. This is an aliasing-model contract fix with no behavioral change: same operations, same order, same data. No new inputs are parsed and no trust boundary is touched.

Level of scrutiny

High — this is unsafe Rust on a cross-thread raw-pointer hand-off path in the bundler, which REVIEW.md calls out as the most-blocked category. The change itself is mechanically narrow (a receiver-type change plus symmetric caller updates) and strictly weakens what the function asserts about aliasing, so it cannot introduce UB where there was none. The PR description includes a Miri reduction demonstrating the before/after under both Tree Borrows and Stacked Borrows. Still, the correctness of &mut *(*this).bv2 staying as-is (deferred to #37709) and the interaction with the consuming side (run_on_js_thread, noted as reported separately) rest on a threading-model argument a maintainer should confirm.

Other factors

  • Part of an established series of identical-pattern fixes (#37685, #37723, #37709) already landing in this repo.
  • arena_create returns &'r mut T (bundle_v2.rs:3005); the callers coerce it directly to *mut T in the binding, so the &mut is never named and cannot be reused — the one raw pointer is what everything downstream holds.
  • Grep confirms no remaining .dispatch() method-call sites or Resolve::default() uses anywhere in src/.
  • The new lint test follows the existing test/internal/source-lints/ conventions (git-tracked filter, globAllSources, self-test of the audit function, non-vacuous-scan guard) and asserts exactly two fn dispatch in src/bundler/ so a moved or added request type trips it.
  • Not covered by CODEOWNERS.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:32 PM PT - Aug 12th, 2026

@robobun, your commit 3fd2e18 has 1 failures in Build #93311 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37732

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

bun-37732 --bun

Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.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 reviewed this PR and didn't find any bugs. Because it reworks the unsafe cross-thread pointer hand-off for bundler plugin requests — the memory-safety category REVIEW.md flags as most-blocked — a human look at the aliasing-model reasoning would still be worthwhile.

What was reviewed:

  • Both dispatch bodies: this is passed verbatim to outstanding_*.push and Task::init, no reborrow formed; the cancelled path and increment_scan_counter ordering are unchanged.
  • All three call sites now bind arena_create's result as *mut (ref-to-raw coercion at the binding, one root pointer); the removed Default for Resolve has no remaining users.
  • The source lint's audit self-tests cover the pre-PR shape and raw-receiver bodies that reintroduce a second derivation; found asserts exactly two sites so the check can't pass vacuously.
Extended reasoning...

Overview

Changes Resolve::dispatch and Load::dispatch in src/bundler/bundle_v2.rs from &mut self receivers to unsafe fn dispatch(this: *mut Self), so the outstanding-list entry and the posted task both hold copies of the same raw pointer instead of two independent reborrows of a &mut receiver. The three callers now build the request, place it in the arena, coerce the returned &mut T to *mut T at the binding site, and pass that pointer through. The now-unused impl Default for Resolve is deleted. A new source-lint test in test/internal/source-lints/ pins the shape.

Security risks

None user-facing. This is an internal aliasing-model contract fix; no input parsing, auth, or network surface is touched.

Level of scrutiny

High. This is unsafe Rust that shares raw pointers between the bundle thread and the JS thread, in a file that already carries several related in-flight PRs (#37685, #37709, #37723). The transformation is mechanical and the Miri reduction in the description is convincing, but per REVIEW.md this is exactly the category where a maintainer should confirm the reasoning — in particular that coercing arena_create's &'r mut T to *mut T at the caller (rather than having arena_create return raw) is the intended shape, and that leaving &mut *(*this).bv2 in place is consistent with the plan for #37709.

Other factors

Grep confirms exactly two fn dispatch in src/bundler/ (matching the lint's found assertion), no remaining .dispatch() method-call sites, and no remaining Resolve::default() uses. The comment-cop feedback on doc-comment length was addressed in dc8b5ba and all threads are resolved. The test is a regex source lint (not a runtime repro), which is the established pattern for this family of aliasing fixes; the PR description reports the bundler plugin suites pass on the debug/ASAN build.

BundleV2::dispatch_plugin_request takes the request by value, parks it in
the arena and hands the one resulting pointer to both the outstanding list
and the task, so no unsafe is needed anywhere on the dispatch path and the
two request types' dispatch fns only forward to it. OutstandingNode gains
the accessor for the list a request type lives in.

The lint now checks the helper's shape and that every dispatch forwards to
it, and fails if the tree-wide publish lint still ratchets bundle_v2.rs.
Comment thread src/bundler/bundle_v2.rs Outdated
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
@robobun robobun changed the title bundler: dispatch plugin requests through the pointer the pass keeps, not a &mut receiver bundler: allocate, link and post a plugin request through one pointer instead of a &mut receiver Aug 12, 2026

@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 reviewed this PR and didn't find any bugs. Because it reworks pointer provenance on the bundler's cross-thread plugin dispatch path and carries an explicit landing-order dependency on #37723, a human look would still be worthwhile.

What was reviewed:

  • dispatch_plugin_request preserves the original order (link → cancelled check → post) and both OutstandingNode::outstanding impls point at the right list.
  • All three call sites updated; increment_scan_counter still runs before dispatch; no remaining Resolve::default() or parameterless .dispatch() calls in the crate.
  • arena_create's &mut T return coerces to the single *mut T used for both push and Task::init — matches the Miri-validated shape in the description.
Extended reasoning...

Overview

The PR converts Resolve::dispatch / Load::dispatch in src/bundler/bundle_v2.rs from &mut self methods that derived two pointers from the receiver (one into OutstandingList, one posted to the JS thread) into by-value forwarders to a new BundleV2::dispatch_plugin_request<T>. That helper allocates the request in the arena once and hands the same *mut T to both the intrusive list and Task::init, eliminating the aliasing-model UB Miri flags on main. OutstandingNode in src/bundler/Graph.rs gains an outstanding() associated fn so the helper can pick the right list generically. The now-unused Default for Resolve is removed, and a new source-lint test pins the shape.

Security risks

None identified. This is an internal aliasing/provenance contract fix; no user-controlled input reaches new code paths, no auth/crypto/permissions surface is touched, and the change is a net reduction of unsafe (two blocks removed, none added).

Level of scrutiny

High. This is memory-safety-adjacent code on the bundler's cross-thread plugin dispatch path — exactly the category the repo's review guidance names as most-blocked. The reasoning is subtle (Tree/Stacked Borrows provenance across an arena allocation, an intrusive list, and a task posted to another thread), and while the change looks correct and behavior-preserving, a maintainer familiar with the surrounding on_resolve/on_load consuming side and the related open PRs (#37723, #37709) should confirm the shape and coordinate the stated landing order.

Other factors

  • I verified arena_create returns &'r mut T (bundle_v2.rs:2982), which coerces to the *mut T binding; Task::init and OutstandingList::push both already accept *mut T, so no new coercion sites were introduced.
  • Grep confirms no remaining callers of Resolve::default() or the old zero-arg .dispatch() in src/bundler/.
  • The behavioral test coverage is the existing bundler_plugin* / bundler_defer / bake plugins suites (per the description); the new file is a regex-based source lint rather than a runtime regression test, which is appropriate for a contract fix with no observable behavior change but means the runtime guarantee rests on those existing suites plus the Miri reduction.
  • The PR explicitly states it should land after #37723 and that its last lint test enforces the ratchet-entry cleanup on rebase — merge coordination is a human call.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #37803 adds test/internal/source-lints/self-receiver-push-put.test.ts, which (among other spellings) flags a method pushing its receiver with the bare coercion, i.e. the two outstanding_*.push(self) sites in Resolve::dispatch / Load::dispatch that this PR removes. It allowlists src/bundler/bundle_v2.rs: 2 pointing here; whichever of the two lands second deletes that entry (the ratchet test in the file fails until it does).

…d name

Any push onto an outstanding list outside dispatch_plugin_request is now
the offence, whatever the method is called, instead of auditing every
fn named dispatch in the crate. The landing-order check against the
publish lint's allowlist is dropped: that lint's own exact-count ratchet
already fails in any tree that has both changes.

@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 reviewed this PR and didn't find any bugs. Because it reworks pointer ownership on the bundle-thread ↔ JS-thread plugin dispatch path and has a stated landing-order dependency on #37723 (ratchet entry to delete on rebase), a human look would still be worthwhile.

What was reviewed:

  • dispatch_plugin_request binds one arena_create result and passes that same *mut T to both OutstandingList::push and Task::init, matching the aliasing fix described; push and Task::init already take *mut T.
  • All three .dispatch() call sites updated to pass the by-value request plus &mut BundleV2; increment_scan_counter still runs before dispatch at both Resolve callers; the cancelled-pass link-then-wake sequence is preserved verbatim.
  • Resolve::default removal — grepped, no remaining users.
  • The new source-lint's regexes checked against the actual helper body and against OutstandingList::{push,unlink,pop} — non-link uses are correctly ignored.
Extended reasoning...

Overview

Refactors Resolve::dispatch and Load::dispatch in src/bundler/bundle_v2.rs from &mut self methods (which derived two separate raw pointers from the receiver — one for the outstanding list, one for the posted task) into thin by-value forwarders to a new generic BundleV2::dispatch_plugin_request<T>. That helper allocates the request in the arena once and hands the single resulting *mut T to both the intrusive list and the JS-loop task, resolving the Miri-reported aliasing violation (a neighbouring dispatch's link write invalidating the posted pointer). OutstandingNode in src/bundler/Graph.rs gains an outstanding() accessor so the helper can be generic over Resolve / Load. The now-unused impl Default for Resolve is deleted, and the three call sites are simplified (no more pre-allocate-then-assign). A new source-lint test pins the shape.

Security risks

None identified. This is an internal aliasing-model contract fix; no user-controlled input reaches new code, no auth/crypto/permission surface is touched. The pointer being unified is arena-owned and the arena outlives the bundle pass, same as before.

Level of scrutiny

High. This is memory-safety code on a cross-thread path (bundle thread posting to the JS/plugin thread), and it is one of a family of interdependent aliasing PRs (#37685, #37709, #37723, #37803) with cross-PR ratchet entries whose landing order is load-bearing. The description explicitly says this must land after #37723 and then delete that PR's ratchet entry for bundle_v2.rs — that step is not in this diff and needs to happen on rebase. A maintainer coordinating that family should confirm the sequencing.

Other factors

The order of operations in the consolidated helper matches both original bodies exactly (link → check cancelled → post), and the carried-over cancelled-path comment is verbatim. arena_create returns &'r mut T, which the helper immediately coerces to *mut T — the reference is never used again, so both consumers share one provenance. The stored bv2 back-pointer inside Resolve/Load (set by init) is still derived from a caller-side &mut BundleV2 before a fresh &mut self is taken for dispatch; that back-ref pattern is preexisting throughout this file and the PR notes the consuming-side aliasing is being addressed separately, so I did not treat it as a regression here. All comment-cop bot flags on doc length are resolved in the timeline (dc8b5ba, 0f43e95). No CODEOWNERS entry covers src/bundler/. Bundler plugin suites pass per the description; the source-lint self-tests cover main's shape and several near-miss regressions.

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