http: publish send_sync's result without holding a reference into the channel past the release - #38345
http: publish send_sync's result without holding a reference into the channel past the release#38345robobun wants to merge 1 commit into
Conversation
… channel past the release send_sync frees its SingleHTTPChannel as soon as read_item returns, which it can do the moment the publishing thread's lock release lands. The publisher was write_item(&self) with a GuardedLock, so the HTTP thread was still inside frames holding references into the channel (write_item's &self, the guard's Drop, Mutex::unlock(&self)) while the owner freed it; both aliasing models reject the free for that shape and rustc marks those arguments dereferenceable. Add Guarded::with_lock_raw, which locks through a raw pointer, runs the caller's closure under the lock and releases through Mutex::unlock_raw so the releasing store is the last access to the Guarded, and make write_item take the channel as *const Self and publish through it. The Mutex::unlock_raw / Futex::wake_raw layer is the same change as in the WaitGroup fix so that either can land first. The crate gets a miri model of the handoff (guarded::tests), run by test/internal/threading-guarded-miri.test.ts; the by-reference shape fails it within a few dozen iterations. handoff-publish-raw.test.ts pins the publisher signatures and that send_sync_callback ends at the publish.
|
Warning Review limit reached
Next review available in: 38 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (6)
Comment |
|
Status: fix pushed, waiting on CI and review.
|
There was a problem hiding this comment.
I reviewed this PR and the automated bug-hunting pass found no issues. Because it rewrites the unlock path of the core Mutex/Futex/Guarded primitives across every platform backend and adds new unsafe raw-pointer API (with_lock_raw, unlock_raw, wake_raw) whose correctness rests on aliasing-model reasoning, a human look is still warranted.
Checked: the condvar signal in write_item sits inside the with_lock_raw closure so it runs before the release; with_lock_raw's own (*this).mutex.lock() and the &mut Value passed to f both drop their protectors before unlock_raw; the FreeBSD/WASM .cast_mut() adaptations preserve the original *mut c_void argument type; Bun__unlock and Mutex::unlock route through unlock_raw so no by-reference unlock remains on any backend.
Extended reasoning...
Overview
This PR fixes an aliasing-model violation in AsyncHTTP::send_sync's one-shot result handoff. The HTTP thread's write_item(&self) held a protected &self (and, via GuardedLock::drop → Mutex::unlock(&self), a protected &Mutex) past the store that releases the lock, while send_sync frees the channel as soon as read_item returns — which it can do the instant that release lands. Under Stacked/Tree Borrows the free is UB (protected tags may not be deallocated), and rustc marks those references dereferenceable. The fix threads raw pointers through the whole release path: new Guarded::with_lock_raw(this: *const Self, f), Mutex::unlock_raw(this: *const Self), per-backend unlock_raw (SRWLOCK, os_unfair_lock, futex), and Futex::wake_raw(*const AtomicU32) for the contended-unlock tail. write_item now takes this: *const Self and publishes through with_lock_raw; send_sync_callback makes that its last statement. Two new tests: a miri model in guarded.rs (driven by test/internal/threading-guarded-miri.test.ts) and a source-lint pinning the raw-receiver shape.
Security risks
None in the direct sense — no untrusted input, auth, or crypto. The risk surface is memory safety: every backend's unlock_raw is now unsafe with a contract that the pointer is live until the releasing store, and wake_raw deliberately allows the address to be dead. A mistake here would affect every mutex user in the runtime. The changes look correct (the release is genuinely the last access on every backend; futex wakes are address-keyed on all four kernels and WASM linear memory), but this is exactly the category REVIEW.md flags as most-blocked.
Level of scrutiny
High. This is core bun_threading — the mutex/futex primitives every subsystem depends on — with platform-specific unsafe on five backends, a new pub unsafe fn on Guarded, and an FFI signature change (os_unfair_lock_unlock from safe fn(&UnsafeCell<_>) to fn(*mut _)). The reasoning is subtle (protectors, dereferenceable, wake-after-free being kernel-benign) and the PR description states it is natively benign today with miri as the only discriminator, so a regression would be silent. The Mutex/Futex hunks are stated to be byte-identical to #38330, which adds a merge-order/coordination dimension a human should confirm.
Other factors
The PR is thoroughly self-documented and tested (miri model on multiple seeds, source-lint with self-tests, cross-target cargo check, bun-publish/bun-audit under ASAN). I traced each concern I'd raise: the closure in write_item derefs (*this).cv while the lock is still held so that reference ends before the release; with_lock_raw's (*this).mutex.lock() autoref and the &mut Value handed to f both end before unlock_raw; the FreeBSD/WASM ptr.cast().cast_mut() correctly reproduces the *mut c_void/*mut i32 the FFI expects now that ptr is *const AtomicU32; the cfg(miri) EFAULT arm is Linux-only and does not weaken native builds. Nothing looked wrong, but the blast radius and the cross-cutting unsafe API addition put this outside what I'd approve without a human reviewer.
|
Updated 1:35 AM PT - Aug 14th, 2026
✅ @robobun, your commit b00fd0eaebf50ddfb337bbeb1f9ff734c8adcdac passed in 🧪 To try this PR locally: bunx bun-pr 38345That installs a local version of the PR into your bun-38345 --bun |
Problem
AsyncHTTP::send_sync(src/http/AsyncHTTP.rs) heap-allocates aSingleHTTPChannel, blocks inread_item(), and frees the channel on the next line.read_itemcan return the moment the HTTP thread's publish releases the channel's lock.SingleHTTPChannel::write_item(&self): lock theGuardedslot, store the result,notify_one, and release by dropping theGuardedLock. When the release lands, the HTTP thread is still insidewrite_item(&self),GuardedLock::drop, andMutex::unlock(&self), all holding references into the channel, whilesend_syncfrees it.dereferenceable. The old comment insend_sync("the callback has finished") was true of the memory accesses but not of the frames still holding references.WaitGroup::finish), which lists this instance as deliberately left out.Fix
Guarded::with_lock_raw(this: *const Self, f)(src/threading/guarded.rs): locks through the pointer, runsf(&mut Value)under the lock, and releases withMutex::unlock_raw, so the store that releases the lock is the last access to theGuarded.fis where anything that must happen before the reader may free the slot goes (here: the condvar signal), because once the release lands the slot may be gone.SingleHTTPChannel::write_itemtakesthis: *const Selfand publishes throughwith_lock_raw;send_sync_callbackpasses its rawthisand makes that call its last statement;read_item(&self)is unchanged (the owner calls it and frees only after it returns). The comments insend_syncandsend_sync_callbacknow state the real contract: the channel may be freed as soon as the publish's release lands.Mutex::unlock_raw, the per-backendunlock_rawimpls andFutex::wake_raw(src/threading/Mutex.rs, Futex.rs) are needed so that no frame belowwith_lock_rawholds a reference across the release either. These two files are byte-identical to their state in threading: finish a WaitGroup without holding a reference into it past the release #38330, so the two PRs merge cleanly in either order; whichever lands second loses those hunks on rebase. TheGuardedhelper, the http change and the tests are the only things specific to this PR.dereferenceablereason about. It is the tree's existing convention for functions whose last act lets someone else free the object (this: *mut Selfreceivers, self-receiver-reclaim.test.ts;WaitGroup::finish_rawin threading: finish a WaitGroup without holding a reference into it past the release #38330).src/threading/guarded.rsgets a crate test modelling the handoff (Guarded<Option<u32>>+Condition+ a plain field, like the channel'sresponse_buffer; writer publishes throughwith_lock_raw, reader takes the value and frees the box, then joins). Passes under Tree Borrows miri on seeds 0 to 5 and under the default Stacked Borrows (300 iterations each, about 9s), and natively (10,000 iterations). The same model with the&self+ guard writer is rejected at the reader'sBoxdrop after 8, 7, 17 and 35 iterations on seeds 0 to 3 (diagnostics below).test/internal/threading-guarded-miri.test.ts(new): runscargo miri test -p bun_threading -- guarded::under Tree Borrows and requires the model to have run. Scoped toguarded::because the crate'swait_grouptest is still rejected on main until threading: finish a WaitGroup without holding a reference into it past the release #38330 lands; it can become a crate-wide run after that. Skips where miri or the cargo workspace is unavailable, like linear-fifo.test.ts.test/internal/source-lints/handoff-publish-raw.test.ts(new): pins thatwith_lock_rawandwrite_itemtake the object asthis: *const _, release through the raw path and keep no reference binding, and thatsend_sync_callbackends at the publish; on main it reportswrite_itemtaking&self. Runs in the source-lints workflow on every PR touchingsrc/**/*.rs. Wholetest/internal/source-lints/still passes (111 tests).bun bd test test/cli/install/bun-publish.test.ts --timeout 60000(39 pass; every publish is asend_syncround trip, against Verdaccio and the mock registries, under the ASAN debug build) andbun-audit.test.ts(17 pass). Without--timeoutthe three lifecycle-script publish tests exceed bun test's local 5s default on the debug build (6.5s: each of the six scripts starts a debug bun) and take the rest of the file down with them; that is independent of this change, and CI's runner passes a larger per-test timeout.cargo check -p bun_threading -p bun_http --testson x86_64-pc-windows-msvc, aarch64-apple-darwin, x86_64-unknown-linux-musl and x86_64-unknown-freebsd,cargo check --releaseon the host (theReleaseImpl-direct path),cargo clippy --no-depson both crates,cargo test -p bun_threading,cargo fmt --check.bun_threading::Channelinprefetch_remote_images(src/runtime/cli/run_command.rs), andsend_sync_callbackwritingerr/elapsedback into the caller'sAsyncHTTPwhilesend_sync(&mut self)is on the stack. The s3download_streaminstance from threading: finish a WaitGroup without holding a reference into it past the release #38330's list uses a bareMutexand needsunlock_rawmadepub, which is a one-word change on top of either PR.Background
bun run rust:miriuses), a reference passed as a function argument,&selfincluded, is protected until that function returns: the callee may assume the memory stays valid, and freeing memory a protected reference covers is undefined behaviour even if the callee never touches it again. This is what lets rustc emitdereferenceablefor reference arguments. A raw pointer argument makes no such claim, which is why a function whose release is what lets another thread free the object takes*const Self.response_bufferin the channel) and the mutex's padding bytes, and it is the deallocation that is rejected, not a read or write.Guarded<T>isbun_threading's mutex-plus-value;lock()returns aGuardedLockwhoseDropunlocks through a&Mutex.Condition::wait_guardedunlocks and re-locks that same mutex around the wait, which is whyread_itemcannot observe the value before the writer's release, and why the release is the exact point after which the channel may be freed.unlock_rawissues after the releasing store is harmless on a freed word; miri models it asEFAULT, which the Linux backend now ignores undercfg(miri)only (from threading: finish a WaitGroup without holding a reference into it past the release #38330's hunk).miri diagnostics for the by-reference writer in the same model
The model test with the writer as
fn write(&self, v) { let mut g = self.slot.lock(); *g = Some(v); self.cv.notify_one(); },MIRIFLAGS=-Zmiri-tree-borrows cargo miri test -p bun_threading, fails at iteration 8, 7, 17 and 35 for-Zmiri-seed=0..3. Depending on the interleaving the protected tag it names is the&selfof the writer or the&Mutexof the unlock; one run, condensed:The fixed test does not reach the contended wake-after-free tail in 300 iterations (checked by instrumenting the
cfg(miri)EFAULT arm); that tail is exercised by #38330'swait_grouptest.