s3: reach the streaming download task through its raw pointer on both threads - #38351
s3: reach the streaming download task through its raw pointer on both threads#38351robobun wants to merge 2 commits into
Conversation
…t the release wait() returning lets the owner free the group, so the thread that finishes the last task may not touch the group after the store that lets wait() return. #34458 moved the last real access before that store, but finish(), Mutex::unlock() and the unlock's Futex::wake() all still took the group (or its mutex word) by reference, which asserts the memory for the whole call; Miri rejects the waiter's free under both aliasing models, and in the contended case the unlock tail really does form &self.state after the waiter may have freed it. WaitGroup::finish_raw(this: *const Self) does the work through raw pointers; its last access is Mutex::unlock_raw's releasing store, and the contended-path wake goes through Futex::wake_raw, which takes the word by address because every backend's wake side only uses the address as a key. finish(&self) stays for groups something else keeps alive (ThreadPool joins its workers before it drops, the Windows install queue is a static) and delegates to finish_raw; unlock(&self) delegates to unlock_raw; the per-OS unlock impls take the pointer as well, and Bun__unlock uses them directly. The os_unfair_lock_unlock extern takes the address for the same reason. LinkerContext's two source-map groups are the callers whose waiter frees right after wait() (the task slab in generate_chunks_in_parallel, the whole BundleV2 on the Bun.build error path); they use finish_raw. The crate's WaitGroup test now finishes through finish_raw and runs 500 iterations under Miri (the unfixed shape fails within 2..200 iterations on each of 12 seeds). Miri answers a FUTEX_WAKE on a freed word with EFAULT, which the Linux backend tolerates under cfg(miri) only; a real kernel never reports that for a private wake. bun_threading joins MIRI_CRATES, and test/internal/threading-miri.test.ts runs the crate under miri the same way.
|
Status: analysis-based fix (no native repro exists; the shape is rejected by Miri on a reduction, see the description). Fail-before / pass-after is |
… threads S3HttpDownloadStreamingTask is used by the HTTP thread and the JS thread at the same time for its whole life. Its &mut self methods (process_http_callback and update_state on the HTTP thread, report_progress on the JS thread) each asserted the whole task, mutex word included, for the duration of the call while the other thread may be taking the lock, and process_http_callback's receiver and lock guard were still live when its final unlock let the JS thread free the task. Convert those methods to this: *mut Self functions that form references one field or one call at a time, fold release_portable into Drop (which runs once the HTTP thread is done), and make both HTTP-thread critical sections end in Mutex::unlock_raw, which becomes pub for this, so the releasing store is the HTTP thread's last access to the task. A source lint bans &mut self receivers in the task's impl block and guard / receiver spellings of the mutex in the s3 directory.
There was a problem hiding this comment.
Beyond the inline nit, I traced the new should_enqueue && chain in process_http_callback against the old early-return ladder: the three conditions are equivalent (is_done || !buffer.is_empty() is the De Morgan of the old empty-chunk guard, and compare_exchange(false, true, ..) can only fail with Err(true), so .is_ok() matches the old Err-then-check-flag path exactly), and the exchange still short-circuits away under the same conditions as before, so has_schedule_callback is never set without a post. body_into still runs before the emptiness check.
Extended reasoning...
The one reported finding is a doc-comment filename typo. The substantive risk in this diff was the control-flow reshaping at the end of process_http_callback; I verified it is behavior-preserving (same short-circuit order, same side-effect gating on the atomic exchange). The rest of the change is a mechanical &mut self → this: *mut Self conversion plus lock_guard() → lock()/unlock_raw(), whose soundness argument is well-documented in the PR and the type docs. Given this is unsafe cross-thread lifetime code reasoning about Tree Borrows protected tags, and is stacked on an unlanded PR, a human look is still appropriate — not approving.
ad61deb to
59bec16
Compare
|
Updated 2:05 AM PT - Aug 14th, 2026
❌ @robobun, your commit 59bec16 has some failures in 🧪 To try this PR locally: bunx bun-pr 38351That installs a local version of the PR into your bun-38351 --bun |
|
Thanks. The stale lint filename in the type docs is fixed in 59bec16 (it now names |
There was a problem hiding this comment.
I reviewed this PR (including the 59bec16 doc-comment fix) and didn't find any bugs. Because it's a soundness change to cross-thread raw-pointer access and mutex-release ordering — and is stacked on #38330 which hasn't landed yet — a human look is still worthwhile.
What was reviewed:
- The
process_http_callback&&-chain is behaviour-equivalent to the old early-return ladder:(is_done || !is_empty)is the negation of the old guard, andcompare_exchange(false, ..).is_ok()is equivalent because theErrarm always carriestruefor anAtomicBool; short-circuiting still gates the exchange. - The remaining
&selfcalls (get_state,set_state,(*this).mutex.lock()) all return beforeunlock_raw, so no protected reference into the task outlives the release on the HTTP thread. release_portablewas only called fromDrop; folding it in removes the last&mut selfin the inherent impl without changing drop order.- The lint's regexes were checked against the allowed/banned snippet lists and the actual patterns in the fixed file.
Extended reasoning...
Overview
This PR converts three &mut self methods on S3HttpDownloadStreamingTask (report_progress, update_state, process_http_callback) to unsafe fn(this: *mut Self, ..) and replaces two lock_guard() critical sections with explicit lock() / Mutex::unlock_raw(&raw const ..) pairs. The rationale is that a &mut self receiver is a protected exclusive borrow of the whole task under Tree Borrows, and the other thread routinely writes the mutex word (every lock() attempt) and, on the final chunk, may free the task while the HTTP thread is still inside MutexGuard::drop -> Mutex::unlock(&self). It also widens Mutex::unlock_raw from pub(crate) to pub (the workspace denies unreachable_pub, so pub here just means "used from another crate"), folds release_portable into Drop, and adds a source-lint test that bans &mut self receivers and non-raw mutex spellings for this type.
Security risks
None. This is an internal aliasing-model soundness refactor with no user-facing surface, no parsing of untrusted input, and no auth/crypto path. Native behaviour is claimed unchanged ("natively benign today"); the diff bears that out — same lock, same state transitions, same body_into, same compare_exchange and posts.
Level of scrutiny
High. This is exactly the category the repo review guide flags hardest: cross-thread lifetime, raw pointers, and "the release is what lets the other thread free the owner" ordering. The reasoning is very thorough and I did not find a flaw, but the correctness argument depends on subtle Tree Borrows semantics (protected-tag lifetimes, interior-mutability carve-outs for & vs &mut, futex-wake-by-address) that a maintainer who owns bun_threading / the S3 client should sign off on. It is also stacked on #38330, so the merge order matters.
Other factors
- I traced the
process_http_callbackrefactor line by line: the old code returnedtrueiff(!wait_until_done || is_done)AND NOT(is_empty && !is_done)AND the exchange did not reportErr(true). Since a failedcompare_exchange(false, ..)on anAtomicBoolcan only reportErr(true),.is_ok()is exactly the same decision, and the new&&chain preserves the short-circuit that keeps the exchange from running (and setting the flag) when we would not post. - The remaining reference-forming calls under the lock (
(*this).get_state(),(*this).set_state(..), the autoref&Mutexin(*this).mutex.lock()) all complete and drop their protected references beforeunlock_raw, so nothing on the HTTP thread holds a reference into the task across the release. On the JS side,report_progressno longer holds a whole-task&mutwhile the HTTP thread hammers the mutex word or while the callback re-enters viaon_stream_cancelled. - The prior review's only finding (stale lint filename in the type doc) is fixed in 59bec16 and confirmed present in the current diff.
- The source-lint test guards against regression and self-checks its patterns against banned/allowed snippets plus non-vacuity checks (impl block present,
mutex: Mutexfield present, at least one.mutex.lock(present), which matches the repo's "prove the test can fail" convention.
Given the subject matter and the stacked dependency, I'm deferring rather than approving.
|
Noted, nothing further to change from this round. On the merge order: this PR needs |
Stacked on #38330: this PR needs its
Mutex::unlock_raw, so its base is that PR's branch and the diff here is only this PR's own change (3 files). It should land after #38330 (or after #38345, which carries the sameMutex.rs/Futex.rshunks); once one of them is in I will rebase this onto main.Problem
S3HttpDownloadStreamingTask(src/runtime/webcore/s3/download_stream.rs) is one heap object used by two threads at once for its whole life: the HTTP thread records each chunk into it under the task'smutex(http_callback->process_http_callback->update_state), and the JS thread reports the chunks under the same mutex (on_response->report_progress) and frees the task once it has readhas_more == false.&mut selfmethods:process_http_callbackandupdate_state(HTTP thread) andreport_progress(JS thread). A&mut selfargument is a protected exclusive borrow of the whole task, the mutex word and the atomics included, for the duration of the call (Background). The other thread writes those words whenever it tries to take the lock, which is routine:on_responselocks while the HTTP thread is insideprocess_http_callback, and the HTTP thread locks whilereport_progressis running the chunk callback. Under Tree Borrows the first such access during the call is undefined behaviour whether or not anything is freed; a reduction of the shape is rejected by Miri at the other thread'slock()(read access through <tag> ... is forbidden ... foreign to the protected tagcreated at the&mut self; full diagnostics below).process_http_callbackhad a second problem on the final chunk. When a task is already queued (has_schedule_callbackset), the final result posts nothing; the JS thread may be blocked inon_response'slock(), and the moment the HTTP thread releases the lock it readshas_more == falseand frees the task. At that point the HTTP thread was still insideprocess_http_callback(&mut self)and insideMutexGuard::drop->Mutex::unlock(&self), i.e. the freed task was still covered by protected references (&mut self, and&Mutexinto it). The comment on the guard claimed it "drops strictly before the task can be freed by the main thread", which is the ordering that does not hold. The guard alone is enough for Miri to reject the free (deallocation through <tag> ... is forbidden, protected tag created atMutex::unlock(&self)), same class as 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.release_at_shutdownused the same guard shape.Fix
process_http_callback,update_stateandreport_progressbecomeunsafe fn f(this: *mut Self, ..)and reach the task through(*this).field, so every reference they form covers one field for one call (&mut (*this).reported_response_buffer.listforbody_into, the&AtomicBoolfor the exchange, and so on); none of those fields is touched by the other thread while we hold the lock, and the words the other thread does touch (the mutex,signal_store) are never inside a reference we hold. The&selfhelpersget_state/set_statestay: a shared reference leaves the interior-mutable words to the other thread, and the remaining fields are only written by whoever holds the lock.process_http_callback,release_at_shutdown) are now(*this).mutex.lock(); ...; Mutex::unlock_raw(&raw const (*this).mutex);, theWaitGroup::finish_rawshape from threading: finish a WaitGroup without holding a reference into it past the release #38330, so the releasing store is the HTTP thread's last access to the task.release_at_shutdowncannot race a free today (by the time the HTTP thread runs it, the task's JS thread is parked inshutdown_for_exitor has been joined); it is converted so that the HTTP-thread side of this mutex has one shape that does not depend on that teardown ordering, and so the lint below holds without exceptions.Mutex::unlock_rawbecomespub(it ispub(crate)in threading: finish a WaitGroup without holding a reference into it past the release #38330; the workspace deniesunreachable_pub, sopubis exactly "has a user in another crate"), with a doc note on what the caller's own frames have to look like.release_portable(only called fromDrop) is folded intoDrop, so the inherent impl has no&mut selfmethods left;Dropitself runs after the JS thread has read the final state, i.e. after the HTTP thread's last access, so its&mut selfis fine and is documented as such.body_into, same exchange and posts. The early-return ladder at the end ofprocess_http_callbackis now one&&chain with the same short-circuiting (the exchange still only runs when the old code ran it; setting the flag without posting would stall the stream, which the comment there now says). TheErr(prev)arm of the old code was unconditional in practice (a failed exchange fromfalsealways reportstrue), so.is_ok()is the same decision, and the same spellingrelease_at_shutdownalready used.dereferenceableon reference arguments reason about. It is the tree's existing convention for objects another thread (or a re-entrant callback) can touch during the call:this: *mut Selfreceivers (src/CLAUDE.md, "Pointer provenance at FFI boundaries",borrow = ptr; self-receiver-reclaim.test.ts), andfinish_raw/unlock_rawin threading: finish a WaitGroup without holding a reference into it past the release #38330. The type docs on the struct now state the rule.test/internal/source-lints/s3-download-task-raw-access.test.tsbans&mut selfreceivers insideimpl S3HttpDownloadStreamingTaskand the two non-raw spellings of the mutex (.mutex.lock_guard()in any spelling, andself.mutex.lock()/unlock()/try_lock()) in src/runtime/webcore/s3/, checks its own patterns against banned and allowed snippets, and checks the scanned file still has the impl block, themutex: Mutexfield and a.mutex.lock(so it cannot pass vacuously. Against the unfixed source it reports exactlyreport_progress,update_state,process_http_callback,release_portableand the twolock_guard()sections; with this change it passes. Runs in the source-lints workflow on every PR touchingsrc/**/*.rs; the wholetest/internal/source-lints/directory still passes (103 tests).bun_threading's tests (Tree Borrows, 400 iterations each):&mut self+ guard is rejected with and without the free; raw pointer + guard is rejected when the other thread frees; raw pointer +unlock_raw(this PR's shape) is clean, with the other thread freeing the box right after the release in 336 of the 400 iterations. The reduction is not committed: it would test a copy of the protocol, not download_stream.rs, which the lint covers; threading: finish a WaitGroup without holding a reference into it past the release #38330's crate test already coversunlock_rawitself.S3Client.file(..).stream()downloads per round, 200 rounds, over a 512 KiB body, an empty body, a 64-chunk trickled body, a 404 error document and a trickled 500, half of them with a deliberately busy consumer) completes clean; a temporary counter confirmed 274 of 800 downloads in a 100-round run took the "final result with a task already queued" path, the one where the unlock is what frees the task.bun bd teston the local-server S3 files (s3-stream-cancel-leak, s3-stream-error-gc, s3-insecure, s3-requester-pays, s3-storage-class, s3-argument-validation, s3-fd-validation, s3-list-checksum-algorithm, s3-connection-close): pass. s3-list-objects' "fall back to NoSuchKey" case exceeds the 5 s local timeout when the whole file runs on the debug build here, identically on the base build without this change (it passes alone); it uses the non-streaming request type. The credentialed s3.test.ts / s3.leak.test.ts streaming coverage runs in CI. (Locally the S3 tests needHTTP_PROXYunset in this container; S3 requests do not honourNO_PROXYyet, Make S3 requests honor NO_PROXY and HTTPS_PROXY like fetch #32046.)cargo checkandcargo clippy --no-depsonbun_threadingandbun_runtime,cargo fmt --check;test/internal/threading-miri.test.tsfrom threading: finish a WaitGroup without holding a reference into it past the release #38330 still passes with the visibility change.(*this).loop_handle.post_task(..)inhttp_callback, which holds a&LoopHandleinto the task while the JS thread may free it once the task is queued, is s3: post the streaming download's final task through a copy of the handle #37880 (the two compose; this PR leaves those lines alone apart from the comments next to them). The siblingS3HttpSimpleTask::stage_http_result(&mut self), which only races the teardown-timestop_for_vm_teardownstore, and both types'stop_for_vm_teardownreadinghttpwhile the HTTP thread overwrites it, are reported separately; they do not fit this type's unconditional rule.Background
bun run rust:miriuses) a reference passed as a function argument is protected until that function returns: the callee may assume nothing else accesses the memory it covers (for&mut, no other access at all; for&, no writes to the non-interior-mutable bytes) and that it is not freed, even if the callee never touches it again. rustc relies on the same thing to mark reference argumentsdereferenceablefor LLVM. A raw pointer argument asserts nothing, and a reference to a single field covers only that field, which is why the fixed code takes*mut Selfand forms field references per access.&vs&mut: atomics and the mutex word areUnsafeCell-based, so a shared reference to the task tolerates the other thread's writes to them; an exclusive reference does not, because&mutclaims every byte regardless of interior mutability. That is the difference between the&selfhelpers that stay and the&mut selfmethods that go.Mutex::unlock_raw(threading: finish a WaitGroup without holding a reference into it past the release #38330): releases through a raw pointer so that the store which releases the lock is the last access to the mutex's memory, and the futex wake that may follow goes by address only.unlock(&self)andMutexGuard::dropboth hold a&Mutexuntil they return, so they cannot end a critical section whose release lets another thread free the owner.concurrent_taskto the JS thread only if no post is outstanding (has_schedule_callbackexchange).on_responseon the JS thread locks, reads the state, reports, unlocks, and frees the task if the state was final. So a final result arriving while a post is outstanding is delivered purely by the unlock: the JS thread is, or will be, waiting on the mutex and frees the task as soon as it gets it.Miri reduction (temporarily appended to src/threading/Mutex.rs on top of #38330) and its results
Three writer shapes over a boxed
Task { mutex: Mutex, has_more: AtomicBool, _payload: [u8; 64] }; the reader thread (standing in foron_response) is told to go once the writer thread starts, locks, readshas_more, unlocks, and either frees the box when it saw the final state or leaves it to the main thread:bun run rust:miri -p bun_threading -- probe_(Tree Borrows, 400 iterations per case):&mut self+ guardread access through <tag> at alloc[0x40] is forbiddenat Mutex.rsDebugImpl::lock(the reader'slock()),the accessed tag is foreign to the protected tagcreated atfn receiver_guard(&mut self)&mut self+ guarddeallocation through <tag> at alloc[0x4c] is forbidden, protected tag created atpub fn unlock(&self)(the guard's drop)unlock_rawAn earlier run of the first case with the reader's lock attempt landing as a write was reported as
write access through <tag> at alloc[0x48] is forbidden ... this foreign write access would cause the protected tag (currently Reserved (conflicted)) to become Disabled; protected tags must never be Disabled, atFutexImpl::try_lock'sfetch_or. Natively (cargo test) all five cases pass, which is the "benign today" part of the Problem section.