Skip to content

bundler: hand a finished Bun.build back through its pointer, not a &mut receiver - #37723

Open
robobun wants to merge 4 commits into
mainfrom
farm/67207583/bundle-completion-post-raw-ptr
Open

bundler: hand a finished Bun.build back through its pointer, not a &mut receiver#37723
robobun wants to merge 4 commits into
mainfrom
farm/67207583/bundle-completion-post-raw-ptr

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Every Bun.build() ends with the bundle thread posting the finished task to the JS thread from inside complete_on_bundle_thread(&mut self). The post carries the task's only ref, so the JS thread can free the task while that &mut self, and the &mut its caller holds, are still live arguments.
  • Freeing memory a live reference argument points at is UB under both of Rust's aliasing models, used again or not. A standalone reduction of this shape fails under Miri with the strongly protected tag disallows deallocations (Tree Borrows) and deallocating while item [Unique] is strongly protected (Stacked Borrows).
  • No crash is known from this today, since nothing reads the task after the post; the contract is what is wrong. Same class as fetch: release the FetchTasklet through its raw pointer, not a &mut receiver #37703 and bundler: make Worker::deinit_soon take the worker pointer instead of &mut self #37685, here across threads.

Fix

  • complete_on_bundle_thread takes this: *mut Self instead of &mut self. It reads the two fields it needs through raw accesses that end before the post, then posts this, the same shape as the existing zlib and VmHandle posts.
  • The bundle thread no longer turns the dequeued task into a &mut. Each trait call gets a reborrow that ends with the call, so on both the success and the error path no reference to the task exists on this thread when the post happens. Order of operations is unchanged.
  • A new source lint bans posting a pointer spelled from self as a heap task. On main it reports exactly this one site; five other sites it can see are allowlisted by count, each naming the PR that converts it.
  • Verification: the lint fails without the fix and passes with it; the UB itself is shown only by the standalone Miri reduction, not by an in-tree test. The bundler tests (one runs this hand-back thousands of times) and a script hitting the success, throw: false and throwing arms 50 times each pass on the ASAN build.

Background

  • Bun.build() runs on a dedicated bundle thread. Each build is a heap-allocated completion task: the JS thread enqueues it, the bundle thread dequeues it as a raw pointer, runs the build, stores the result and log on it, and hands it back.
  • The hand-back is a ConcurrentTask posted to the JS thread's event loop. The task has one ref from creation, the post carries it, and the JS-side handler adopts and releases it, which frees the task. The post is therefore the bundle thread's last legal touch of the object.
  • Reference protectors: while a reference is an argument of a call, Rust's aliasing models treat its pointee as protected until the call returns, so freeing it from any thread is UB even if the reference is never used again. Raw pointers carry no protection, hence the fix keeps the task raw and reborrows per call.
  • Miri is the interpreter that checks Rust code against those models. Tree Borrows and Stacked Borrows are the two models; bun run rust:miri uses Tree Borrows.
  • test/internal/source-lints/ holds bun tests that regex-scan the Rust sources for banned spellings, with a per-file allowlist of exact counts. The count must match exactly, so converting a site forces its entry down and a new site fails outright.

[review] gate passed · iteration 0 · 3 files touched

fails on main (without fix)
ASAN without fix: 1 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/self-receiver-publish.test.ts
bun test v1.4.0 (2ba088829)

test/internal/source-lints/self-receiver-publish.test.ts:
(pass) scans a non-empty set of tracked Rust sources [2.79ms]
(pass) the patterns match the banned spellings and nothing else [23.65ms]
280 |   expect(banned.map(s => findPosts(s).length)).toEqual(banned.map(() => 1));
281 |   expect(allowed.map(s => findPosts(s).length)).toEqual(allowed.map(() => 0));
282 | });
283 | 
284 | test("no method posts its own receiver as a task", () => {
285 |   expect(offenders).toEqual([]);
                          ^
error: expect(received).toEqual(expected)

- []
+ [
+   "src/runtime/api/js_bundle_completion_task.rs:1124",
+ ]

- Expected  - 1
+ Received  + 3

      at <anonymous> (/workspace/bun/test/internal/source-lints/self-receiver-publish.test.ts:285:21)
(fail) no method posts its own receiver as a task [6.22ms]
(pass) allowlisted files still carry exactly their documented count [5.13ms]

 3 pass
 1 fail
 7 expect() calls
Ran 4 tests across 1 file. [28.78s
... (truncated)

release without fix: 1 FAILED
bun test v1.4.0-canary.1 (9008ae7ab)

test/internal/source-lints/self-receiver-publish.test.ts:
(pass) scans a non-empty set of tracked Rust sources [0.11ms]
(pass) the patterns match the banned spellings and nothing else [0.55ms]
280 |   expect(banned.map(s => findPosts(s).length)).toEqual(banned.map(() => 1));
281 |   expect(allowed.map(s => findPosts(s).length)).toEqual(allowed.map(() => 0));
282 | });
283 | 
284 | test("no method posts its own receiver as a task", () => {
285 |   expect(offenders).toEqual([]);
                          ^
error: expect(received).toEqual(expected)

- []
+ [
+   "src/runtime/api/js_bundle_completion_task.rs:1124",
+ ]

- Expected  - 1
+ Received  + 3

      at <anonymous> (/workspace/bun/test/internal/source-lints/self-receiver-publish.test.ts:285:21)
(fail) no method posts its own receiver as a task [0.91ms]
(pass) allowlisted files still carry exactly their documented count [0.16ms]

 3 pass
 1 fail
 7 expect() calls
Ran 4 tests across 1 file. [877.00ms]
__F:1:S:0
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/self-receiver-publish.test.ts
bun test v1.4.0 (2ba088829)

test/internal/source-lints/self-receiver-publish.test.ts:
(pass) scans a non-empty set of tracked Rust sources [2.31ms]
(pass) the patterns match the banned spellings and nothing else [22.99ms]
(pass) no method posts its own receiver as a task [1.43ms]
(pass) allowlisted files still carry exactly their documented count [3.98ms]

 4 pass
 0 fail
 7 expect() calls
Ran 4 tests across 1 file. [28.50s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     2ba0888295
  features     baseline

22 deps, 107 codegen, 1176 objects in 1134ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] gen ErrorCode+*.h
[2/1238] gen bindgenv2
[3/1238] install /workspace/bun
bun install v1.4.0-canary.1 (9008ae7ab)

Checked 107 installs across 153 packages (no changes) [127.00ms]
[4/1238] fetch zlib
[zlib] up to date
[5/1238] fetch picohttpparser
[picohttpparser] up to date
[6/1238] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[7/1238] fetch tinycc
[tinycc] up to date
[8/1237] gen .bind.ts → GeneratedBindings.cpp
[9/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[10/1237] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (9008ae7ab)

Checked 1 install across 2 packages (no changes) [33.00ms]
[11/1237]
... (truncated)
diff hotspot
src/bundler/BundleThread.rs                        |  77 ++++--
 src/runtime/api/js_bundle_completion_task.rs       |  20 +-
 .../source-lints/self-receiver-publish.test.ts     | 294 +++++++++++++++++++++
 3 files changed, 356 insertions(+), 35 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/bundler/BundleThread.rs                                   5     11      0
src/runtime/api/js_bundle_completion_task.rs                  6      3      0
test/internal/source-lints/self-receiver-publish.test.ts      8     21      0
Original description

Problem

JSBundleCompletionTask::complete_on_bundle_thread(&mut self) (src/runtime/api/js_bundle_completion_task.rs) is how the bundle thread hands a finished Bun.build() back:

let this = std::ptr::from_mut::<Self>(self);
let ct = jsc::ConcurrentTask::create(jsc::Task::init(this));
handle.post_task(ct) ...
handle.embedded_work_finished();

The task is created with ref_count == 1 (create_and_schedule_completion_task) and nothing takes another ref, so the post carries the task's only ref. On the JS thread on_complete_anytask adopts it (ScopedRef::adopt) and releases it on return, which runs deinit and heap::takes the allocation. The JS thread picks the task up as soon as post_task wakes its loop, so on the normal path of every build the allocation can be freed while the bundle thread is still inside complete_on_bundle_thread(&mut self), and inside BundleThread::generate_in_new_thread(completion: &mut C, ..) (src/bundler/BundleThread.rs), whose caller in thread_main also held the task as &mut C and called completion.complete_on_bundle_thread() itself on the error arm.

A reference argument is protected for the duration of the call it was passed to, and freeing protected memory is UB under both aliasing models regardless of whether the reference is used again (codegen relies on the same thing: the argument is annotated dereferenceable for the whole call). A standalone reduction of exactly this shape, with the consumer on another thread, fails under Miri with Tree Borrows (the model bun run rust:miri uses):

error: Undefined Behavior: deallocation through <10104> at alloc1428[0x0] is forbidden
  = help: the allocation of the accessed tag <10104> also contains the strongly protected tag <4692>
  = help: the strongly protected tag <4692> disallows deallocations
help: the strongly protected tag <4692> was created here, in the initial state Reserved
   |     fn complete_on_bundle_thread_before(&mut self, handle: &Handle) {
   |                                         ^^^^^^^^^

and under Stacked Borrows with deallocating while item [Unique for <4874>] is strongly protected. The same reduction posting through the raw pointer passes under both. No crash is known from this today (nothing reads the task after the post); the contract is what is wrong. Same class as #37703 (FetchTasklet release) and #37685 (Worker::deinit_soon, which had the same publish-then-freed-elsewhere shape), here across threads.

Reduction run under Miri
use std::sync::mpsc;
use std::thread;

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

struct Completion { bundle_loop: *mut u8 }

struct Handle { post: mpsc::Sender<SendPtr>, freed: mpsc::Receiver<()> }

impl Handle {
    fn post_task(&self, task: SendPtr) { self.post.send(task).unwrap(); }
    // Stands in for `embedded_work_finished()`: by now the JS thread may have
    // run the completion. The channel pins the interleaving the real code allows.
    fn embedded_work_finished(&self) { self.freed.recv().unwrap(); }
}

impl Completion {
    // main
    fn complete_on_bundle_thread_before(&mut self, handle: &Handle) {
        self.bundle_loop = std::ptr::null_mut();
        let this = std::ptr::from_mut::<Self>(self);
        handle.post_task(SendPtr(this));
        handle.embedded_work_finished();
    }
    // this PR
    unsafe fn complete_on_bundle_thread_after(this: *mut Self, handle: &Handle) {
        unsafe { (*this).bundle_loop = std::ptr::null_mut() };
        handle.post_task(SendPtr(this));
        handle.embedded_work_finished();
    }
}

fn main() {
    let (post, queue) = mpsc::channel::<SendPtr>();
    let (freed_tx, freed) = mpsc::channel::<()>();
    // The JS thread: `on_complete_anytask` adopts the ref and frees the task.
    let js_thread = thread::spawn(move || {
        let task = queue.recv().unwrap();
        drop(unsafe { Box::from_raw(task.0) });
        freed_tx.send(()).unwrap();
    });
    let handle = Handle { post, freed };
    let completion = Box::into_raw(Box::new(Completion { bundle_loop: std::ptr::dangling_mut() }));
    if std::env::args().nth(1).as_deref() == Some("after") {
        unsafe { Completion::complete_on_bundle_thread_after(completion, &handle) };
    } else {
        unsafe { (*completion).complete_on_bundle_thread_before(&handle) };
    }
    js_thread.join().unwrap();
}

MIRIFLAGS=-Zmiri-tree-borrows cargo miri run -- before and the default Stacked Borrows run both exit 1 with the errors quoted above; -- after exits 0 under both.

Fix

  • CompletionStruct::complete_on_bundle_thread becomes unsafe fn complete_on_bundle_thread(this: *mut Self), with the contract (last touch of *this by the bundle thread; the caller must not hold a reference to it across the call) on the trait. The impl reads bundle_loop / loop_handle through statement-scoped raw accesses and posts this itself, like async_job_run in node_zlib_binding.rs and post_job in VmHandle.rs already do.
  • BundleThread::thread_main no longer materializes &mut C for the dequeued task; generate_in_new_thread takes *mut C and reborrows it for each trait call (create_and_configure_transpiler, init_and_run, set_log; the returned &'a mut Transpiler borrows the arena, not the task), so no reference to the task is live at either hand-back. The order of operations is unchanged: the success path still posts before the arena teardown (which only drops bundle-thread memory; Transpiler/Resolver hold log as raw pointers with no drop glue), and the error path still posts from thread_main after set_result.

The rest of the build's lifetime (the build-long &mut self trait calls, and the JS-side field writes that happen after the enqueue) is the same family and is #37740, which overlaps this PR on complete_on_bundle_thread and the pointer plumbing; whichever lands second has a small rebase. #35312, #35060 and #35158 touch neighbouring lines of BundleThread.rs but none of them changes this receiver.

Other sites of this shape were found while writing the lint below and are each their own change rather than part of this one: Resolve::dispatch / Load::dispatch (#37732), DeferredBatchTask::schedule (#37709 reshapes it), the two ThreadSafeFunction posts (#37741 addon-thread side, #37762 JS-thread side), napi_async_work::run / post_to_js_thread (#37750, which also adds the lint for the embedded-task .from(..) spelling), TranspilerJob::dispatch_to_main_thread (#37778), and FetchTasklet::deref_from_thread and StatWatcher::post_to_js_thread, which post through &self / a helper's pointer and are reported for their own fixes. The lint's header lists the ones its regex cannot see; its allowlist names the PR converting each one it can, and the entry for whichever of those lands before this PR gets dropped here on rebase (the ratchet test fails otherwise, so it cannot be missed in either order).

Tests

test/internal/source-lints/self-receiver-publish.test.ts bans posting a pointer spelled from self as a heap task: Task::init / ConcurrentTask*::create_from / ConcurrentTask*::from_callback applied to from_mut(self), from_ref(self).cast_mut(), self as *mut, &raw mut *self, bare self (which coerces), or a local bound to one of those (including let p: *mut Self = self;) further down the same function. Same-loop enqueue_task posts are in scope on purpose: there the hazard is provenance rather than the protector (the queued pointer is a child of the receiver reborrow, and the first access through the owner's pointer before the queue drains kills it; the maybe_queue_finalizer case in napi_body.rs is exactly that, and the finalizer it posts is what frees the object), which the header states alongside the cross-thread argument, both checked with Miri reductions. It checks its patterns against positive and negative examples and ratchets the five regex-visible instances with what is wrong at each and the PR converting it (above). Against main it reports exactly

src/runtime/api/js_bundle_completion_task.rs:1124

and passes with this branch.

Verification

On the debug (ASAN) build: test/bundler/bun-build-api.test.ts (52 pass, including the "thousands of times in one process" test, which runs this hand-back a few thousand times), bundler_plugin.test.ts, bundler_plugin_chain.test.ts, bundler_defer.test.ts, metafile.test.ts, bundler_html_server.test.ts and test/js/bun/http/bun-serve-html.test.ts (the HTMLBundle route path through the same completion), plus a script exercising the success arm, the throw: false error arm and the throwing arm 50 times each. bun test test/internal/source-lints/ (19 files) passes; cargo clippy -p bun_bundler -p bun_runtime and cargo fmt --check are clean.

Two things seen while running those are unrelated to this change and were reported separately: a DevServer debug assertion when a dev-mode HTML route is started after process.chdir() (shows up when bundler_html_server.test.ts and bun-serve-html.test.ts run in one process; the backtrace does not involve this code), and bun-serve-html-entry.test.ts failing to connect to localhost in this container with the release binary as well.

…ut receiver

complete_on_bundle_thread posts the completion task's only ref to the JS
thread, which frees the task as soon as the post lands. It took &mut self,
and BundleThread held the task as &mut C across the post as well, so the
allocation could be freed while reference arguments to it were still live.

The trait method now takes *mut Self and BundleThread keeps the dequeued
pointer raw, reborrowing it per call; nothing forms a reference to the task
across either hand-back.

Adds a source lint banning posting a pointer spelled from self as a task,
with the remaining same-shaped sites ratcheted.
@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: 2 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: 1ddad08c-08f6-4a34-9fd7-adc4dfd16778

📥 Commits

Reviewing files that changed from the base of the PR and between 3e61ab3 and 2ba0888.

📒 Files selected for processing (3)
  • src/bundler/BundleThread.rs
  • src/runtime/api/js_bundle_completion_task.rs
  • test/internal/source-lints/self-receiver-publish.test.ts

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

@robobun

robobun commented Aug 12, 2026

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

@robobun, your commit 2ba0888 has some failures in Build #93236 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 37723

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

bun-37723 --bun

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review; nothing further planned from this side.

Reproduced as a standalone Miri reduction of the hand-back shape (bundle thread posts from &mut self, consumer thread frees): Tree Borrows reports deallocation through <tag> ... is forbidden ... the strongly protected tag disallows deallocations pointing at the &mut self receiver, Stacked Borrows reports deallocating while item [Unique] is strongly protected; the same reduction through *mut passes under both (source in the PR description). In the tree the lint in this PR reports exactly src/runtime/api/js_bundle_completion_task.rs:1124 on main and nothing on this branch.

Verified on the debug (ASAN) build with test/bundler/bun-build-api.test.ts, the plugin/defer tests, metafile.test.ts, bundler_html_server.test.ts and bun-serve-html.test.ts; details in the description.

Since the first push: the lint's header also states the same-thread (provenance) hazard and lists the sites its regex cannot see, each allowlist entry names the PR converting it (#37732, #37709, #37741, #37762), and the contract comments were shortened. Merge order with those PRs and with #37740 is in the description: whichever lands second rebases, and the ratchet test points at any entry that has to go.

CI: build 93236 (final push, 2ba0888) has no failing tests: 192 jobs passed and the two darwin 26 aarch64 test jobs expired unrun waiting for an agent; those lanes ran and passed on build 92775 for the same source change (the pushes in between touch only comments and the lint file, which the Buildkite lanes do not run). 92775's one red, test-tonic on darwin 14, fails and passes on retry on main as well. Not retriggering for the expired jobs.

@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 restructures cross-thread unsafe ownership hand-back in the Bun.build() completion path (aliasing-model protectors, statement-scoped reborrows, raw-pointer receivers) and encodes an allowlist of five other sites in a new source-lint, a human look would still be worthwhile.

What was reviewed:

  • Order of operations in generate_in_new_thread is unchanged (post-then-teardown on success, set_result-then-post on error); each (*completion) reborrow ends at its ; before the hand-back.
  • Confirmed Transpiler.log is *mut Log (transpiler.rs:124) — drop_in_place(transpiler_ptr) after the post has no drop glue that dereferences into the possibly-freed task.
  • The new lint follows self-receiver-reclaim.test.ts conventions exactly (tracked-file filter, realpath dedup, ratcheted allowlist, positive/negative self-checks, non-vacuous scan guard).
Extended reasoning...

Overview

This PR converts CompletionStruct::complete_on_bundle_thread from &mut self to unsafe fn(this: *mut Self), and threads the raw pointer through BundleThread::thread_maingenerate_in_new_thread so no &mut C reference to the completion task is live across the point where it is posted to the JS thread (which may free it). The impl in js_bundle_completion_task.rs reads bundle_loop/loop_handle through statement-scoped raw accesses before posting this. A new source-lint (self-receiver-publish.test.ts) bans posting a self-derived pointer via Task::init / create_from / from_callback, with a ratcheted allowlist for five documented sites (bundle_v2.rs ×2, DeferredBatchTask.rs, napi_body.rs ×2).

Security risks

None user-facing. The change is a Rust aliasing-model soundness fix (protected reference freed by another thread mid-call). No input parsing, auth, or crypto is touched.

Level of scrutiny

High. This is unsafe Rust in the cross-thread hand-back that runs on every Bun.build(). The transformation is mechanical (reference → raw pointer with statement-scoped reborrows) and follows in-tree precedent (async_job_run, post_job; same class as #37703/#37685), but the correctness relies on subtle claims: that no reborrow of *completion outlives its statement, that drop_in_place(transpiler_ptr) after the post cannot reach the task's memory (verified: log is a raw *mut), and that the allowlisted sites in the lint are correctly characterised as not-freeing / same-thread / blocked-on-callers.

Other factors

  • Bug hunter found nothing. I checked the two paths independently and the order of operations matches the pre-PR code.
  • The lint test mirrors self-receiver-reclaim.test.ts structure closely and self-checks its regexes against 18 banned and 15 allowed examples plus a non-empty-scan guard.
  • The PR description is thorough (Miri reduction under both aliasing models, extensive test list, explicit scoping of what is left for follow-ups).
  • No CODEOWNERS on these paths; no prior human reviewer comments to address.

Deferring because memory-safety-critical unsafe Rust across threads warrants human eyes even when the automated pass is clean.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

The two bundle_v2.rs sites this lint ratchets (Resolve::dispatch / Load::dispatch) are converted in #37732. Whichever of the two lands second should delete the "src/bundler/bundle_v2.rs": 2 entry; the ratchet test here will point at it.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up on the "src/bundler/bundle_v2.rs": 2 entry: #37732 converts those two sites by routing them through one by-value helper, and against that branch this lint counts 0 there, so the ratchet test here fails as soon as both changes are in one tree. #37732 is marked as landing after this PR and will delete the entry on its rebase (this lint's own exact-count test is what flags it there). Two small asks for this side: have the entry's comment name #37732, and if this PR ends up merging second, drop the entry here before merging.

…-receiver-publish cannot see

Pin create_from/from_callback to ConcurrentTask paths, add the intrusive
.from(..) spelling, state the same-thread (provenance) hazard that makes
same-loop posts part of the shape, list the same hand-over spelled outside
the regex, and correct the napi allowlist note: the finalizer post's consumer
does free the receiver.
Comment thread src/bundler/BundleThread.rs Outdated
Comment thread src/bundler/BundleThread.rs Outdated
Comment thread src/bundler/BundleThread.rs Outdated
Comment thread src/bundler/BundleThread.rs
Comment thread src/bundler/BundleThread.rs
Comment thread src/bundler/BundleThread.rs
Comment thread src/bundler/BundleThread.rs

@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 automated bug hunt found no issues. Because it rewires the cross-thread ownership hand-back for every Bun.build() — turning &mut self into raw-pointer receivers with new statement-scoped reborrows and a post-then-teardown ordering that depends on Transpiler's drop glue not reaching the completion's log — a human look at the unsafe reasoning would still be worthwhile.

What was reviewed

  • generate_in_new_thread ordering: confirmed the arena teardown after complete_on_bundle_thread only touches bump-allocated memory (transpiler_ptr, ast_memory_store), not *completion; the transpiler.log raw pointer into (*completion).log dangles after the post but has no drop glue per the PR's claim.
  • Error path in thread_main: the (*completion).set_result(..) reborrow ends before C::complete_on_bundle_thread(completion), matching the trait contract.
  • The source-lint's regex patterns against its own positive/negative examples and the ratchet entries; the bundle_v2.rs: 2 entry's comment does not yet name #37732 as the 12:24 follow-up asked.
Extended reasoning...

Overview

This PR fixes a Rust aliasing-model violation in the Bun.build() completion hand-back: JSBundleCompletionTask::complete_on_bundle_thread(&mut self) posted a pointer derived from self to the JS thread, which adopts the task's only ref and may free it while the &mut self (and the caller's &mut C in generate_in_new_thread / thread_main) is still a live protected argument. The fix converts complete_on_bundle_thread and generate_in_new_thread to take *mut Self / *mut C, with each trait call reborrowing through a statement-scoped (*completion).method() so no reference is live at the post. A new source-lint test bans the Task::init(from_mut(self)) pattern tree-wide with a documented ratchet for the four remaining sites.

Security risks

None in the traditional sense; this is a memory-safety soundness fix under Tree/Stacked Borrows (demonstrated via Miri reduction), not a new attack surface. The change strictly narrows what is UB.

Level of scrutiny

High. This is unsafe Rust in the cross-thread ownership hand-back for every Bun.build() — REVIEW.md's most-blocked category. The reasoning is subtle: after the post, *completion may be freed on another thread, and the function continues to run ast_memory_store.pop() and drop_in_place(transpiler_ptr). That is only sound because Transpiler holds log (which points into (*completion).log) as a raw pointer with no drop glue, an invariant the PR asserts but which a reviewer should verify against Transpiler's actual Drop impl. The success-path ordering (post before arena teardown) is preserved from before, so this is not a new hazard, but the raw-pointer conversion makes the dependency explicit.

Other factors

The PR is thoroughly argued (Miri reduction, ASAN test runs across the bundler suite including the thousands-of-builds stress test, clippy/fmt clean, CI green modulo an unrelated darwin flake). The comment-cop bot flagged the SAFETY comments and the author shortened them in c265bf9; those threads are resolved. There's a self-noted follow-up (12:24Z) asking the bundle_v2.rs ratchet entry to name #37732 — the current diff's entry does not, which is a minor doc nit. Given the depth of unsafe reasoning required and the hot-path nature of the code, I'm deferring rather than approving.

…ption

The embedded-task .from() spelling is covered by the sibling lint in #37750,
so this one goes back to the heap-task constructors only.
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 2ba0888: the bundle_v2.rs entry now names #37732 (the DeferredBatchTask and napi entries name #37709 and #37741 / #37762 the same way), and the description records the merge-order rule: if any of those lands before this PR, its entry gets dropped here on rebase, which the ratchet test forces either way. The embedded-task .from(..) spelling that 3644c91 had added to this lint is back out, since #37750 carries a dedicated lint for it.

@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 restructures the cross-thread ownership hand-off that every Bun.build() goes through — turning &mut self receivers into raw *mut with statement-scoped reborrows across generate_in_new_thread, thread_main, and the trait impl — a human look at the aliasing/lifetime reasoning would still be worthwhile.

What was reviewed:

  • Confirmed no access to *completion survives past complete_on_bundle_thread on either the success or error arm; the post-hand-back teardown (drop_in_place(transpiler_ptr), ast_memory_store.pop()) only touches arena/bundle-thread memory, and Transpiler.log is a raw *mut with no drop glue so the completion's log field is not reached.
  • Checked the new impl reads bundle_loop/loop_handle out before posting this, and only the local handle clone is used afterwards.
  • Verified the source-lint test's ratchet entries name their converting PRs (the 12:24 follow-up ask re: #37732 is addressed), the pattern self-test covers the spellings, and scripts/glob-sources.ts exports globAllSources.
Extended reasoning...

Overview

This PR converts CompletionStruct::complete_on_bundle_thread from &mut self to unsafe fn(this: *mut Self) and threads that change up through BundleThread::generate_in_new_thread (now takes *mut C) and thread_main (no longer materializes &mut C). The motivation is a Miri-reproduced aliasing-model violation: the bundle thread posts the task's only ref to the JS thread, which may free it while a &mut self argument is still protected on the bundle thread's stack. A new source-lint (self-receiver-publish.test.ts) bans the pattern tree-wide with a ratcheted allowlist for four sites being converted in sibling PRs.

Security risks

None. This is an internal ownership/aliasing correctness change with no user-facing surface, no parsing of untrusted input, and no security-sensitive paths.

Level of scrutiny

High. This is unsafe Rust on a hot cross-thread path (every Bun.build()) whose correctness rests on non-local invariants: that each (*completion).method() reborrow ends with its call, that the returned &'a mut Transpiler borrows the arena rather than the task, that Transpiler::drop (run via drop_in_place after the post) does not reach into the completion (its log field is a raw *mut, confirmed at src/bundler/transpiler.rs:124), and that nothing below the hand-back touches *completion. I traced each of these and they hold, and the change follows the same shape as async_job_run / post_job cited in the description — but this is exactly the category REVIEW.md flags for careful maintainer sign-off ("Never let a pointer or slice outlive the memory it points into", "Reference counts provably balanced on every terminal path").

Other factors

The PR is thoroughly verified (Miri reduction under both aliasing models, ASAN debug build across the bundler suite including the thousands-of-builds test, the lint fails on main at exactly the fixed site). The comment-cop bot threads are all resolved (comments were shortened in c265bf9 to the required # Safety / SAFETY: contracts). The 12:24 robobun follow-up asked for the bundle_v2.rs allowlist entry to name #37732, which it now does. There is a coordination note that #37732 will delete that entry when it lands second. Nothing here blocks; the deferral is purely because the unsafe reasoning is subtle enough to merit a maintainer's eyes.

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