fetch: release the FetchTasklet through its raw pointer, not a &mut receiver
Problem
- Every
fetch()ends with one final progress hop on the JS thread. That hop releases the tasklet's JS-side ref, normally its last, so the release frees the tasklet. The release was made from inside a&mut selfmethod, through a pointer spelled from the receiver. - Freeing an allocation while a
&mutparameter to it is still live is undefined behaviour under both of Rust's aliasing models, whether or not the reference is touched afterwards. A reduction of the shape fails under Miri withUndefined Behavior: deallocation through <1642> at alloc786[0x0] is forbidden(Tree Borrows) anddeallocating while item [Unique for <1663>] is strongly protected(Stacked Borrows). - The function that ends a streamed request body had the same shape. Its release is the last ref when the response finishes before the upload does, and it is reached both from the stream's completion promise and from the body sink.
- No crash is known from this today; the contract is what is wrong. The file's other releasing entry points already take a raw pointer for this reason.
Fix
- Each JS-thread entry point that owns a ref now receives the raw allocation pointer, adopts the ref into a guard before any reference to the tasklet exists, and runs the old
&mutbody through a borrow scoped to that call. The guard releases when it drops, after the borrow is gone, so no reference is live at the free. - The tasklet's raw release function is deleted, so a
&mut selfmethod on this type has nothing left to misuse. The HTTP thread's release stays, since it posts the free to the JS thread instead of freeing in place. - The two pointers kept past the final hop for the body's later release (the sink's back-pointer and the promise context) are now made from the allocation pointer, not from the hop's borrow, because a pointer made from the borrow is dead by the time those releases run. Releases inside the hop's own frame still go through
self; the hop's guard holds a ref throughout, so none of them can be the last. - Behaviour is unchanged: releases move, none are added or removed, and each guard drops where the explicit release used to run. Making
&mut selfmethods on this type safe to re-enter is out of scope (#31745). The same shape in other files is allowlisted by the new lint, per file at its exact count. - Verification: the new source lint reports exactly the three sites on main and passes on this branch. The fetch suites, including streamed request bodies, pass on the ASAN build, and a script that streams uploads to a server which answers before the upload finishes (the ordering where the body release is the last ref) ran 20 rounds with GC between rounds without an ASAN report. The undefined behaviour itself is shown only by the standalone Miri reductions in the original below, not by a test in bun.
Background
FetchTaskletis the per-fetch()object shared by the HTTP thread and the JS thread. It is a heap allocation held by raw pointer with an intrusive refcount; the release that takes the count to zero frees it.- A progress hop is a task the HTTP thread posts to the JS thread whenever a fetch makes progress. The final hop owns the JS-side ref taken when the fetch was created and is responsible for releasing it.
- Under Rust's aliasing models (Stacked Borrows and Tree Borrows, which
bun run rust:mirichecks), a reference passed as a function parameter is protected for the whole call: freeing its allocation during the call is undefined behaviour even if nothing reads it afterwards. A raw pointer parameter carries no such protection. - Provenance: a raw pointer derived from a
&mutborrow is only valid while that borrow is. Once the allocation is used through another path, releasing through the stale pointer is also undefined behaviour, so anything stored past the current frame must come from the original allocation pointer. bun_ptr::ScopedRef::adoptwraps a ref the caller already holds in a guard that releases it, through its own pointer, on drop. One other task arm in the dispatcher already releases this way.
[review] gate passed · iteration 1 · 5 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-release.test.ts
bun test v1.4.0 (939b42e00)
test/internal/source-lints/self-receiver-release.test.ts:
(pass) scans a non-empty set of tracked Rust sources [2.20ms]
(pass) the patterns match the banned spellings and nothing else [16.71ms]
248 | expect(banned.map(s => findReleases(s).length)).toEqual(banned.map(() => 1));
249 | expect(allowed.map(s => findReleases(s).length)).toEqual(allowed.map(() => 0));
250 | });
251 |
252 | test("refcount release through a pointer spelled from the receiver is banned", () => {
253 | expect(offenders).toEqual([]);
^
error: expect(received).toEqual(expected)
- []
+ [
+ "src/runtime/webcore/fetch/FetchTasklet.rs:937",
+ "src/runtime/webcore/fetch/FetchTasklet.rs:956",
+ "src/runtime/webcore/fetch/FetchTasklet.rs:2286",
+ ]
- Expected - 1
+ Received + 5
at <anonymous> (/workspace/bun/test/internal/source-lints/self-receiver-release.test.ts:253:21)
(fail) refcount release through a pointer spelled from the receiver
... (truncated)
release without fix: 1 FAILED
bun test v1.4.0-canary.1 (da3851e57)
test/internal/source-lints/self-receiver-release.test.ts:
(pass) scans a non-empty set of tracked Rust sources [0.12ms]
(pass) the patterns match the banned spellings and nothing else [0.40ms]
248 | expect(banned.map(s => findReleases(s).length)).toEqual(banned.map(() => 1));
249 | expect(allowed.map(s => findReleases(s).length)).toEqual(allowed.map(() => 0));
250 | });
251 |
252 | test("refcount release through a pointer spelled from the receiver is banned", () => {
253 | expect(offenders).toEqual([]);
^
error: expect(received).toEqual(expected)
- []
+ [
+ "src/runtime/webcore/fetch/FetchTasklet.rs:937",
+ "src/runtime/webcore/fetch/FetchTasklet.rs:956",
+ "src/runtime/webcore/fetch/FetchTasklet.rs:2286",
+ ]
- Expected - 1
+ Received + 5
at <anonymous> (/workspace/bun/test/internal/source-lints/self-receiver-release.test.ts:253:21)
(fail) refcount release through a pointer spelled from the receiver is banned [0.27ms]
(pass) allowlisted files still carry exactly their documented count [0.17ms]
3 pass
1 fail
15 expect() calls
Ran 4 tests across 1 file. [4.92s]
__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-release.test.ts
bun test v1.4.0 (939b42e00)
test/internal/source-lints/self-receiver-release.test.ts:
(pass) scans a non-empty set of tracked Rust sources [2.19ms]
(pass) the patterns match the banned spellings and nothing else [17.31ms]
(pass) refcount release through a pointer spelled from the receiver is banned [1.27ms]
(pass) allowlisted files still carry exactly their documented count [5.97ms]
4 pass
0 fail
15 expect() calls
Ran 4 tests across 1 file. [26.00s]
__F:0:S:0
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1051ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/5] gen generated_host_exports.rs
generated_host_exports.rs: 93 exports (host=3, lazy=10, generic=80, rust=0); 239 extern-C blocks audited
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)
^[[1m^[[92m Compiling^[[0m bun_install v0.0.0 (/workspace/bun/src/install)
^[[1m^[[92m Compiling^[[0m bun_jsc v0.0.0 (/workspace/bun/src/jsc)
^[[1m^[[92m Compiling^[[0m bun_ast_jsc v0.0.0 (/workspace/bun/src/ast_jsc)
^[[1m^[[92m Compiling^[[0m bun_js_parser_jsc v0.0.0 (/workspace/bun/src/js_parser_jsc)
^[[1m^[[92m Compiling^[[0m bun_patch_jsc v0.0.0 (/workspace/bun/src/patch_jsc)
^[[1m^[[92m Compiling^[[0m bun_css_jsc v0.0.0 (/workspace/bun/src/css_jsc)
^[[1m^[[92m Compiling^[[0m bun_semver_jsc v0.0.0 (/workspace/bun/src/semver_jsc)
^[[1m^[[92m Compiling^[[0m bun_sys_jsc v0.0.0 (/workspace/bun/src/sys_jsc)
^[[1m^[[92m Compiling^[[0m bun_sql_jsc v0.0.0 (/workspace/bun/src/sql_jsc)
^[[1m^[[92m Compiling
... (truncated)
diff hotspot
src/runtime/dispatch.rs | 2 +-
src/runtime/webcore.rs | 7 +-
src/runtime/webcore/fetch/FetchRequestBodySink.rs | 46 ++--
src/runtime/webcore/fetch/FetchTasklet.rs | 188 ++++++++-------
.../source-lints/self-receiver-release.test.ts | 262 +++++++++++++++++++++
5 files changed, 397 insertions(+), 108 deletions(-)
gate history · 2 passed · 0 rejected · iteration 1
evidence per changed file
file reads edits tests
src/runtime/dispatch.rs 2 2 0
src/runtime/webcore.rs 1 1 0
src/runtime/webcore/fetch/FetchRequestBodySink.rs 6 7 0
src/runtime/webcore/fetch/FetchTasklet.rs 31 43 0
test/internal/source-lints/self-receiver-release.test.ts 9 19 0
Original description
Problem
FetchTasklet::on_progress_update(&mut self) (src/runtime/webcore/fetch/FetchTasklet.rs) ended the final progress hop with
FetchTasklet::deref(std::ptr::from_mut(self));
That deref releases the JS-side ref taken in get(). callback on the HTTP thread derefs its own ref right after posting the final hop, so by the time the hop runs on the JS thread this is normally the tasklet's last ref: deref runs deinit, which heap::takes the box, while the &mut self argument (formed by cast!(FetchTasklet) in dispatch.rs) is still live. Every fetch() goes through this on its final hop.
Freeing an allocation while a reference argument to it is live is rejected by both aliasing models, independent of whether anything touches the reference afterwards. A standalone reduction of exactly this shape (a &mut self method whose trailing release drops the count to zero and frees the box) fails under Miri with Tree Borrows, the model bun run rust:miri uses, pointing at the receiver:
error: Undefined Behavior: deallocation through <1642> at alloc786[0x0] is forbidden
= help: the allocation of the accessed tag <1642> also contains the strongly protected tag <1622>
= help: the strongly protected tag <1622> disallows deallocations
help: the strongly protected tag <1622> was created here, in the initial state Reserved
| fn on_progress_update_before(&mut self) {
| ^^^^^^^^^
and under Stacked Borrows with deallocating while item [Unique for <1663>] is strongly protected. The same reduction with the release performed through the allocation pointer after the method returns passes under both. No crash is known from this today; it is the contract that is wrong, and it is the reason the file's other releasing entry points (callback, resume_request_data_stream, deref_from_thread) already take *mut and say so in their comments.
write_end_request(&mut self, err) had the same shape (let this_ptr = ptr::from_mut(self); ... FetchTasklet::deref(this_ptr) on four exits). Its release is the last ref when the response finishes before a streamed request body does: the final hop's cleanup cancels the sink and drops the JS-side ref, and the pump promise then settles into on_resolve_request_stream / on_reject_request_stream, which called (*this).write_end_request(..) through a &mut receiver. FetchRequestBodySink::end_from_stream reached it through task.get_mut() the same way (its own comment already noted the call may free the tasklet and the sink with it).
Reduction run under Miri
use std::sync::atomic::{AtomicU32, Ordering};
struct Tasklet { ref_count: AtomicU32, payload: u64 }
impl Tasklet {
fn deref(this: *mut Tasklet) {
if unsafe { (*this).ref_count.fetch_sub(1, Ordering::SeqCst) } == 1 {
drop(unsafe { Box::from_raw(this) });
}
}
// main
fn on_progress_update_before(&mut self) {
self.payload += 1;
Tasklet::deref(std::ptr::from_mut(self));
}
// the shape this PR uses (ScopedRef's Drop is the `deref` call)
fn on_progress_update_after(this: *mut Tasklet) {
unsafe { &mut *this }.tick();
Tasklet::deref(this);
}
fn tick(&mut self) { self.payload += 1; }
}
fn main() {
let t = Box::into_raw(Box::new(Tasklet { ref_count: AtomicU32::new(1), payload: 0 }));
if std::env::args().nth(1).as_deref() == Some("after") {
Tasklet::on_progress_update_after(t);
} else {
unsafe { (*t).on_progress_update_before() };
}
}
MIRIFLAGS=-Zmiri-tree-borrows cargo miri run -- before and the default (Stacked Borrows) run both report the errors quoted above; -- after exits 0 under both.
Fix
The container that rules this out is bun_ptr::ScopedRef (already used this way by the FileResponseStreamEof arm in dispatch.rs): the entry point that owns a ref receives the allocation pointer, adopts the ref into a guard before any reference to the tasklet exists, and runs the &mut body through a call-scoped reborrow; the guard releases through its own pointer when it drops, after that borrow is gone. Every JS-thread release of a tasklet ref is now such a guard, and FetchTasklet::deref, the raw release a &mut self method could reach for, is deleted, so the file cannot express the bug any more. deref_from_thread stays: it is the HTTP thread's release, and it hops the destroy to the JS thread instead of freeing in place.
on_progress_update(this: *mut FetchTasklet): takes the mutex and readsis_donethroughfrom_raw_ref, adopts the JS-side ref whenis_done(Option<ScopedRef>), and runs the former body ason_progress_update_locked(&mut self, this, is_done). The two derefs inside the body (the script-forbidden exit and thecleanupclosure) are gone; the deref was already the last thing every path did, so the order of operations is unchanged. The dispatch.rs arm passescast_ptr!(FetchTasklet)instead of forming a&mut.write_end_request(this: *mut FetchTasklet, err): adopts thestart_request_streamref and runs the former body asend_request_body(&mut self, err)(the four exit-site derefs collapse into the guard).- The two pointers
start_request_streamstores for the deferred releases, the sink's back-pointer and the promise ctx recovered byon_resolve_request_stream/on_reject_request_stream, are now made from the allocation pointer the hop passes down (BackRef::from_raw_mut(this),.then(.., this, ..)) instead of from its own&mut self. Self-review of the first version caught this: a pointer made from the hop's borrow is dead by the time those releases run (every later access through the allocation pointer, including the hop's own release, invalidates it), so the last-ref release through it was rejected by both models even after the receiver fix. The second reduction below models exactly that and passes only with the allocation pointer stashed. The releases that happen inside the frame (start_request_stream's six synchronous exits,cancel_request_body_sink) keep going throughself: they run while the hop's guard still holds the JS-side ref, so they never free, and a pointer derived from the live borrow is the right one to use inside it. The ledger for that claim is in the comments below. FetchRequestBodySink::end_from_streamtakes the sink pointer (as its siblingNetworkSink::end_from_streamalready did; theSinkHandle::endarm passesp.as_ptr()), because the release it makes can free the tasklet and, throughclear_sink, the sink itself. The JS-sideend()calls the non-releasing helper directly and debug-asserts that there was nothing to release, since only JS-pump sinks have a JS object.finalizewas converted the same way by #37716, which this branch is rebased on.resume_request_data_stream,release_unrunand the sink'sfinalizefallback adopt the ref they own the same way (the first loses the closure it used to reach a single trailing deref).
Behaviour is unchanged: the diff moves releases, it does not add or remove any, and the guards drop at exactly the points the explicit derefs used to run.
What this does not do: make &mut self methods of this type safe to re-enter (that is the &self/Cell conversion #31745 is pursuing for this type), or touch other types. The same shape exists elsewhere; see the lint's allowlist and scope note below.
Second reduction: the stashed pointer
use std::sync::atomic::{AtomicU32, Ordering};
struct Tasklet { ref_count: AtomicU32, aborted: bool, poll_ref: u32 }
struct Guard(*mut Tasklet); // ScopedRef, reduced
impl Drop for Guard {
fn drop(&mut self) {
if unsafe { (*self.0).ref_count.fetch_sub(1, Ordering::SeqCst) } == 1 {
drop(unsafe { Box::from_raw(self.0) });
}
}
}
impl Tasklet {
fn start_request_stream(&mut self, this: *mut Tasklet, stash_root: bool) -> *mut Tasklet {
self.ref_count.fetch_add(1, Ordering::SeqCst);
if stash_root { this } else { std::ptr::from_mut(self) }
}
fn on_progress_update(this: *mut Tasklet, stash_root: bool) -> *mut Tasklet {
let _js_ref = Guard(this);
let me = unsafe { &mut *this };
let stash = me.start_request_stream(this, stash_root);
me.poll_ref = 0;
stash
}
fn write_end_request(this: *mut Tasklet) {
let _body_ref = Guard(this);
unsafe { &mut *this }.end_request_body();
}
fn end_request_body(&mut self) { self.aborted = true; }
}
fn main() {
let stash_root = std::env::args().nth(1).as_deref() == Some("root");
let t = Box::into_raw(Box::new(Tasklet { ref_count: AtomicU32::new(1), aborted: false, poll_ref: 1 }));
let stash = Tasklet::on_progress_update(t, stash_root);
Tasklet::write_end_request(stash); // the pump promise settling later
}
Stashing from_mut(self) (the first version of this PR, and what main does): Tree Borrows reports reborrow through <tag> ... is forbidden ... has state Disabled ... due to a foreign write access, Stacked Borrows reports trying to retag from <tag> for Unique permission ... that tag does not exist in the borrow stack. Stashing the allocation pointer (-- root): both exit 0.
Tests
-
test/internal/source-lints/self-receiver-release.test.tsbans a release (dereffamily orScopedRef::adopt) applied to the receiver: spelled as a pointer inline (deref(ptr::from_mut(x)),deref(ptr::from_ref(x).cast_mut()),deref_nn(NonNull::from(self)),self as *mut,&raw mut *self,ScopedRef::adopt(ptr::from_mut(self))), passed as a bareselfthat the*mutparameter coerces (deref(self), which the self-review found the first version missed, and which is how six live sites spell it), or stored in a local first and released later in the same function (including via.as_ptr()). Definitions such asfn deref(self)do not count. It checks its patterns against positive and negative examples and ratchets the remaining instances with their reason. Againstmainit reports exactlysrc/runtime/webcore/fetch/FetchTasklet.rs:937 src/runtime/webcore/fetch/FetchTasklet.rs:956 src/runtime/webcore/fetch/FetchTasklet.rs:2286and passes with this branch. Its header states what it does not see, most importantly
deref(self.as_ctx_ptr()): that is the spelling bun_ptr'sAsCtxPtrdoc currently recommends and the one most R-2 wrappers (Subprocess, sockets, websocket_client, the SQL connections) use, so it is a decision about the idiom rather than a list of sites, and it has been handed off as such. The allowlist entries were each read. Non-final releases (another ref provably outlives the call):html_rewriter.rs,node_zlib_binding.rs,js_valkey.rs,process.rs,PostgresSQLConnection.rs,PostgresSQLQuery.rs,h3_client/ClientSession.rs, three of the fourstatic_pipe_writer.rssites. Real instances of this bug, allowlisted at their exact counts so they cannot multiply, each with its own fix in flight or tracked:static_pipe_writer.rs(on_write, #37755),SubprocessPipeReader.rs(on_reader_done/on_reader_error),h2_client/ClientSession.rs(on_close, whoseref_scopeguard is the final release, andmaybe_release),ProxyTunnel.rs(detach_and_deref). The FileSink entry from the first version is gone because #37716 landed. The sibling lints for the unconditional-teardown half of the class (self-receiver-reclaimfrom #37681;destroy/deinit/finalizein #37685, #37693, #37705) state that refcount releases are out of their scope, which is what this one covers.
Verification
On the debug (ASAN) build, at the current head: test/js/web/fetch/{fetch-abort-stream-body,body,client-fetch,fetch-keepalive,fetch-http2-client,fetch-stream-cancel-leak,fetch-response-finalizer-sweep,exiting}.test.ts, test/js/bun/http/fetch-file-upload.test.ts, test/js/node/http/node-fetch.test.js, test/regression/issue/13696.test.ts and the used as a request body test in fetch.test.ts all pass (the one timeout seen was the ITER=100 ended-inline fixture finishing in 3.9 to 5.0 s in this container, where the debug binary's start-up alone is about 2.7 s of that). A script streaming a Bun.file() stream and an upstream response body into fetch, both against a server that reads the whole body and against one that answers before the upload finishes (the ordering where write_end_request's release is the last ref, through both the promise handlers and end_from_stream), ran 20 rounds with a full GC between rounds without an ASAN report. Earlier heads additionally ran the broader fetch suites listed in the history of this description (fetch, fetch.stream, fetch-leak, fetch-backpressure, abort-signal-leak, fetch.tls and others), with only environment-specific failures. bun test test/internal/source-lints/ (19 files) passes; cargo clippy -p bun_runtime and cargo fmt --check are clean.