threading: publish into a Channel without holding a reference into it past the release - #38358
threading: publish into a Channel without holding a reference into it past the release#38358robobun wants to merge 1 commit into
Conversation
… past the release Channel::write_item(&self) kept &self (and, through the MutexGuard, &Mutex) alive until after the store that lets a blocked read_item return. prefetch_remote_images reads from a channel on its own stack and returns, ending the channel's storage, as soon as the last download's tick arrives, so the HTTP thread's publish of that tick could still be holding those references when the storage died. Add Channel::write_item_raw(this: *const Self, item), whose last access to the channel is the releasing store inside Mutex::unlock_raw; write_item delegates to it. RemoteImageDownload::on_done publishes through it as its last statement. Mutex::unlock_raw / Futex::wake_raw are the same hunks as in the WaitGroup change (#38330). The crate test models the caller (reader frees each channel as soon as it has its items) and is rejected by miri for the &self shape; threading-channel-miri.test.ts runs it under Tree Borrows. The markdown test covers more downloads than the channel has slots.
|
Warning Review limit reached
Next review available in: 40 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: ready for review. Reproduced with the model test this PR adds to src/threading/channel.rs, run against main's Overlap: src/threading/Mutex.rs and Futex.rs are byte-identical to #38330 and #38345 (whichever lands later drops those hunks on rebase); #38351, stacked on #38330, additionally makes |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it rewires the unlock path of Mutex and Futex::wake across every platform backend and threads raw-pointer receivers through the core Channel publish, a human look is still worthwhile.
Checked: write_all/write_items have no remaining crate callers; LinearFifo::write_item exists and matches the new error branching; on_done's last access to *this (reading this.done) precedes the publish and the local &mut carries no protector; write_item_locked's &self and its putters.wait(&self.mutex) reference both end before unlock_raw while the item is unpublished.
Extended reasoning...
Overview
This PR fixes a Stacked/Tree Borrows protector violation in bun_threading::Channel: RemoteImageDownload::on_done publishes into a DoneChannel that lives on the reading thread's stack, and the reader may drop that stack frame as soon as read_item returns — while the writer's write_item(&self) (and the write_all/write_items/MutexGuard::drop frames beneath it) still hold protected &self references into the channel. The fix adds Channel::write_item_raw(this: *const Self), Mutex::unlock_raw, and Futex::wake_raw, so no reference is protected across the releasing store; the critical section (write_item_locked(&self)) forms a &Self that dies before unlock_raw. The dead write_all/write_items slice path is removed. on_done now calls DoneChannel::write_item_raw(this.done, 0) as its last statement. Tests: a miri model test in channel.rs, a bun-level miri driver in test/internal/, and a 300-image markdown-entrypoint test that exercises the full-buffer wait path.
Security risks
None identified. This is an aliasing-model correctness fix in threading primitives; no auth, crypto, or untrusted-input parsing is touched. The runtime behavior (lock → push → signal → unlock) is unchanged; only which pointer tags are live across the release changes.
Level of scrutiny
High. Mutex::unlock() now routes through unlock_raw on every platform (SRWLOCK, os_unfair_lock, futex, FreeBSD umtx, wasm), and os_unfair_lock_unlock's extern signature changed from safe fn(&UnsafeCell<..>) to unsafe fn(*mut ..). These are the most-used sync primitives in the runtime, and per-platform cfg-gated code is only type-checked on its own target. The PR description says these Mutex/Futex hunks are byte-identical to #38330 and #38345, so cross-checking those is worthwhile. The reasoning is very carefully documented and matches the tree's established *mut Self receiver convention (self-receiver-reclaim source lint, UnboundedQueue::push_raw), and the miri seed tables show the model failing on main and passing here.
Other factors
- Verified
write_all/write_itemswerepub(crate)with no other in-crate callers, so their removal is safe.LinearFifo::write_itemexists (linear_fifo.rs:492) and returnsResult<(), AllocError>, matching theB::DYNAMICbranch. - Checked
on_done: the locallet this = &mut *thisis not a function argument, so it carries no protector; its last use is the field readthis.doneevaluated beforewrite_item_rawruns. Theresultparameter dropped after the unsafe block owns its own state and doesn't reference the channel or the box. - Checked
write_item_locked:putters.wait(&self.mutex)releases the mutex mid-call, but the item is unpublished at that point so the channel is contractually live; the&selfprotector onwrite_item_lockedends beforeunlock_rawruns. - The
unlock_rawdoc comment referencesWaitGroup::finish_raw, which per the description is added in #38330 (not yet landed) — a forward reference the author calls out explicitly. - Test coverage is thorough: miri model with seed sweep, native 32k-channel stress, plus an integration test that overflows the 256-slot static buffer. The miri driver test correctly guards on toolchain availability and asserts the model actually ran.
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Not a duplicate of any of the three; they are sibling instances of one bug class, each fixing a different primitive or caller, and none of them touches
What is specific to this PR: |
|
Updated 1:05 AM PT - Aug 14th, 2026
❌ @robobun, your commit 0d632d9 has some failures in 🧪 To try this PR locally: bunx bun-pr 38358That installs a local version of the PR into your bun-38358 --bun |
Problem
RunCommand::prefetch_remote_images(src/runtime/cli/run_command.rs) keeps its done-channel (DoneChannel, abun_threading::Channel) as a local, blocks indone_channel.read_item()once per download, and returns as soon as the last tick arrives, which ends the channel's storage and drops theRemoteImageDownloadboxes.RemoteImageDownload::on_doneas(*this.done).write_item(0).read_itemcan return the moment that publish's mutex release lands, and at that moment the HTTP thread is still insidewrite_item(&self),write_all(&self),write_items(&self)and theMutexGuarddrop'sMutex::unlock(&self), all of which hold references into the channel.dereferenceable. Natively nothing observable happens today: after the releasing store the only work left is those frames returning, plus an address-keyed futex wake when the unlock was contended. So there is no bun-level repro; the discriminator is miri on a model of the handoff.read_itemhas handed it the items), run against main'sChannelwith the writer usingwrite_item(&self)(the only publish main has), is rejected at the reader'sBoxdrop: Tree BorrowsUndefined Behavior: deallocation through <tag> at alloc[0xc] is forbidden, pointing at the end ofwrite_all(&self); Stacked Borrowsnot granting access to tag <..> because that would remove [SharedReadOnly for <..>] which is strongly protected, namingwrite_all's&self. Every seed tried fails within the first half of a run (16 Tree Borrows seeds, 4 Stacked Borrows seeds; table below).WaitGroup::finish) and http: publish send_sync's result without holding a reference into the channel past the release #38345 (SingleHTTPChannel::write_iteminsend_sync), which lists thisChannelinstance as tracked separately.bun_threading::Channelhas no other in-tree user.Fix
Channel::write_item_raw(this: *const Self, item): locks through the pointer, runs the critical section (write_item_locked(&self): closed check, push,getters.signal(), or wait onputterswhile the buffer is full), then releases withMutex::unlock_raw. The&Selfformed for the critical section ends before the unlock; the releasing store insideunlock_rawis the thread's last access to the channel.write_item(&self)delegates to it and its doc says when&selfis still fine (a channel that something other than the matching read keeps alive).write_allandwrite_items(a slice path whose only caller passed one item) are folded into the single-item function; the read side is unchanged.on_donepublishes withDoneChannel::write_item_raw(this.done, 0)as its last statement. After the terminal callback the HTTP thread only touches its own threadlocal allocation and statics (on_async_http_callback_raw), and theresultdropped after the publish owns everything it frees, so the publish's release really is the last access to anything the main thread frees.Mutex::unlock_raw, the per-backend impls,Futex::wake_raw) are byte-identical to threading: finish a WaitGroup without holding a reference into it past the release #38330 and http: publish send_sync's result without holding a reference into the channel past the release #38345, so those merge with this in any order and whichever lands later just loses the hunks on rebase. s3: reach the streaming download task through its raw pointer on both threads #38351 (the s3 instance, stacked on threading: finish a WaitGroup without holding a reference into it past the release #38330) additionally makesunlock_rawpub; against it this PR differs in that one block of Mutex.rs, resolved by keepingpub(this PR only needspub(crate)). (unlock_raw's doc namesWaitGroup::finish_raw, which threading: finish a WaitGroup without holding a reference into it past the release #38330 adds.) The hunks are needed here for the same reason as there: without them the frame belowwrite_item_rawwould hold a&Mutexacross the release.read_item), only which pointers are live across the release changes, and that is exactly what the aliasing models anddereferenceablereason about. It is the tree's existing convention for functions whose last act lets another thread free the object (this: *mut Selfreceivers, test/internal/source-lints/self-receiver-reclaim.test.ts;WaitGroup::finish_rawin threading: finish a WaitGroup without holding a reference into it past the release #38330;UnboundedQueue::push_rawin event_loop: post to a MiniEventLoop through raw pointers and publish last #37883).channel::tests::reader_may_free_a_channel_once_it_has_the_items(src/threading/channel.rs): per batch one writer thread publishes two items into each of 32 one-slot channels and the reader frees each channel right after its secondread_item; the one-slot buffer makes the writer wait for the reader on every channel, so every channel is a real chance for the free to race the publishing write's return (with one item per channel the writer runs ahead and the model only probes the first channel of each thread). 32 batches under miri (about 18s): passes on Tree Borrows seeds 0 to 11 and Stacked Borrows seeds 0 to 3; natively 1000 batches in 0.3s.items_arrive_in_order_through_a_full_buffercoverswrite_item(&self)and the full-buffer wait path the two functions share.cargo miri test -p bun_threading -- channel::under Tree Borrows, asrust:miridoes, and requires the model to have run. Fails on main'ssrc/(no model there; the model itself cannot be written against main's API other than in the&selfshape shown above), passes with this change (23s underbun bd testhere). Scoped tochannel::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; same structure as http: publish send_sync's result without holding a reference into the channel past the release #38345's guarded test, and both can fold into a crate-wide run after that.cargo check -p bun_threading --testson x86_64-pc-windows-msvc, aarch64-apple-darwin, x86_64-unknown-freebsd and aarch64-unknown-linux-musl,--release(theReleaseImpl-direct path) on linux, windows and darwin,cargo check -p bun_runtime,cargo clippy -p bun_threading --no-deps(with--teststhe only finding is main's pre-existingstd::thread::spawnin thewait_grouptest, which threading: finish a WaitGroup without holding a reference into it past the release #38330 replaces),cargo fmt --check,bun bd test test/internal/source-lints/(98 pass).cfg(miri)EFAULTarm: the fixed model did not reach the contended wake-after-free tail in 6 runs (6144 channels); threading: finish a WaitGroup without holding a reference into it past the release #38330's test is the one that exercises that tail.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 claims nothing, which is why a function whose release is what lets another thread free the object takes*const Self.&Channelalso covers the padding between its fields (the0xcin the diagnostic is inside the mutex), and it is the deallocation that is rejected, not a read or write.bun_threading::Channelis a mutex, two condition variables (getters,putters) and aLinearFifo.read_itemwaits ongetters, and a condition-variable wait re-acquires the mutex before returning, so a blockedread_itemcannot return before the publisher's unlock; the store inside that unlock is the exact point after which the channel may be gone, and everything the publisher needs the channel for (the push and the signal) happens before it.unlock_rawissues after the releasing store is harmless on a freed word; miri models it asEFAULT, which the Linux backend ignores undercfg(miri)only (part of the shared hunk).miri diagnostics against main's Channel, and the seed tables
Model test appended to main's src/threading/channel.rs with the writer calling
(*channel).write_item(..),cargo miri test -p bun_threading -- channel::.Tree Borrows (
MIRIFLAGS=-Zmiri-tree-borrows), default seed:Other interleavings report the same free as
the accessed tag is foreign to the protected tag <..> (currently Frozen) ... protected tags must never be Disabled, with the protected tag beingwrite_all's&self(channel.rs:80).Stacked Borrows (
MIRIFLAGS=""):Batch (of 32, 32 channels each) in which the
&selfshape is rejected, by-Zmiri-seed:2, 0, 4, 2, 0, 0, 2, 5, 12, 5, 2, 16, 0, 2, 7, 02, 0, 4, 2The fixed shape completes all 32 batches with no report on Tree Borrows seeds 0..11 and Stacked Borrows seeds 0..3 (about 17.7s of interpretation each).