Skip to content

bundler: touch outstanding plugin requests through their pointer, not as &mut - #37746

Open
robobun wants to merge 4 commits into
mainfrom
farm/00c4c823/bundler-plugin-outstanding-request-raw
Open

bundler: touch outstanding plugin requests through their pointer, not as &mut#37746
robobun wants to merge 4 commits into
mainfrom
farm/00c4c823/bundler-plugin-outstanding-request-raw

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Nothing breaks for users today: no crash is known and nothing miscompiles. What is wrong is the aliasing contract on the bundler's plugin requests.
  • While an onResolve / onLoad request is out with the plugins, both sides use it at once: the plugin side writes the answer, and the bundle pass keeps writing the request's list link and its deferred flag. Under Bun.build those are two threads; it works because neither side touches the other's fields.
  • Every function on either side nevertheless held the whole request as &mut (or &), which claims the other side's fields too. A link write landing during such a call is undefined behaviour; a reduction fails under Miri with Undefined Behavior: write access through <4570> at alloc2247[0x0] is forbidden.
  • A second instance needs one request only: strings borrowed from the request were passed into the call that runs the plugin, and a plugin that answers without suspending lets the pass free those buffers before that call returns. Miri: Undefined Behavior: deallocation through <9184> at alloc2084[0x0] is forbidden.

Fix

  • While a request is out, both sides reach it only through the raw pointer, one field at a time; the strings the plugin needs are copied out before the call into it; and posting the answer back is the last thing the plugin side does with it. The rule is written once, on the Resolve / Load type docs.
  • Why it is sound: while a request is out, the pass writes only the link and deferred, the plugin side writes only the answer, its defer flag and its task node, and no borrow of the request is live across the plugin call or after a post. Once answered or cancelled the pass owns the whole request again, so those paths keep taking &mut.
  • No behaviour change: the same strings reach the plugin and the same fields are read and written in the same order. Load::was_file, false since the port, is deleted. This is the consuming side only; the posting side is bundler: allocate, link and post a plugin request through one pointer instead of a &mut receiver #37732.
  • Verification: nothing is observable at runtime, so the test is a source lint over the functions that touch an outstanding request: 35 findings on main, none here. The two Miri reductions in the original fail before and pass after. The bundler plugin, chain, defer and bake plugin suites pass under ASAN; the worker-terminate suite passed on an earlier revision of the branch and timed out on a loaded host on this one.

Background

  • A plugin request (Resolve for onResolve, Load for onLoad) is a struct the bundle pass allocates per hook call and hands to whatever runs the JS plugins. JS writes the answer into it and posts the same struct back; the pass then consumes it and frees its buffers. Under Bun.build the pass has its own thread and event loop; under bake both sides share one loop.
  • The pass tracks the requests that are out in an intrusive doubly-linked list: the prev / next pointers live inside each request. Pushing or unlinking one request therefore writes into its neighbours, which the plugin side may be holding at that moment.
  • defer() lets an onLoad plugin park its load until the rest of the scan is done. The pass sets and later clears a deferred flag on parked loads while they are still out with the plugin, so that is a second field the pass writes during the shared window.
  • Rust's aliasing rule: a &mut T or &T argument promises that nobody else writes to, or frees, any byte of the T until the call returns, even bytes the callee never reads. A raw pointer with a field projection (&raw mut (*p).field) promises that for the one field only. Miri checks this (Stacked Borrows and Tree Borrows are its two models); a violation is undefined behaviour whether or not the compiler exploits it today.
  • The plugin call passes the request pointer to C++ as an opaque context cookie, and C++ hands it back unchanged to the Rust callbacks that record an answer, an error, or a defer. A plugin that answers synchronously runs those callbacks from inside the original call.

[review] gate passed · iteration 1 · 5 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/source-lints/bundler-plugin-outstanding-request.test.ts
bun test v1.4.0 (c7960c246)

test/internal/source-lints/bundler-plugin-outstanding-request.test.ts:
435 |     "src/bundler/Graph.rs impl OutstandingList": 1,
436 |     "src/bundler/bundle_v2.rs impl OutstandingNode for Load": 1,
437 |     "src/bundler/bundle_v2.rs impl OutstandingNode for Resolve": 1,
438 |   };
439 |   for (const e of ENTRIES) expected[`${e.file} ${e.fn}`] = e.count;
440 |   expect(counts).toEqual(expected);
                       ^
error: expect(received).toEqual(expected)

  {
    "src/bundler/Graph.rs drain_deferred_tasks": 1,
    "src/bundler/Graph.rs impl OutstandingList": 1,
    "src/bundler/Graph.rs trait OutstandingNode": 1,
    "src/bundler/bundle_v2.rs impl OutstandingNode for Load": 1,
    "src/bundler/bundle_v2.rs impl OutstandingNode for Resolve": 1,
    "src/bundler/bundle_v2.rs on_load_async": 1,
    "src/bundler/bundle_v2.rs on_notify_defer_mini": 1,
    "src/bundler/bundle_v2.rs on_resolve_async": 1,
    "src/bundler/bundle_v2.rs ru
... (truncated)

release without fix: 2 FAILED
bun test v1.4.0-canary.1 (da3851e57)

test/internal/source-lints/bundler-plugin-outstanding-request.test.ts:
435 |     "src/bundler/Graph.rs impl OutstandingList": 1,
436 |     "src/bundler/bundle_v2.rs impl OutstandingNode for Load": 1,
437 |     "src/bundler/bundle_v2.rs impl OutstandingNode for Resolve": 1,
438 |   };
439 |   for (const e of ENTRIES) expected[`${e.file} ${e.fn}`] = e.count;
440 |   expect(counts).toEqual(expected);
                       ^
error: expect(received).toEqual(expected)

  {
    "src/bundler/Graph.rs drain_deferred_tasks": 1,
    "src/bundler/Graph.rs impl OutstandingList": 1,
    "src/bundler/Graph.rs trait OutstandingNode": 1,
    "src/bundler/bundle_v2.rs impl OutstandingNode for Load": 1,
    "src/bundler/bundle_v2.rs impl OutstandingNode for Resolve": 1,
    "src/bundler/bundle_v2.rs on_load_async": 1,
    "src/bundler/bundle_v2.rs on_notify_defer_mini": 1,
    "src/bundler/bundle_v2.rs on_resolve_async": 1,
    "src/bundler/bundle_v2.rs run_on_js_thread": 2,
    "src/runtime/api/JSBundler.rs JSBundlerPlugin__addError": 1,
    "src/runtime/api/JSBundler.rs JSBundlerPlugin__onDefer": 1,
    "src/runtime/api/JSBundler.rs JSBundlerPl
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/internal/source-lints/bundler-plugin-outstanding-request.test.ts
bun test v1.4.0 (c7960c246)

test/internal/source-lints/bundler-plugin-outstanding-request.test.ts:
(pass) the audited functions are still where this lint looks for them [9.51ms]
(pass) the audit recognizes the shapes it claims to [311.25ms]
(pass) outstanding plugin requests are reached only as raw pointers, field by field, in every audited function [1.57ms]

 3 pass
 0 fail
 43 expect() calls
Ran 3 tests across 1 file. [3.77s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1242ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/144] gen NodeModuleModule.lut.h
Generating /workspace/bun/build/release/codegen/NodeModuleModule.lut.h from /workspace/bun/src/jsc/modules/NodeModuleModule.cpp
[2/144] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[3/144] gen ZigGeneratedClasses.{cpp,h,rs}
Found 2 classes from /workspace/bun/src/jsc/resolve_message.classes.ts
  - ResolveMessage (15 fields)
  - BuildMessage (10 fields)
Found 1 classes from /workspace/bun/src/runtime/api/Archive.classes.ts
  - Archive (4 fields, 1 class fields)
Found 2 classes from /workspace/bun/src/runtime/api/BunObject.classes.ts
  - ResourceUsage (8 fields)
  - Subprocess (20 fields)
Found 1 classes from /workspace/bun/src/runtime/api/cron.classes.ts
  - CronJob (5 fields)
Found 3 classes from /workspace/bun/src/runtime/api/filesystem_router.classes.ts
  - FileSystemRouter (5 fields)
  - FrameworkFileSystemRouter (2 fields)
  - MatchedRoute (8 fields)
Found 1 classes from /worksp
... (truncated)
diff hotspot
src/bundler/Graph.rs                               |  77 +-
 src/bundler/bundle_v2.rs                           | 211 ++++--
 src/runtime/api/JSBundler.rs                       | 299 ++++----
 src/runtime/dispatch.rs                            |  23 +-
 .../bundler-plugin-outstanding-request.test.ts     | 814 +++++++++++++++++++++
 5 files changed, 1147 insertions(+), 277 deletions(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                                      reads  edits  tests
src/bundler/Graph.rs                                          7     11      0
src/bundler/bundle_v2.rs                                     16     34      0
src/runtime/api/JSBundler.rs                                  6     15      0
src/runtime/dispatch.rs                                       2      1      0
…source-lints/bundler-plugin-outstanding-request.test.ts      2      7      0
Original description

Problem

api::JSBundler::Resolve / Load (src/bundler/bundle_v2.rs) are the onResolve / onLoad requests a bundle pass hands to whatever runs the plugins: another thread under Bun.build, the same loop under bake (the code is shared). From dispatch until the answer is consumed, one request is in use on both sides at once, by field. The plugin side reads the fields set before dispatch and writes the answer (value, called_defer, the intrusive task node). Meanwhile the request stays linked in the pass's Graph::outstanding_resolves / outstanding_loads, and the pass side writes its link whenever another request is dispatched (OutstandingList::push sets the current head's prev) or answered (unlink fixes up both neighbours), and writes Load::deferred (Graph::drain_deferred_tasks over every linked load, on_notify_defer_mini on the mini loop). Under Bun.build this is genuinely concurrent; it works because the two sides never touch the same field.

Every place the plugin side touched a request nevertheless held the whole struct as &mut, and the pass side did the same to requests that were still out:

// src/runtime/dispatch.rs
unsafe { &mut *cast_ptr!(..JSBundler::Load) }.run_on_js_thread();          // and the Resolve arm
// src/bundler/bundle_v2.rs
pub fn run_on_js_thread(&mut self)                                          // Resolve, Load; also passes &self.path etc. into the plugin call (below)
pub fn on_load_async(&mut self, load: &mut Load)                            // held across the post-back; on_resolve_async likewise
pub fn on_notify_defer_mini(load: &mut Load, ..)                            // pass side, load still out
fn link(&mut self) -> &mut OutstandingLink<Self>                            // OutstandingNode: push/unlink reborrow the head / the neighbours whole
// src/runtime/api/JSBundler.rs
let resolve = unsafe { &mut *resolve };                                     // onResolveAsync, for the whole body
extern "C" fn JSBundlerPlugin__onLoadAsync(this: &mut Load, ..)             // straight from C++
let load = unsafe { bun_ptr::callback_ctx::<Load>(ctx) };                   // addError (callback_ctx's contract says single-threaded)
(&mut *load).on_defer(..)  /  fn on_defer(&mut self, ..)                    // onDefer

A &mut Load covers the outstanding and deferred bytes too, and as a function argument it is protected for the duration of the call, so a link write landing during the call is a foreign write to a protected tag: UB under both aliasing models whether or not the reference is used again. With on_load_async it is worse, because the pass side can unlink and consume the answer while the thunk's &mut is still on the plugin side's stack. Any multi-file build whose plugins answer asynchronously has several requests outstanding at once, so the link writes do land during the callbacks.

There is a second, timing-shaped instance of the same thing that does not need two requests at all. run_on_js_thread passed &self.path / &self.import_record.* into Plugin::match_on_load / match_on_resolve, whose &[u8] parameters stay protected for the whole call into the plugin. A callback that does not actually suspend (or no matching callback; runOnLoadPlugins / runOnResolvePlugins unwrap settled promises without awaiting, and the C++ side calls addError synchronously on a throw) answers from inside that call, the pass side may consume the answer at once, and on_load / on_resolve free path / import_record: a deallocation of memory a protected reference points into, while the plugin call is still unwinding.

No crash is known from either: nothing in these bodies reads the fields the other side writes, and the freed buffers are not read again, so there is nothing for the noalias / dereferenceable on the arguments to miscompile today. The contract is what is wrong. Two reductions, each sequenced over channels so that the only thing left for Miri to find is the aliasing violation, fail under Tree Borrows (the model bun run rust:miri uses) and under Stacked Borrows, and pass in the shape this PR uses:

# a thunk holding &mut Load while push() links the next request
error: Undefined Behavior: write access through <4570> at alloc2247[0x0] is forbidden
   |     (*(&raw mut (*self.head).outstanding)).prev = node;
   = help: the accessed tag <4570> is foreign to the protected tag <8957> (i.e., it is not a child)
   = help: this foreign write access would cause the protected tag <8957> (currently Reserved) to become Disabled
help: the protected tag <8957> was created here, in the initial state Reserved
   | fn on_load_async_before(this: &mut Load, plugin: &Plugin) {

# path: &[u8] into the request held across a plugin call that answers synchronously
error: Undefined Behavior: deallocation through <9184> at alloc2084[0x0] is forbidden
   = help: this deallocation (acting as a foreign write access) would cause the protected tag <6618> (currently Frozen) to become Disabled
   |     drop(std::mem::take(&mut load.path));
help: the protected tag <6618> was created here, in the initial state Frozen
   |     fn match_on_load_before(&self, path: &[u8], context: *mut Load) {

(Stacked Borrows: not granting access to tag <4728> because that would remove [Unique for <9295>] which is strongly protected and .. would remove [SharedReadOnly for <6866>] which is strongly protected, at the same two sites.)

Reduction 1: link write during a thunk (cargo miri run -- before fails, -- after passes; with and without MIRIFLAGS=-Zmiri-tree-borrows)
use std::sync::mpsc;
use std::thread;

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

/// `Graph::OutstandingLink`.
struct Link {
    prev: *mut Load,
    next: *mut Load,
    linked: bool,
}

/// `api::JSBundler::Load`: the plugins' thread fills in `value`; the bundle
/// thread owns `outstanding` for as long as the request is out.
struct Load {
    value: u32,
    outstanding: Link,
}

impl Load {
    fn new() -> *mut Load {
        Box::into_raw(Box::new(Load {
            value: 0,
            outstanding: Link { prev: std::ptr::null_mut(), next: std::ptr::null_mut(), linked: false },
        }))
    }
}

/// `Graph::OutstandingList`, bundle thread only. `push` of a request writes
/// the current head's `prev`; that head may be out with the plugins' thread.
struct OutstandingList {
    head: *mut Load,
}

impl OutstandingList {
    fn push(&mut self, node: *mut Load) {
        unsafe {
            let l = &raw mut (*node).outstanding;
            (*l).linked = true;
            (*l).prev = std::ptr::null_mut();
            (*l).next = self.head;
            if !self.head.is_null() {
                (*(&raw mut (*self.head).outstanding)).prev = node;
            }
        }
        self.head = node;
    }
    fn unlink(&mut self, node: *mut Load) {
        unsafe {
            let l = &raw mut (*node).outstanding;
            if !(*l).linked {
                return;
            }
            (*l).linked = false;
            let (prev, next) = ((*l).prev, (*l).next);
            if prev.is_null() {
                self.head = next;
            } else {
                (*(&raw mut (*prev).outstanding)).next = next;
            }
            if !next.is_null() {
                (*(&raw mut (*next).outstanding)).prev = prev;
            }
        }
    }
}

struct Plugin {
    in_callback: mpsc::Sender<()>,
    bundle_thread_done: mpsc::Receiver<()>,
}

/// Stands in for the JS callback running inside the thunk: while it runs,
/// the bundle thread carries on with the build.
impl Plugin {
    fn run_callback(&self) {
        self.in_callback.send(()).unwrap();
        self.bundle_thread_done.recv().unwrap();
    }
}

/// main: `JSBundlerPlugin__onLoadAsync(this: &mut Load, ..)` and the rest of
/// the answer path hold the whole request as `&mut` for the duration.
fn on_load_async_before(this: &mut Load, plugin: &Plugin) {
    plugin.run_callback();
    this.value = 1;
}

/// this PR: the request arrives as the pointer and only its own fields are written.
unsafe fn on_load_async_after(this: *mut Load, plugin: &Plugin) {
    plugin.run_callback();
    unsafe { (*this).value = 1 };
}

fn main() {
    let after = std::env::args().nth(1).as_deref() == Some("after");

    let (dispatch, dispatched) = mpsc::channel::<SendPtr>();
    let (answer, answered) = mpsc::channel::<SendPtr>();
    let (in_callback_tx, in_callback) = mpsc::channel::<()>();
    let (bundle_thread_done_tx, bundle_thread_done) = mpsc::channel::<()>();

    // The plugins' thread.
    let plugin_thread = thread::spawn(move || {
        let plugin = Plugin { in_callback: in_callback_tx, bundle_thread_done };
        let SendPtr(load) = dispatched.recv().unwrap();
        if after {
            unsafe { on_load_async_after(load, &plugin) };
        } else {
            on_load_async_before(unsafe { &mut *load }, &plugin);
        }
        answer.send(SendPtr(load)).unwrap();
    });

    // The bundle thread. Leaked boxes stand in for the pass's arena.
    let mut list = OutstandingList { head: std::ptr::null_mut() };
    let a = Load::new();
    let b = Load::new();

    // `Load::dispatch` of `a`.
    list.push(a);
    dispatch.send(SendPtr(a)).unwrap();

    // While `a`'s plugin callback is running, the build reaches another file
    // and dispatches `b`: `push` writes `a.outstanding.prev`.
    in_callback.recv().unwrap();
    list.push(b);
    bundle_thread_done_tx.send(()).unwrap();

    // `a` comes back answered; `BundleV2::on_load` unlinks and consumes it.
    let SendPtr(done) = answered.recv().unwrap();
    plugin_thread.join().unwrap();
    assert!(std::ptr::eq(done, a));
    list.unlink(done);
    let value = unsafe { (*done).value };
    assert_eq!(value, 1);
    list.unlink(b);
    assert!(list.head.is_null());

    unsafe {
        drop(Box::from_raw(a));
        drop(Box::from_raw(b));
    }
    println!("ok");
}
Reduction 2: synchronous answer during the plugin call (same invocation)
// The synchronous-answer path:
//
//   Load::run_on_js_thread(this)
//     -> Plugin::match_on_load(<borrow of (*this).path>, .., this)
//          -> C++ JSBundlerPlugin__matchOnLoad -> JS runOnLoadPlugins answers without
//             suspending -> JSBundlerPlugin__onLoadAsync -> on_load_async: the post
//               [bundle thread: on_load(&mut *load) -> LoadDeinitGuard frees load.path]
//          <- C++ returns
//     <- match_on_load returns: only here is the `path: &[u8]` argument's protector released
use std::sync::mpsc;
use std::thread;

struct SendPtr<T>(*mut T);
unsafe impl<T> Send for SendPtr<T> {}

struct Load {
    path: Box<[u8]>,
    value: u32,
}

struct Plugin {
    answer: mpsc::Sender<SendPtr<Load>>,
    bundle_thread_consumed: mpsc::Receiver<()>,
}

impl Plugin {
    /// Stand-in for C++ `JSBundlerPlugin__matchOnLoad` -> JS -> sync `onLoadAsync`
    /// -> `JSBundlerPlugin__onLoadAsync` -> `on_load_async` (the post), then the
    /// C++/JS frames unwinding while the bundle thread runs.
    fn ffi_match_on_load(&self, context: *mut Load) {
        unsafe { (*context).value = 1 };
        self.answer.send(SendPtr(context)).unwrap(); // on_load_async: the post
        // JS/C++ frames are still unwinding here; the bundle thread is free to run.
        self.bundle_thread_consumed.recv().unwrap();
    }

    /// Previous shape: `match_on_load(&mut self, path: &[u8], ..)`. `path` is a
    /// protected argument for the whole FFI call, although it was only needed
    /// to build the BunString copy.
    fn match_on_load_before(&self, path: &[u8], context: *mut Load) {
        let _bun_string: Vec<u8> = path.to_vec();
        self.ffi_match_on_load(context);
    }

    /// This PR: the copy is made first and nothing borrowed from the request is
    /// live across the FFI call.
    fn match_on_load_after(&self, bun_string: Vec<u8>, context: *mut Load) {
        let _bun_string = bun_string;
        self.ffi_match_on_load(context);
    }
}

unsafe fn run_on_js_thread(this: *mut Load, plugin: &Plugin, after: bool) {
    if after {
        let path_copy = unsafe { (*this).path.to_vec() };
        plugin.match_on_load_after(path_copy, this);
    } else {
                plugin.match_on_load_before(unsafe { &(*this).path }, this);
    }
}

/// Bundle thread: `on_load_mini` -> `on_load(&mut *load)` -> `LoadDeinitGuard` frees `path`.
fn on_load(load: &mut Load) -> u32 {
    drop(std::mem::take(&mut load.path));
    std::mem::replace(&mut load.value, 0)
}

fn main() {
    let after = std::env::args().nth(1).as_deref() == Some("after");

    let (dispatch, dispatched) = mpsc::channel::<SendPtr<Load>>();
    let (answer, answered) = mpsc::channel::<SendPtr<Load>>();
    let (consumed_tx, consumed) = mpsc::channel::<()>();

    // Plugins' (JS) thread.
    let plugin_thread = thread::spawn(move || {
        let plugin = Plugin { answer, bundle_thread_consumed: consumed };
        let SendPtr(load) = dispatched.recv().unwrap();
        unsafe { run_on_js_thread(load, &plugin, after) };
    });

    // Bundle thread. Leaked box stands in for the pass's arena.
    let load = Box::into_raw(Box::new(Load { path: b"/entry.ts".to_vec().into_boxed_slice(), value: 0 }));
    dispatch.send(SendPtr(load)).unwrap();

    // Mini loop wakes on the post and consumes the answer while the plugins'
    // thread is still inside match_on_load.
    let SendPtr(done) = answered.recv().unwrap();
    assert!(std::ptr::eq(done, load));
    let value = on_load(unsafe { &mut *done });
    assert_eq!(value, 1);
    consumed_tx.send(()).unwrap();

    plugin_thread.join().unwrap();
    unsafe { drop(Box::from_raw(load)) };
    println!("ok");
}

This is the consuming side of the request; #37732 is the posting side (dispatch taking &mut self and handing out two reborrows, which is also what stands between this PR and "the list, the cookie and the answer hold one pointer"; here the cookie and the answer are the task's pointer), and #37709 / #37691 are about how these same functions reach the pass (bv2) and the loop, which is left exactly as it was here. All four are independent; this one touches the same run_on_js_thread bodies as #37709 and the same thunk bodies as #37691, so whichever lands second has a small conflict to resolve, with no change to what either does. #37731 (the scan counter when defer() is not awaited) reshapes the defer notify path on these same lines and keeps the &mut Load shape on the load it posts and in its on_notify_defer; if it lands first, those two get the same conversion in the rebase and join the lint's table, and if this lands first it needs the raw shape from the start. The lint's population is a table, so either order is a few lines.

Fix

While a request is outstanding it is only ever touched through the raw pointer, field by field, on both sides, and nothing borrowed from it is live across the call into the plugin or after a post. The contract is written once, on the Resolve / Load type docs; everything else points at it.

  • Plugin side: Resolve / Load::run_on_js_thread(this: *mut Self); the run_task arms pass the queued pointer instead of reborrowing it, and this itself becomes the context cookie C++ hands back. The strings for the plugin call are copied out first (BunString::clone_utf8, Plugin::load_namespace / resolve_namespace, which is the namespace defaulting that used to live inside the match functions) and Plugin::match_on_load / match_on_resolve take them by value, so no borrow of the request is an argument of the call that may answer it; those two functions have no other callers. JSBundlerPlugin__onLoadAsync takes *mut Load (C++ declares void*; the ABI is unchanged), onResolveAsync and addError stop forming &mut (addError casts the cookie itself), on_defer becomes a free unsafe fn on_defer(load: *mut Load, ..) (the LoadJsExt trait existed only to give it method syntax) and reads bv2 / parse_task before posting the notify, so the post is the last thing done with the request. BundleV2::on_load_async / on_resolve_async take *mut and post it as is; from_callback, enqueue_task_concurrent_with_extra_ctx and Task::init already took raw pointers.
  • Pass side: OutstandingNode::link(&mut self) becomes unsafe fn link_raw(this: *mut Self) -> *mut OutstandingLink<Self> (&raw mut (*this).outstanding); OutstandingList::push / unlink / pop write through it and a new for_each walks through it, so the list never forms a reference to a node and drain_deferred_tasks shrinks to the one deferred write it makes per load (OutstandingLink::next becomes private as a result). unlink takes the node as *mut too (its callers hold &mut, which coerces). on_notify_defer_mini takes *mut Load and writes deferred through it.
  • The BackRef field (parse_task) is copied out before use: rustc's dangerous_implicit_autorefs rejects (*this).parse_task.x (it goes through Deref), which is the same mistake in a form the compiler happens to catch.
  • Deleted: Load::was_file and the branch in onLoadAsync that read it. It has been false since it was ported (it was never set in the Zig version either); had it run, it would have answered the load without posting it back and left it linked.

The answer-side consumers (on_load / on_resolve) and the cancellation path (fail_outstanding_plugin_requests) still take &mut, because by then the plugin side is done with the request and the pass side owns all of it again:

  • Answer: the post in on_*_async is now the last thing the plugin side does with the request (the thunks return straight after it, and C++ only ever round-trips the cookie without dereferencing it), and the post itself orders everything before it ahead of the pass side's pop. The task node is written just before each post and read after the pop, so it is ordered the same way; the one case where it is enqueued a second time while still queued (defer() not awaited) is what bundler: keep the scan counter balanced when an onLoad plugin does not await defer() #37731 is fixing by giving the notify its own node.
  • Cancellation: stop_for_vm_teardown runs on the plugins' own thread, tombstones the plugin (so onLoadAsync / onResolveAsync / addError are dropped in C++ from then on), then stores cancelled with Release; is_done reads it with Acquire, consumes the answers already in its queue, and only then fails what is left. Hop tasks still queued on that VM's loop are released unrun (__bun_release_task_unrun; release_unrun is a no-op for these types), so no access from that thread follows the store.

The partition itself: while a request is out and not cancelled, the pass side writes only outstanding (the list) and Load::deferred (drain_deferred_tasks, on_notify_defer_mini), and the plugin side writes only value, called_defer and task and reads fields that were set in init (the ParseTask behind parse_task is not scheduled until on_load). Every write to a request field in bundle_v2.rs / Graph.rs / JSBundler.rs is one of those or is on the answer / cancellation side.

No behavior change: the same strings reach the plugin (the copies were already being made, one frame lower), and the same fields are read and written in the same order on every path.

Verification

Nothing is observable at runtime, so the test is a source lint, test/internal/source-lints/bundler-plugin-outstanding-request.test.ts, over exactly this population: the eleven function definitions that touch a request while it may be outstanding (including drain_deferred_tasks), the two run_task arms, the OutstandingNode trait and its two impls, and the OutstandingList impl. For each function it checks that the request arrives as a *mut Resolve / Load / Self parameter (a receiver, a reference, or an untyped pointer with no recognized request parameter all fail; addError and drain_deferred_tasks name their handles explicitly), that neither that parameter nor any local rebound from it (let x = req;, let x = ctx.cast::<Load>();, as casts) is reborrowed, borrowed whole, turned into a reference or NonNull (from_mut, as_mut(), ..), read or written as a whole (read() / write() / *req = ..), used as a method receiver through (*req), or reached through callback_ctx; that where the request is handed on (cookie, post-back, notify) the body passes the pointer it received; and that the match_on_* call in the hops has no & argument at all. The structural audits check that the trait hands out no references, both impls are raw projections, and the list impl never reborrows a node or takes one as &mut. Its header states the boundary (per-function text checks; a helper that forms the reference internally is outside them), it has a guard that the population is still where it looks, and a self-test covering every accepted and rejected shape above, including the evasions. Against main's src/ it reports 35 findings at the sites listed above; here it passes.

Debug (ASAN) build: test/bundler/bundler_plugin.test.ts (53 pass; includes the sync and async throwing plugins, i.e. addError for both request types, and plenty of synchronously answered requests), bundler_plugin_chain.test.ts (13), bundler_defer.test.ts (10; on_defer, the notify, on_notify_defer_mini, drain_deferred_tasks over the parked loads), test/bake/dev/plugins.test.ts (3; both sides on one loop). test/js/web/workers/worker-terminate-funnels.test.ts (10; builds cancelled with requests outstanding, i.e. pop / unlink on the cancellation path) passed on the first revision of this branch; on the current revision its cases hit their 30s ceilings on a host with a load average around 170 (they take 20 to 26s here when the machine is quiet), and the cancellation path it covers is unchanged between the two revisions. cargo clippy -p bun_bundler -p bun_runtime and cargo fmt are clean.

…ld by field

While an onResolve/onLoad request is out with the plugins' thread it is
still linked in the bundle thread's outstanding list, which that thread
writes through (push of the next request, unlink of an answered one,
Load::deferred from on_notify_defer_mini). Every place the plugins' thread
touched a request held the whole struct as &mut for the duration
(run_task arms, run_on_js_thread, the onResolveAsync/onLoadAsync/addError/
onDefer thunks, on_*_async across the post-back), and the list reborrowed
the neighbours whole to write their links. Pass the request as *mut
everywhere it may be outstanding and access only the owning side's fields
through it; OutstandingNode projects the link as a raw pointer.

Removes Load::was_file, which was never set.

Adds a source lint over this population of functions.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Bundle V2 plugin request handling now uses raw pointers for outstanding loads and resolves. Plugin inputs are copied into owned strings. Runtime callbacks and deferred notifications use pointer-based handoffs. A source-lint test audits these access patterns.

Changes

Outstanding plugin request handling

Layer / File(s) Summary
Raw outstanding-list operations
src/bundler/Graph.rs
Outstanding-list links, insertion, removal, popping, and traversal now use raw node pointers. Deferred loads clear their state through pointer traversal.
Raw request execution and plugin inputs
src/bundler/bundle_v2.rs
Resolve and load execution now accepts raw request pointers. Request fields are copied into owned BunString values before plugin calls. Outstanding-node linking uses raw projections, and Load::was_file was removed.
Callback, deferral, and dispatch handoffs
src/runtime/api/JSBundler.rs, src/bundler/bundle_v2.rs, src/runtime/dispatch.rs
Runtime callbacks, deferred notifications, error handling, and dispatch arms pass raw request pointers and update request fields through pointer access.
Raw-pointer source audit
test/internal/source-lints/bundler-plugin-outstanding-request.test.ts
Added source audits and unit tests for prohibited request borrows, pointer handoffs, dispatch usage, outstanding-list APIs, and raw node projections.

Suggested reviewers: dylan-conway, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The description references related PRs and clearly explains their scope and interaction with this change.
Out of Scope Changes check ✅ Passed The changed files and removed field support the raw-pointer migration and its source-lint enforcement.
Title check ✅ Passed The title clearly summarizes the main change: using raw pointers instead of mutable references for outstanding plugin requests.
Description check ✅ Passed The description explains the problem, fix, behavior impact, and verification results in substantial technical detail.

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. Two reductions in the description (a thunk holding the request as &mut Load while the pass side's OutstandingList::push writes that request's prev; and a &[u8] into the request held across a plugin call that answers synchronously, while the pass side frees the buffer) fail under Miri with Tree Borrows and with Stacked Borrows, and pass in the shapes this branch uses. The source lint reports 35 findings with src/ at main and passes on this branch; the plugin, plugin chain, defer and bake plugin suites pass on the debug build (the worker-terminate funnels suite passed on the first revision and is timing out under host load on the second; the path it covers is unchanged between them, see the description).

Second revision (3cc7518), from self-review: the strings for the plugin call are copied out of the request before the call and match_on_* take them by value (the synchronous-answer case above); OutstandingList::for_each replaces the link walk in drain_deferred_tasks, which joins the lint's population along with the two OutstandingNode impls; the lint follows rebindings of the pointer and rejects the as_mut / read / write / NonNull / untyped-parameter spellings; type docs and the lint header no longer describe only the mini-loop arm.

Third revision (895a4fb) is comments and the test only: the contract is stated once on Resolve and everything else points at it, and the lint reads its sources in beforeAll. All review threads are answered and resolved.

Related open PRs on the same lines: #37732 (posting side), #37709 / #37691 (how these functions reach the pass and the loop), #37731 (defer notify path). Each pair applies independently; whichever of a pair lands second resolves a small conflict, as described in the body.

CI, for whoever picks this up: the source under test has not changed since 895a4fb (c7960c2 is an empty commit). Builds 92838 and 93269 of it are green on every lane that ran, including the ASAN lane and the source-lints job that runs the new lint; in both, the only red entry is the darwin 26 aarch64 - test-bun pair, which expired unclaimed while that queue was stalled for every build. The retriggered build 93544 never got to the tests: its build-cpp lanes failed fetching vendor archives from github.com (c-ares, lol-html, mimalloc: Failed to download after 5 attempts), which is the agents' network, not the diff. I am not pushing further retriggers; a Buildkite rebuild of 93544 or a merge on the strength of 93269 are both fine from this side.

Update: the darwin-26 queue eventually served build 93544, and both of its darwin 26 aarch64 - test-bun shards passed on this source, so with 92838 / 93269 every test lane has now passed on the code under review; 93544's own red is only the vendor-download build failures on the linux/freebsd agents and the unrelated complex-workspace install test, which is reported separately.

@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 aliasing contract for cross-thread bundler plugin requests — converting eleven function signatures and the OutstandingList internals from &mut to raw-pointer field-by-field access, deleting the was_file field/branch and LoadJsExt, and adding a 626-line source lint — and it interacts with four other open PRs on the same lines, a human look is warranted.

What was reviewed:

  • The &mut T*mut T conversion at each of the eleven sites and the two run_task arms preserves the same field reads/writes in the same order.
  • unlink's callers (on_load/on_resolve/pop) still type-check via &mut → *mut coercion; Load::bake_graph() retains its remaining caller in on_load.
  • was_file has no remaining setters or readers in bundle_v2.rs; the deleted branch was unreachable.
  • on_defer now reads bv2/parse_task before the notify post so the post is the last touch of *load, matching the stated contract.
Extended reasoning...

Overview

This PR fixes an aliasing-model UB (verified under Miri Tree Borrows and Stacked Borrows) in the bundler plugin request path. api::JSBundler::Resolve/Load requests are shared between the bundle thread and the plugins' thread by field while outstanding: the plugins' thread writes value/called_defer/task while the bundle thread writes the intrusive outstanding link and deferred. The old code formed &mut Load/&mut Resolve on the plugins' thread (as function arguments, protected for the call duration), which under both borrow models is UB when the bundle thread concurrently writes the link. The fix converts every touch point on both sides to raw-pointer field-by-field access: run_on_js_thread(this: *mut Self), on_*_async(*mut), on_notify_defer_mini(*mut Load), OutstandingNode::link_raw(this: *mut Self) -> *mut OutstandingLink<Self>, and the four C++-facing thunks. It deletes the always-false was_file field and its dead branch, removes the LoadJsExt trait, and adds a source-lint test enforcing the shape.

Security risks

None. This is a mechanical &mut*mut conversion with no change to what is read or written, no new inputs, and no user-facing surface.

Level of scrutiny

High. This is memory-safety-critical unsafe Rust at a cross-thread boundary. Per REVIEW.md's "know the thread affinity of every line you touch" and "never let a pointer or slice outlive the memory it points into," the field-ownership contract this PR establishes (documented on the Resolve/Load type docs) needs a maintainer to confirm it matches the actual concurrent execution model — particularly that the answer-side consumers (on_load/on_resolve) and fail_outstanding_plugin_requests really do run only after the plugins' thread is done with the request, which is what justifies them keeping &mut. The PR's Miri reduction proves the model violation but can't prove the field partition itself is complete.

Other factors

  • The PR explicitly interacts with four other open PRs (#37732, #37709, #37691, #37731) on the same lines; whichever lands first affects the rebase of the others and the lint's ENTRIES table. A maintainer should decide the landing order.
  • The dead-code deletion (was_file + its branch that scheduled parse_task.task directly and set value = Consumed without unlinking) is well-justified — I confirmed no setter exists in the tree — but deleting a branch that would have bypassed on_load_async warrants human sign-off.
  • The 626-line source lint is a novel test shape (regex over stripped Rust source) with a self-test; it looks correct but is itself a maintenance surface.
  • No CI results are visible on the timeline yet.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

On the two points the review asked a human to confirm, here is the argument, now also in the description:

Why on_load / on_resolve and fail_outstanding_plugin_requests may keep &mut:

  • Answer side: after this change the post in on_load_async / on_resolve_async is the last access the plugins' thread makes (JSBundlerPlugin__onLoadAsync / onResolveAsync / addError return straight after it, and JSBundlerPlugin.cpp only round-trips the cookie, it never dereferences it), and the queue push orders those accesses before the bundle thread's pop. Same for task: written just before each post, read after the pop.
  • Cancellation: JSBundleCompletionTask::stop_for_vm_teardown runs on the plugins' own thread. It tombstones the plugin first (JSBundlerPlugin.cpp drops addError / onLoadAsync / onResolveAsync once tombstoned is set), then stores cancelled with Release; BundleV2::is_done loads it with Acquire, runs the answers already in its own queue, and only then fails the rest. Hops still queued on that VM's loop are released unrun (__bun_release_task_unrun in dispatch.rs; release_unrun is a no-op for Resolve / Load), so nothing on that thread touches a request after the store.

Completeness of the partition: grepping every write to a Resolve / Load field in bundle_v2.rs, Graph.rs and JSBundler.rs, the bundle thread writes outstanding (the list) and Load::deferred (drain_deferred_tasks, on_notify_defer_mini) while a request is out, and otherwise only writes on the answer or cancellation side; the plugins' thread writes value, called_defer and task, and reads fields set in init plus the ParseTask behind parse_task, which the bundle thread does not schedule until on_load. The one known overlap is the task node being enqueued twice when defer() is not awaited, which is the bug #37731 fixes (separate node for the notify) and is unchanged here.

On was_file: the field has been false since the port (and was in the Zig version too), so the branch never ran; deleting it changes no path. Had it run, it would have answered the load without posting it back and left the request linked, which is the other reason not to convert it and keep it.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 AM PT - Aug 12th, 2026

@robobun, your commit 68d83ff is building: #92838

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:07 PM PT - Aug 12th, 2026

@robobun, your commit c7960c2 has 3 failures in Build #93544 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37746

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

bun-37746 --bun

…k the outstanding list raw

A plugin that answers without suspending posts the answer from inside
match_on_load / match_on_resolve, after which the pass side may consume
the request and free its buffers, so the &[u8] arguments into the request
those functions took were protected across exactly that window. The hops
now build the BunStrings first and the match functions take them by value.

OutstandingList gets a raw for_each so drain_deferred_tasks no longer
walks the links itself; the type docs name both deferred writers.

The lint now also covers drain_deferred_tasks and the two OutstandingNode
impls, follows rebindings of the request pointer, rejects the as_mut /
read / write / NonNull and untyped-parameter spellings, and requires the
match_on_* call in the hops to take no borrows.
Comment thread src/bundler/Graph.rs Outdated
Comment thread src/bundler/Graph.rs Outdated
Comment thread src/bundler/Graph.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
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/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/runtime/api/JSBundler.rs Outdated
Comment thread src/bundler/Graph.rs
Comment thread src/bundler/Graph.rs
Comment thread src/bundler/Graph.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/bundler/bundle_v2.rs
Comment thread src/runtime/api/JSBundler.rs
Comment thread src/runtime/api/JSBundler.rs
Comment thread src/runtime/api/JSBundler.rs
Comment thread src/runtime/api/JSBundler.rs
Comment thread src/runtime/api/JSBundler.rs
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

895a4fb (comments and the test only; no code change, git diff 3cc7518ddd..895a4fb3bb -- src is all comment lines):

  • The outstanding-request contract is now stated once, on Resolve, and everything else (Load, OutstandingNode / OutstandingList, the thunks, the match_on_* functions) is down to one to three lines pointing at it. That addresses the comment-length threads as far as they can be: what they still land on after this push are # Safety sections on unsafe fns and the // SAFETY: line above each unsafe block, which the workspace requires (undocumented_unsafe_blocks is deny in Cargo.toml), plus the one contract doc. None of the flagged comments justifies a workaround; they state which fields each side owns, which is the invariant the code depends on and cannot express itself.
  • The lint reads its sources in beforeAll, as suggested, so a moved or renamed source file fails the guard test instead of failing module load.
  • The JSBundlerPlugin__onLoadAsync conversion-error arms (Thrown / Terminated falling through to the panic) are unchanged from main; this PR only changed how the request pointer is held around them. Answering the load with an error instead of panicking is a behaviour change with its own test and is being handled separately.

Comment thread src/runtime/api/JSBundler.rs
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

The .expect() calls in JSBundlerPlugin__onResolveAsync flagged above are the sibling of the onLoadAsync conversion arms mentioned in my previous comment: both predate this PR, both are only re-indented here, and the separate fix for the load thunk covers the resolve thunk as well, so both requests get answered with an error instead of panicking.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up on overlap with #37709 (opened a bit earlier, same aliasing family, different bug): it rewrites the same two bodies, Resolve::run_on_js_thread and Load::run_on_js_thread, plus the tail of Graph::drain_deferred_tasks, so the two PRs conflict textually in bundle_v2.rs and Graph.rs. Semantically they compose: #37709 removes BundleV2::plugins_mut (the &mut *bv2 the new bodies here still form on the plugin thread via bv2.plugins_mut()) and instead gives Resolve / Load a plugins field copied in init, and turns DeferredBatchTask into an arena-allocated task like the other two. In the raw-pointer shape this PR wants, that becomes Plugin::opaque_mut((*this).plugins.expect("plugins").as_ptr()), which also satisfies the source lint #37709 adds (no bv2 / plugins_mut in the hop bodies). Whichever of the two lands second needs that small rebase; the sample "good" body in bundler-plugin-outstanding-request.test.ts that calls bv2.plugins_mut() would need the same update.

@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 the bug hunting system found no issues. Because it reshapes the cross-thread aliasing contract for outstanding Resolve/Load requests across ~25 new unsafe blocks in bundler-critical code (and interacts with four sibling PRs — #37709, #37731, #37732, #37691 — on the same lines), a human look would still be worthwhile.

What was reviewed:

  • The field-partition contract on Resolve and every raw-pointer site against it — each side touches only its own fields, and the post is the last touch.
  • match_on_load/match_on_resolve string ownership: copies are made before the plugin call, so no borrow of the request is live across a synchronous answer; namespace defaulting moved to Plugin::load_namespace/resolve_namespace preserves the empty/file mapping.
  • on_defer: plugin and parse_task are read out before the notify post; append_defer_promise touches only the plugin, not load.
  • was_file deletion — the field was never set anywhere in the tree, so the removed branch was dead.
Extended reasoning...

Overview

This PR converts every access to an outstanding bundler plugin request (api::JSBundler::Resolve / Load) from &mut / &self to raw-pointer field-by-field access, on both the plugin side (run_on_js_thread, the JSBundlerPlugin__* C-ABI thunks, on_defer, on_*_async) and the pass side (OutstandingList / OutstandingNode, drain_deferred_tasks, on_notify_defer_mini). It also lifts the string arguments of match_on_load / match_on_resolve to owned BunStrings copied out of the request first, so no borrow of the request survives into the plugin call (which may answer synchronously and free those buffers before returning). The dead Load::was_file field and LoadJsExt trait are deleted. A new 814-line source lint (test/internal/source-lints/bundler-plugin-outstanding-request.test.ts) enforces the raw-pointer discipline over an explicit table of function names, with self-tests for both the conforming and previous shapes.

Security risks

None user-facing. This is internal memory-model correctness (Tree Borrows / Stacked Borrows aliasing UB under Miri, no known miscompile). No new inputs are parsed, no trust boundary changes. The FFI signature of JSBundlerPlugin__onLoadAsync changes from &mut Load to *mut Load, which is ABI-identical to the void* C++ already passes.

Level of scrutiny

High. Per the repo's own review guidance, native memory safety is the most-blocked category. The change introduces roughly two dozen new unsafe blocks/fns whose SAFETY comments hinge on a single field-ownership contract stated once on Resolve. The contract itself (which fields each side may touch while a request is outstanding, and that the post is the last touch) is the load-bearing invariant; a reviewer familiar with the bundler's threading should confirm it against dispatch, the mini-loop path, and the cancellation path. The description supplies two Miri reductions demonstrating both the before-UB and after-clean states, and the source lint pins the shape going forward, but the correctness of the partition is a design assertion that benefits from human sign-off.

Other factors

  • The bug hunting system found nothing. My own read confirmed the string-copy hoisting preserves the namespace defaulting exactly, for_each reads next before invoking the callback, unlink(*mut T) still accepts &mut T callers by coercion, and on_defer reads everything it needs from load before the notify post.
  • The pre-existing .expect() / panic! on conversion errors in onResolveAsync / onLoadAsync (flagged earlier and by CodeRabbit) are unchanged and acknowledged as a separate follow-up.
  • All comment-cop threads are resolved (comments were consolidated onto the Resolve type doc in 895a4fb; what remains are required # Safety / SAFETY: lines).
  • The author notes textual conflicts with #37709 / #37691 on run_on_js_thread and semantic overlap with #37731 / #37732; whichever lands second needs a small rebase, and the lint's "good" sample calling bv2.plugins_mut() will need updating if #37709 lands first. That sequencing is a human call.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

One clarification on the sequencing note above: the lint does not constrain how the hops reach the pass, so if #37709 lands first nothing in this test needs to change; the bv2.plugins_mut() in the self-test is a synthetic snippet exercising the request-side checks, not a copy of the tree, and the real bodies can switch to plugins_on_js_thread freely. The only rebase work in either order is the textual conflict in the two run_on_js_thread bodies (and, with #37731, the defer path), as described in the body. Otherwise the PR is in its final state: green on two builds of this source, the third build failed downloading vendor archives on the agents, and the status comment above has the details.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Overlap note: #37883 (under review by alii, third round just pushed) now converts Resolve / Load::run_on_js_thread and the dispatch arms to raw pointers, copies the strings out before the plugin call (match_on_* take BunStrings by value), and makes the answer post the last thing the plugin side does with a request, plus the thunks in JSBundler.rs. That covers the second instance in this description and part of the first; what remains distinct here is the field-wise access while the pass writes the list link. Rebasing onto #37883 once it lands should shrink this to that part.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Update to my earlier note: at review, #37883 was cut down to the mini-loop entry points and the queue primitive only; its bundler half (string copies, post-last ordering, the thunks) was dropped in favour of #37997's single post(). So the overlap this PR has is with #37997 now, not with #37883.

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