fetch: hold the AbortSignal as Option<AbortSignalRef>, drop SignalRef
What
fetch_impl (src/runtime/webcore/fetch.rs) took a +1 on the request's AbortSignal with hand-written ref_() calls and parked the raw pointer in a private SignalRef(Option<NonNull<AbortSignal>>) Drop guard, whose take() disarmed it when the pointer was moved into FetchOptions.signal: Option<*mut AbortSignal>. FetchTasklet::get moved that pointer into FetchTasklet.signal: Option<*mut AbortSignal> (src/runtime/webcore/fetch/FetchTasklet.rs), every reader went through bun_opaque::opaque_deref, and clear_abort_signal released the ref with an explicit unref() after clean_native_bindings and pending_activity_unref.
All three now hold Option<AbortSignalRef>, the ExternalShared<AbortSignal> that Request, Response::BodyAbortListener, node_fs and node_fs_watcher already use for the same C++ object.
// before
struct SignalRef(Option<NonNull<AbortSignal>>); // private Drop guard in fetch.rs
signal.0 = 'extract_signal: {
// options object / request-init object:
if let Some(signal__) = AbortSignal::from_js(signal_) {
break 'extract_signal NonNull::new(bun_opaque::opaque_deref(signal__).ref_());
}
// fetch(request):
if let Some(signal_) = req.signal.get() {
break 'extract_signal NonNull::new(signal_.ref_());
}
..
};
pub(crate) signal: Option<*mut AbortSignal>, // FetchOptions and FetchTasklet
fn abort_signal(&self) -> Option<&AbortSignal> { self.signal.map(|p| bun_opaque::opaque_deref(p)) }
signal.clean_native_bindings(..); signal.pending_activity_unref(); signal.unref();
// after
let signal: Option<AbortSignalRef> = 'extract_signal: {
if let Some(signal_ref) = AbortSignal::ref_from_js(signal_) {
break 'extract_signal Some(signal_ref);
}
break 'extract_signal req.signal.get().clone();
..
};
pub(crate) signal: Option<AbortSignalRef>,
fn abort_signal(&self) -> Option<&AbortSignal> { self.signal.as_deref() }
let Some(signal) = self.signal.take() else { return };
signal.clean_native_bindings(..); signal.pending_activity_unref(); // dropping `signal` is the unref
In fetch.rs the three extraction sites use AbortSignal::ref_from_js (options object and request-init object; the signal is not of type AbortSignal rejection is unchanged) or clone the Request's own AbortSignalRef, the pre-abort check borrows the Option, and the FetchOptions literal moves it. In FetchTasklet.rs the two fields change type, abort_signal() becomes as_deref(), the listener registration in get borrows the field, and clear_abort_signal drops the taken value instead of calling unref() by hand. SignalRef, its take(), and the three opaque_deref conversions are deleted; the two comments describing the raw-pointer protocol go with them. No unsafe blocks are added or removed.
Why
Who owns the +1 on the signal is now stated by the field types: fetch_impl holds it for the duration of argument processing (every one of the early returns after extraction releases it through AbortSignalRef's Drop, which SignalRef re-implemented locally), FetchOptions carries it, and FetchTasklet owns it until clear_abort_signal takes it out; a FetchTasklet or FetchOptions that is dropped while still holding the pointer can no longer leak it. This is zero-cost: Option<ExternalShared<AbortSignal>> is one word through the NonNull niche where Option<*mut AbortSignal> was two, so both structs shrink; the same ref()/unref()/pendingActivity FFI calls are issued at the same program points (clear_data always runs clear_abort_signal before the tasklet box is freed, so the field's drop glue sees None); and the Deref reads replace opaque_deref calls that each carried a null check, while ref_from_js drops the NonNull::new check on the value ref_() returned.
Part of a series of small type-system hardening changes; each PR stands alone.
Verification
cargo check and cargo clippy are clean for the touched crates. Debug build succeeds; bun bd test test/js/web/fetch/fetch.test.ts fetch-abort-stream-body.test.ts fetch-abort-queued.test.ts fetch-leak.test.ts: 354 pass, 1 skip, 42 fail (fetch-abort-stream-body 6 pass/1 skip and fetch-abort-queued 1 pass are fully green; all AbortSignal tests in fetch.test.ts and the abort/leak tests in fetch-leak.test.ts pass).
The 42 failures (41 in fetch.test.ts: sandbox localhost/IPv6 ConnectionRefused or egress-proxy responses, chmod-000 reads succeeding as root, debug-build "utf16 ... (with gc)" and redirect timeouts; 1 in fetch-leak.test.ts: "Sending URLSearchParams > does not leak" 120s debug timeout) are the identical set that fails on main in this environment, so they are pre-existing and unrelated to this change.