fetch: post the FetchTasklet deinit hop through a copy of the loop handle - #37884
fetch: post the FetchTasklet deinit hop through a copy of the loop handle#37884robobun wants to merge 3 commits into
Conversation
…ndle deref_from_thread formed a &FetchTasklet after release() reported the last ref and posted the deinit hop through it (post(&self), which in turn borrows the loop_handle field for the post). The JS thread frees the tasklet as soon as the hop is queued, while both of those reference arguments are still live in the posting frames. Read the handle out through the raw pointer first and post through the copy, as the other hand-over posts in the tree do. Add a source lint: after a refcount release(), the releasing function may only reach the object through the raw pointer.
|
Warning Review limit reached
Next review available in: 55 seconds 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 (2)
Comment |
|
Status: fix and test are up (a6e03e8); ready for a maintainer. CI is red only on infrastructure, see below. Reproduced structurally: on CI: builds 93382, 93392 and 93402 each lost build-bun lanes to |
|
Updated 10:08 AM PT - Aug 12th, 2026
⏳ @robobun, your commit a6e03e8 is still building in
|
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. The deref_from_thread rewrite is sound and mirrors the existing clone-out-then-post pattern elsewhere in the tree, but since this is cross-thread refcount teardown on the fetch path and ships a new tree-wide source lint, a human look is still worthwhile.
What was reviewed:
- Confirmed
(*this).loop_handle.clone()runs before the hop is posted, so the auto-ref for.clone()dies before anything can free the tasklet;handle.post_taskthen borrows only the stack copy. - Checked
LoopHandleis#[derive(Clone)](Arc-backed) andpost_taskroutes to the sameVmHandle::postpath as before, so ordering/tag/teardown handling is unchanged. - The lint follows the sibling source-lint conventions, self-checks its patterns, and guards against vacuous passes; verified its
RELEASEregex matches the actualThreadSafeRefCount::releasesignature insrc/ptr/ref_count.rs.
Extended reasoning...
Overview
The PR touches two files: a ~15-line rewrite of FetchTasklet::deref_from_thread in src/runtime/webcore/fetch/FetchTasklet.rs plus a one-line doc addition on post(&self), and a new 242-line source lint at test/internal/source-lints/refcount-release-reborrow.test.ts. The Rust change stops forming &FetchTasklet after ThreadSafeRefCount::release reports the 1→0 transition and instead clones loop_handle out through the raw pointer before posting the deinit hop, so no protected reference into the tasklet is live while the JS thread may be freeing it. The lint enforces this invariant across every ThreadSafeRefCount::release caller in the tree.
Security risks
None identified. This is an internal aliasing-model correctness fix on an existing teardown path; no new inputs, parsing, or trust boundaries are introduced. The behaviour is stated (and appears) to be unchanged at the machine level today.
Level of scrutiny
High. deref_from_thread sits on the HTTP-thread → JS-thread hand-over for every fetch whose last ref drops off-thread, and the correctness argument rests on Tree Borrows / Stacked Borrows protector semantics. The change is small and matches the pattern already used by callback, release_at_shutdown, S3HttpSimpleTask::http_callback and post_job, but memory-safety edits to cross-thread refcounted teardown deserve a maintainer's eyes even when the diff is short. The new lint is also a tree-wide policy addition (regex-based, though self-checked against a banned/allowed corpus) that a human should sign off on.
Other factors
The comment-cop bot's paragraph-length warnings were addressed in a6e03e8 and the threads are resolved. CI shows build failures on several targets, but they are in scripts/build/ci.ts / a Windows SDK header warning and not in the changed files; still, I would not merge until those go green. The lint mirrors the structure of neighbouring lints (self-receiver-reclaim.test.ts, fn-long-mut-reborrow.test.ts), uses the shared globAllSources helper, filters to git-tracked files, and asserts releases > 0 so it cannot pass vacuously.
Problem
&selfreference into the tasklet, and the JS thread may free the tasklet as soon as the task is queued, before the post has returned.bun run rust:miriuses, rejects it as a deallocation of strongly protected memory), and codegen marks such argumentsdereferenceable. Whether anything reads it afterwards does not matter.assert_no_refsreport in fetch: hold FetchTasklet mutex through deref_from_thread so HTTP thread is never the final deref #32707.JSBundleCompletionTask.Fix
Arcincrement, at most once per fetch.ThreadSafeRefCount::release, from the release to the end of the function, for a reference formed to the released object or a post through one of its fields. Onmainit reports exactly the old line in this file; it passes here, and fails if the tree stops having areleasecaller.Background
FetchTaskletis the per-fetch state shared by the JS thread that owns the request and the HTTP thread that runs it. It is refcounted, and its deinit has to run on the JS thread because it drops JSC handles.ThreadSafeRefCount::releasedecrements and returns true on the 1 -> 0 transition instead of destroying. It exists so the releasing thread can hand the object to the thread that is allowed to destroy it.post_taskpushes the task and wakes the loop, so the JS thread can run the task before the posting call returns to the caller.test/internal/source-lints/holds bun tests that regex-scan the tree's sources for banned patterns; each self-checks its patterns against sample banned and allowed spellings.Original description
Problem
FetchTasklet::deref_from_thread(src/runtime/webcore/fetch/FetchTasklet.rs) is the HTTP thread's release of a tasklet ref. Whenrelease()reports that it dropped the last one, it has to hand the tasklet to the JS thread to be destroyed there, and it did so like this:The consumer of that hop (
FetchTaskletDeinitHop::run, dispatched by the JS thread) runsdeinitand frees the tasklet. It can do so as soon asVmHandle::posthas pushed the task and woken the loop, which is beforepost,LoopHandle::post_taskandVmHandle::posthave returned, and each of those frames holds a reference argument pointing into the allocation being freed:post's&selfis the whole tasklet,post_task's&selfis theloop_handlefield inside it. A reference argument is protected for the duration of its call, and freeing the memory it points into is UB under both aliasing models whether or not anything reads it afterwards (Tree Borrows, whichbun run rust:miriuses, rejects it as a deallocation of strongly protected memory; Stacked Borrows as "deallocating while item is strongly protected"); codegen relies on the same guarantee throughdereferenceable. #37723 fixes the same cross-thread shape forJSBundleCompletionTaskand carries a Miri reduction of it.This is the path taken whenever the HTTP thread's deref is the last one: the final
callbackunlocks the tasklet mutex and then derefs, and if the JS thread runs the whole final progress update in between (the interleaving behind theassert_no_refsreport in #32707), the HTTP thread's deref is the 1 -> 0 transition and this code runs. It is latent today:VmHandle::posttouches nothing through the handle after the push, so there is no machine-level use after free, and this PR does not change behaviour. It fixes the contract, in the same way the function's neighbours already spell it:release_at_shutdownandcallbackin this file cloneloop_handleout before their own deref so thatembedded_work_finisheddoes not go through the possibly freed tasklet, andS3HttpSimpleTask::http_callback,node_zlib_binding::async_job_runandpost_jobin VmHandle.rs all post their hand-over through a copied-out handle ("clone the handle out first").Fix
After
release()returns true, readloop_handleout through the raw pointer and post through the copy; no reference to the tasklet exists after the count hit zero.post(&self)stays for its two remaining callers (callback,on_write_request_data_drain), whose own ref keeps the tasklet alive across the post; its doc now says that this is its contract.The ordering is unchanged (release, build the hop, post it) and the hop still goes to the same loop with the same tag, so the teardown handling of a queued hop (
release_unrun) is unaffected. The clone costs oneArcincrement on a path that runs at most once per fetch.#37703 (releases through
&mutreceivers in this file) leaves this function unchanged, and #32707 reorderscallbackbut also keeps it; neither conflicts with this change beyond adjacent lines.Test
test/internal/source-lints/refcount-release-reborrow.test.ts:ThreadSafeRefCount::releaseexists only to route a destroy elsewhere (its doc says so), so every caller is by definition writing this hand-over. The lint checks each caller from the release to the end of the function: forming a reference to the released object (&*p,&mut *p,&(*p).field,p.as_ref(), the tree'sfrom_raw_ref/from_raw_mut/callback_ctxhelpers,ParentRef::from(..p..)), using a reference binding made from it earlier in the function, or posting through a field of it ((*p).loop_handle.post_task(..),(*p).post(..)) fails. It self-checks its patterns against the old and new spellings of this function and a set of other wrong and right spellings. Againstmainit reports exactlyand passes with this branch. It also fails if the tree stops containing a
releasecaller, so it cannot pass vacuously; its header says to delete it in that case.Its header lists the sites of the same hand-over that have no
release()anchor and so stay out of its reach:S3HttpDownloadStreamingTask::http_callback(reported separately), and the napi async work andStatWatcherpost_to_js_threadfunctions that #37723's lint already tracks. The Windows blob mkdirp completions have the same shape and are converted by #37705.Verification
Debug (ASAN) build.
bun test test/internal/source-lints/(all 19 files) passes. test/js/web/fetch: abort-signal-leak (its three fixture functions run directly, about 7,600 fetches, two thirds aborted mid-flight; inside the runner two of them exceed the 5 s budget on this debug build, as they do without this change), fetch-stream-cancel-leak, fetch-abort-queued, fetch-abort-stream-body, fetch-abort-socket-close-race, exiting, body-stream, fetch-response-finalizer-sweep, fetch-leak and fetch.stream and fetch.test.ts. The failures in the last three and in fetch.test.ts are the ones the released binary also has in this container (localhostlisteners vs the address family fetch picks, root ignoring file permissions) plus debug-only timeouts of gc-heavy tests, each of which passes when run alone or without the per-test budget (checked individually).Because the hop is race-dependent, I also built this branch once with a local-only 2 ms sleep between
callback's unlock and its deref (the same trick #37172 proposes as a debug flag) plus a log line in the hop, so the JS thread usually drops its ref first and the HTTP thread's release is the last one: in the multi-chunk stress script 649 of 1,000 tasklets, in the abort fixture 3,885 of 7,650, and across fetch-stream-cancel-leak, fetch-abort-stream-body, body-stream and fetch-abort-queued 6,923 of 11,209 went through the hop, i.e. through this post while the JS thread freed the tasklet underneath it; all tests passed and ASAN reported nothing. Neither the sleep nor the log is in this PR.cargo clippy -p bun_runtimeandcargo fmt --checkare clean.