Skip to content

Hand pool-finished transpiler and patch jobs back through their pointer, not a &mut receiver - #37778

Open
robobun wants to merge 6 commits into
mainfrom
farm/682d5526/transpiler-job-dispatch-raw-ptr
Open

Hand pool-finished transpiler and patch jobs back through their pointer, not a &mut receiver#37778
robobun wants to merge 6 commits into
mainfrom
farm/682d5526/transpiler-job-dispatch-raw-ptr

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • The hand-over moves out to the caller, which already holds the job as a raw pointer: run the body through a statement-scoped reborrow, copy out what is still needed, push or put the pointer, and never touch it again. The shell, network and package manager task callbacks already have this shape.
  • Correct because every &mut to the object ends with its own statement, so none exists at the moment the other side takes it.
  • The transpiler's defer! dispatch becomes an unconditional dispatch by the worker after run(); the workspace is panic = "abort", so the same early returns are covered, and the push stays under the embedded-work count teardown waits on. The finished result is moved out into a struct so the slot still goes back before script runs; a batch cut short by VM termination now releases the jobs it had already popped.
  • Verification: a new source lint bans passing the receiver's address to .push/.put; it reports exactly these three sites on main and passes here (two FilePoll sites are allowlisted at an exact count until io: return a FilePoll to its store through the owner's pointer, not a &mut receiver #37803). Behaviour is unchanged, so the rest is coverage under the ASAN debug build: a new 96-way concurrent import() test that overflows the hive and includes parse failures, a new worker terminate()-during-transpile test, and the existing patch tests.

Background

  • Transpiler store: import()s are transpiled on the thread pool in per-VM job slots (64 inline, heap boxes past that). The pool pushes a finished slot onto a queue; the JS thread pops it, fulfils the module promise and puts the slot back, which drops it in place or frees a heap spill.
  • Patch task: bun install computes and applies package patches on the thread pool; the worker pushes the finished task onto the package manager's queue and the main thread takes it and drops it.
  • Protectors: for the whole of a call, a &mut argument is noalias and dereferenceable. Writing to or freeing that memory before the call returns, from any thread, is undefined behaviour even if the method never reads it again; Miri's Tree Borrows mode reports it, and codegen relies on it.
  • Embedded work: the VM's teardown waits on a counter that schedule bumps and the worker releases after dispatch, which is what makes pushing and posting outside the borrow_if_running() guard safe.
  • Source lints: test/internal/source-lints/ holds tests that scan the Rust tree for a banned shape and pin allowlisted leftovers to an exact count, so a converted site cannot be replaced by a new one.
Original description

Problem

Three places give an object's storage away from inside a &mut self method on that object:

site shape
TranspilerJob::dispatch_to_main_thread(&mut self) (src/jsc/RuntimeTranspilerStore.rs) queue.push(NonNull::from(&mut *self)), run from a scopeguard::defer! inside run(&mut self), which run_from_worker_thread enters through (*this).run(); the not-running branch calls (*this).dispatch_to_main_thread() directly
TranspilerJob::run_from_js_thread(&mut self) (same file) store.put(ptr::from_mut(self)), entered through (*job).run_from_js_thread()
PatchTask::run_from_thread_pool_impl(&mut self) (src/install/patch_install.rs) patch_task_queue.push(NonNull::from(&mut *self)), entered through &mut *PatchTask::from_task_ptr(task)

The push is the hand-over: as soon as it lands, the JS thread pops the transpiler job, writes its fields (run_from_js_thread, or release_queued_jobs_for_teardown during teardown) and put()s it, and the package manager's main thread heap::takes the patch task and drops it (src/install/PackageManager/runTasks.rs). The put is the hive return itself: HiveArrayFallback::put drops the slot in place, and when more than TRANSPILER_JOB_HIVE_CAP (64) jobs are in flight the slot is a heap Box that put frees. In all three cases this happens while the &mut self of the method that made the call (and, for the transpiler's push, of run one frame up) is still a live argument.

A reference argument is protected until its call returns: rustc emits it as noalias + dereferenceable for the whole call, so the compiler may re-read through it after the call instead of keeping the copies taken before it (a source-level read after the push is what crashed the Zig version of this code, #29128), and under the aliasing model bun run rust:miri checks (Tree Borrows) freeing the Box is rejected outright ("the strongly protected tag disallows deallocations") and the other thread's writes race with the protector's release. The comment in dispatch_to_main_thread described the hazard, but the receiver type contradicted it. No crash is known from any of the three today; this is the same contract bug as #37681, #37703, #37723 and #37768, in two spellings (push, put) none of those lints cover.

Fix

All three go through the pointer the caller already holds, the shape ShellTask::on_finish, NetworkTask::notify and PackageManagerTask::Task::callback already use:

  • dispatch_to_main_thread becomes unsafe fn(this: *mut Self): it copies vm and clones loop_handle through accesses that end before the push, pushes this, and posts the store. run_from_worker_thread runs run() through a statement-scoped reborrow (only while the VM is running, as before) and then dispatches unconditionally, which replaces the defer! in run: that guard only existed to cover run's early returns, and the workspace is panic = "abort", so a call after the scoped run() covers the same paths. The dispatch now happens after the borrow_if_running() guard is released on the running path too; the push and post are covered by the embedded-work count (schedule increments it, embedded_work_finished follows the dispatch, and teardown waits for it before close()), which is what the not-running branch already relied on.
  • run_from_js_thread becomes unsafe fn(this: *mut Self). The body that moves the result out of the slot and resets it is now take_completion(&mut self) -> Completion, invoked as a statement-scoped (*this).take_completion(); the put then takes this, and AsyncModule::fulfill runs on the moved-out values, in the same order as before (slot back first, then script). The two callers in RuntimeTranspilerStore::run_from_js_thread pass the popped pointer.
  • PatchTask::run_from_thread_pool keeps the raw pointer it recovers from the pool task, runs the body through (*this).run_from_thread_pool_impl(), and pushes this; the push and wake move out of the &mut self method.

Out of scope, on purpose: TranspilerJob::schedule in the same file is the WorkPool::schedule instance of this and is converted by #37768 (the hunks do not overlap). FilePoll::deinit_possibly_defer (src/io/posix_event_loop.rs and the Windows twin) puts its own slot back the same way; every FilePoll::deinit* entry point is &mut self with many owners, so it is allowlisted in the lint below with an exact count; #37803 converts those two sites and carries the same lint file with the allowlist turned around, so whichever of the two PRs lands second deletes the entries that remain (the ratchet test fails until it does). quic's ENDPOINT_REGISTRY pushes from_ref(self).cast_mut() from a &self method into a same-thread registry whose consumer neither writes nor frees; the lint leaves the shared spellings out rather than allowlisting it.

Verification

test/internal/source-lints/self-receiver-push-put.test.ts bans passing the receiver's address (NonNull::from(&mut *self), from_mut(self), &raw mut *self, self as *mut _, wrapped in NonNull::new* or not, inline or through a local of the same function) to a .push(..) or .put(..), with a self-test of the spellings it does and does not match and an exact-count ratchet for the two FilePoll entries. Against main it reports exactly the three sites above:

src/install/patch_install.rs:184
src/jsc/RuntimeTranspilerStore.rs:525
src/jsc/RuntimeTranspilerStore.rs:577

and passes with this branch.

Behaviour is unchanged, so the rest is coverage of the converted paths, run against the debug (ASAN) build:

  • test/js/bun/resolve/concurrent-dynamic-import.test.ts: new test issues 96 import()s at once, 12 of which fail to parse, and checks every one settles with its own module's result. All 96 jobs are claimed before the first is handed back, so this covers the hive slots, the heap-spilled slots that put() frees, and both the success and the early-return paths of run() reaching the caller-side dispatch. An interleaved comparison of the per-import round trip between this branch and a main build of the same tree showed no difference (3.8 to 6.6 ms per import on a loaded debug box, both builds alike).
  • test/js/web/workers/worker.test.ts: new test terminates workers while their imports are still on the pool. With BUN_DEBUG_Worker=1 the teardown log shows between 1 and 57 jobs still out on the pool in most of the teardowns, so the not-running branch and release_queued_jobs_for_teardown are exercised; the test asserts a clean exit. About 1.2s locally under ASAN. Three older tests in the same block fail locally on this machine exactly the same way without this change (two time out at 5s, one expects a worker to have posted within 30ms; debug build speed), so they are not touched here.
  • test/cli/install/bun-install-patch.test.ts (18 pass) and test/cli/install/bun-patch.test.ts (31 pass) for the patch task hand-over.

cargo build of the touched crates is warning-free; rustfmt --check is clean.

…not &mut self

TranspilerJob::dispatch_to_main_thread(&mut self) pushed the job slot onto
the store queue from inside run(&mut self)'s scope guard, and
PatchTask::run_from_thread_pool_impl(&mut self) pushed its task onto the
manager's queue the same way. From the push on, the consuming thread
recycles or frees the object while those receivers are still live
arguments of the functions that pushed.

dispatch_to_main_thread now takes the slot pointer; run_from_worker_thread
calls run() through a statement-scoped reborrow and dispatches afterwards
on both paths, which also replaces the scope guard. PatchTask's push moves
into run_from_thread_pool, next to the raw pointer recovered from the pool
task. A source lint bans pushing a receiver's address onto a queue, and
the import and worker tests exercise the hive, heap-spilled and teardown
hand-back paths.
@robobun

robobun commented Aug 12, 2026

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

@robobun, your commit 207f02b has 2 failures in Build #93502 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 37778

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

bun-37778 --bun

@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: 1 minute

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: 4544b0a4-e3fb-4f29-a3d8-fbbfc677b555

📥 Commits

Reviewing files that changed from the base of the PR and between 626034f and 207f02b.

📒 Files selected for processing (5)
  • src/install/patch_install.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • test/internal/source-lints/self-receiver-push-put.test.ts
  • test/js/bun/resolve/concurrent-dynamic-import.test.ts
  • test/js/web/workers/worker.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: ready for review.

Reproduced how: test/internal/source-lints/self-receiver-push-put.test.ts against main reports src/jsc/RuntimeTranspilerStore.rs:525 (push), src/jsc/RuntimeTranspilerStore.rs:577 (put) and src/install/patch_install.rs:184 (push), the three receivers handed away from under &mut self; it passes with this branch. The import, worker and patch tests named in the description pass against the debug (ASAN) build with the change.

Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
TranspilerJob::run_from_js_thread(&mut self) ended with store.put(self),
which drops the slot in place, or frees it when it spilled past the hive,
while that receiver is still a live argument. The result is now moved out
by a &mut self helper (take_completion), the put goes through the pointer
the queue handed back, and fulfill runs on the moved-out values as before.

The lint covers .put( alongside .push( (self-receiver-push-put.test.ts);
against main it reports the three converted sites. FilePoll's own put of
its slot is allowlisted with an exact count and tracked separately.
Comment thread src/install/patch_install.rs
Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment thread src/jsc/RuntimeTranspilerStore.rs
Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment thread src/jsc/RuntimeTranspilerStore.rs
Comment thread src/jsc/RuntimeTranspilerStore.rs
Comment thread src/jsc/RuntimeTranspilerStore.rs
Comment thread src/jsc/RuntimeTranspilerStore.rs
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Two follow-up pushes since the PR opened:

  • f9bfdec: TranspilerJob::run_from_js_thread had the same shape one step later (store.put(ptr::from_mut(self)), a Box free for the heap-spilled slots), as pointed out in review. It now takes the popped pointer; take_completion(&mut self) moves the result out, the put goes through the pointer, and fulfill runs on the moved-out values in the same order as before. The lint is renamed self-receiver-push-put.test.ts and covers .put( too; against main it reports the three sites listed in the description. FilePoll::deinit_possibly_defer (posix and windows) is the one other instance it finds; it is allowlisted with an exact count and reported separately, since all of FilePoll::deinit* is &mut self.
  • 16ba6ac: trimmed the comments on the converted functions down to their Safety contracts and a line or two on who owns the slot at each point.

Description and status comment are updated; unresolved review threads from the comment linter are answered above.

@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.

My earlier note on run_from_js_thread was addressed in f9bfdec (now takes the popped pointer, take_completion(&mut self) moves the result out before put(this)), and the comment-cop threads were trimmed in 16ba6ac. No further issues found.

Checked: run()'s sole caller is run_from_worker_thread and the workspace is panic = "abort", so the post-call dispatch covers every path the removed defer! did; the borrow_if_running() guard now dropping before dispatch is covered by the embedded-work count as described. PatchTask::run_from_thread_pool_impl is now private with the one caller. The lint's allowlist entries match the two FilePoll sites.

Given this reworks cross-thread ownership hand-over on the transpiler hot path and worker teardown, a human look is still worthwhile.

Extended reasoning...

Overview

Converts three &mut self methods that hand their own storage to another thread (or back to a hive that may free it) into unsafe fn(this: *mut Self) with statement-scoped reborrows: TranspilerJob::dispatch_to_main_thread, TranspilerJob::run_from_js_thread, and the push half of PatchTask::run_from_thread_pool. Removes the scopeguard::defer! from TranspilerJob::run() in favour of an unconditional dispatch in the caller. Adds a source lint (self-receiver-push-put.test.ts) covering .push( and .put( of the receiver's address, with a ratcheted allowlist for the two FilePoll::deinit_possibly_defer instances, plus runtime coverage of hive overflow (96 concurrent imports) and worker-teardown-mid-transpile.

Security risks

None identified. No user-controlled input parsing, auth, or crypto. The change tightens an aliasing-model contract (protected &mut argument vs. cross-thread free/write) rather than introducing new unsafe surface.

Level of scrutiny

High. This is memory-safety-critical unsafe Rust on a hot path (every async import()) with cross-thread ownership reasoning: it changes when the borrow_if_running() guard is released relative to the queue push, replaces a scope guard with post-call sequencing under panic = "abort", and reorders run_from_js_thread around a new Completion struct. Each step is argued in the description and matches the sibling PRs (#37681/#37703/#37723/#37768), but the reasoning about embedded-work counts keeping the VM handle open across the now-unguarded dispatch, and HiveArrayFallback::put's drop-in-place vs. Box-free split, deserves a maintainer's eyes.

Other factors

My prior review flagged the run_from_js_thread put(ptr::from_mut(self)) sibling; f9bfdec folded it in and extended the lint to .put(. The comment-cop bot's verbosity flags were trimmed in 16ba6ac. All inline threads are resolved. Tests are thorough: the lint self-tests its own patterns, has a non-vacuous scan guard, and ratchets the allowlist; the 96-import test covers both hive and heap-spilled slots plus the parse-error early return; the worker test exercises release_queued_jobs_for_teardown. Verified run() has no other callers and run_from_thread_pool_impl is now private.

@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #37803 converts the two FilePoll sites this PR's self-receiver-push-put.test.ts allowlists (src/io/posix_event_loop.rs, src/io/windows_event_loop.rs) and carries the same lint file with the allowlist turned around (it allowlists the three transpiler/patch sites converted here). The two copies differ only in the header prose and the ALLOW block, so whichever of the two lands second just deletes the entries that remain; the ratchet test in the file fails until that is done.

@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 re-reviewed after f9bfdec and 16ba6ac and didn't find new issues — the run_from_js_thread put now goes through the pointer as flagged, and the lint covers .put( too. Given this is unsafe aliasing-model surgery on the transpiler store hot path and shifts the running-branch dispatch outside the borrow_if_running() guard onto the embedded-work count, a human look is still worthwhile.

What was reviewed:

  • run()'s defer! removal vs. caller-side dispatch — every early return in run() still reaches the dispatch; workspace is panic = "abort" so the guard bought nothing extra.
  • Dispatch-after-guard-drop on the running branch — covered by the embedded-work count as the not-running branch already was; post_task still hits Posted::Queued.
  • take_completion preserves the old order (reset → put → fulfill) and moves out the same fields; run_from_thread_pool_impl going private has no other callers.
Extended reasoning...

Overview

The PR converts three &mut self methods that hand their own storage away (via .push() onto a cross-thread queue or .put() back into a hive) to take *mut Self instead: TranspilerJob::dispatch_to_main_thread, TranspilerJob::run_from_js_thread, and PatchTask::run_from_thread_pool. It removes the scopeguard::defer! inside TranspilerJob::run() in favor of an unconditional dispatch in the caller, adds a Completion struct and take_completion() helper to move the result out before the slot is recycled, and adds a source lint (self-receiver-push-put.test.ts) plus two behavioral tests (96-way concurrent dynamic import overflowing the 64-slot hive; worker terminate mid-transpile).

Security risks

None. This is an internal aliasing-model correctness refactor with no user-facing API surface, no parsing of untrusted input, and no auth/crypto/permissions code touched.

Level of scrutiny

High. The transpiler store is on the hot path of every async module load, and the change alters where the running-branch dispatch happens relative to the borrow_if_running() guard — previously the dispatch ran under the guard (via defer! inside run()), now it runs after the guard drops, relying on the embedded-work count for VM lifetime. The PR description argues this correctly (the not-running branch already relied on the same count), and the post_task result is still asserted Queued, but this is exactly the kind of synchronization-ordering change where a maintainer's eye on the VM-teardown sequencing is valuable. The unsafe-Rust reasoning about protectors is subtle by nature.

Other factors

My earlier review flagged that run_from_js_thread had the same shape as dispatch_to_main_thread — that was folded in with f9bfdec, and the lint was extended to cover .put(. The comment-cop feedback on comment length was addressed in 16ba6ac; the remaining comments are Safety contracts and one- or two-line ownership notes. I confirmed panic = "abort" is set in the workspace Cargo.toml (so removing the defer! in run() for its early-return coverage is sound), and that run_from_thread_pool_impl (now private) has no other callers. The lint's allowlist for the two FilePoll sites is ratcheted with an exact count and #37803 is queued to convert those. The new tests exercise both the hive-inline and heap-spilled slot paths, the parse-error early return, and the teardown release path. No bugs were found by the bug-hunting system on this pass.

RuntimeTranspilerStore::run_from_js_thread pops a whole batch of finished
jobs off the queue and returns as soon as draining microtasks reports that
script was terminated. The jobs it had not reached yet were already off
the queue, so release_queued_jobs_for_teardown never saw them: their path
buffer, module promise, transpiled source and (for slots spilled past the
hive) the slot itself leaked on every worker.terminate() that landed
mid-batch. The drain now releases the remainder through the same helper
the teardown uses.

The worker test keeps a stream of imports in flight and terminates once
the first one has evaluated, so the termination always lands mid-batch;
under ASAN the host is leak-checked with the transpile-wide suppression
removed, which is what fails without this change. Both new tests also
check, in debug builds, that their imports really went through the store.
Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment on lines +241 to +243
/// their source, log and module promise here instead of running. (A batch
/// that `run_from_js_thread` had already popped when script was terminated
/// is released there.)

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment on lines +250 to +252
/// Releases `job` and whatever is left in `iter`: jobs whose completion
/// will not run drop their module promise here, on the JS thread, and go
/// back to the pool.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment on lines +301 to +302
// Script was terminated. The rest of this batch is already off
// the queue, so the teardown would never see it: release it now.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

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