Skip to content

jsc: remove AbortSignal::unref/detach; add abort_signal::PendingActivityRef as the owned hold on a signal - #37609

Open
robobun wants to merge 9 commits into
mainfrom
farm/74f3f996/abort-signal-unref-unsafe
Open

jsc: remove AbortSignal::unref/detach; add abort_signal::PendingActivityRef as the owned hold on a signal#37609
robobun wants to merge 9 commits into
mainfrom
farm/74f3f996/abort-signal-unref-unsafe

Conversation

@robobun

@robobun robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

What

bun_jsc::AbortSignal (src/jsc/AbortSignal.rs) is an opaque_ffi! zero-sized handle to a WebCore::AbortSignal, which is WTF::RefCounted; WebCore__AbortSignal__unref is deref() and frees the object at count zero. The handle exposed that as two safe &self methods:

pub fn unref(&self) { WebCore__AbortSignal__unref(self) }
pub fn detach(&self, ctx: *mut c_void) { self.clean_native_bindings(ctx); self.unref(); }

A &AbortSignal proves nothing about owning a ref: it is what AbortSignalRef (ExternalShared<AbortSignal>) hands out through Deref, and for a ZST handle it is also obtainable from any non-null pointer via the safe AbortSignal::opaque_ref. So this compiles with no unsafe anywhere and is a double release (the AbortSignalRef releases the same ref again when it drops):

let signal = AbortSignal::ref_from_js(value).unwrap();
signal.unref();

Nothing in tree did this; every caller released a ref it had taken with ref_() / new(). But none of those sites had to say so, because the API did not ask. The same was true of the two things the callers release alongside the ref: the pending-activity count and the native abort listener were balanced by hand at every site.

Fix

unref and detach are deleted. The release shim is now reached only from ExternalSharedDescriptor::ext_deref, i.e. from AbortSignalRef's Drop, the shape JSCArrayBuffer, NapiEnv and CookieMapRef already use: taking a ref is safe, releasing one means dropping the owner, and turning a raw +1 into an owner (AbortSignalRef::adopt) is the one unsafe step. ref_ documents this so the method does not grow back; AbortSignal::new() returns an AbortSignalRef since every caller wrapped it.

Per @Jarred-Sumner's suggestion, what the native holders keep is modelled as one owned value, abort_signal::PendingActivityRef (the Rust spelling of AbortSignal::PendingActivityRef): it owns an AbortSignalRef, takes the pending-activity count in new, records the ctx of the one native listener registered through its add_listener, derefs to &AbortSignal, and its Drop removes the listener, drops the pending-activity count and releases the ref. Holders store Option<PendingActivityRef> and release by taking it out. Converted:

  • Subprocess.abort_signal; the spawn code parses the option into an AbortSignalRef (ref_from_js; its own Drop covers the early returns, replacing a scopeguard branch) and wraps it when the listener is attached. clear_abort_signal is a drop. The second attach block in the spawnSync tail was unreachable (the first one runs unconditionally and empties the option) and is deleted.
  • RequestContext.signal, created from AbortSignal::new() with signal_ref() giving the Request its copy; on_abort / finalize_without_deinit take it out, fire it, and drop it, and the WebSocket upgrade moves it into ServerWebSocket.signal, which on_close fires and drops (whatever is left goes with the box in finalize). The three NonNull shims in RequestContext.rs go away. set_signal_aborted fires through a signal_ref() of its own because the listeners it runs can end the request and empty the cell.
  • Response's BodyAbortListener, which was this struct written out by hand.

Holders left as they are, each with a one-line reason: FetchTasklet / fetch's SignalRef guard adopt the +1 they already own at the release point, because #37549 is replacing those fields (on top of this PR its unref() deletion is a no-op conflict) and they become Option<PendingActivityRef> in one line once it lands; the two node:fs holders are the same story with #37420; the http2 SignalRef and fs.watch's FSWatcher (untouched by this PR, it never used unref()) hold an AbortSignalRef plus a listener but take no pending activity today (a native listener already counts as a timeout observer), so giving them a PendingActivityRef would be a GC-behavior change and is left for a follow-up. Once those are converted, pending_activity_ref/unref and clean_native_bindings can stop being public.

Behavior

Everything releases at the same program points as before. The one observable change is the unified Drop order. cleanNativeBindings runs eventListenersDidChange, which cancels an AbortSignal.timeout() timer if the signal has no observers left at that moment; Subprocess dropped its pending-activity count first and removed its listener second, so on main this never aborts:

const signal = AbortSignal.timeout(1000);
await Bun.spawn({ cmd: [bunExe(), "--version"], signal }).exited;
// signal.aborted stays false forever; fetch() with the same signal aborts at 1s

PendingActivityRef::drop removes the listener while the pending-activity count still counts as an observer (the order FetchTasklet and Response already used), so spawn and spawnSync now match fetch; spawn-signal.test.ts gets a case for each, both of which time out on main (fetch's own order is unchanged, so a fetch case would pass on both sides; it is worth adding when FetchTasklet converts). The eager cancel itself also hits a held timeout signal whose last JS listener is removed, and is being fixed separately on the C++ side. The http2 SignalRef now releases its parser ref before the signal ref rather than after; neither release can observe the other.

Tests

  • test/internal/source-lints/refcount-release-owner.test.ts lists the release shims whose pointee has an RAII owner (WebCore__AbortSignal__unref, JSC__ArrayBuffer__deref, NapiEnv__deref, CookieMap__deref) and asserts each is declared once and called only from that owner's release hook, with exact counts so a renamed shim surfaces instead of dropping out. On main it fails with src/jsc/AbortSignal.rs:167 (in fn unref): WebCore__AbortSignal__unref(self) and the other three entries pass; with this change all pass. Complementary to bun_url: make freeing a whatwg::URL an unsafe raw-pointer operation #37577's lint, which allowlists this instance; whichever lands second drops that line.
  • test/js/bun/spawn/spawn-signal.test.ts: the two timeout cases above, run concurrently. Each asserts the child exited before the timer fired (the child is bun --version, which does not boot a JS VM, and debug/ASAN builds get a 3s window instead of 1s) and then waits for the abort event; on a released bun both time out after the preconditions pass.

Verification

Rebased onto current main (the upgrade hand-off now sits next to #37477's UpgradeState); the rebased branch was rebuilt and the runs below repeated on it. cargo check / clippy / fmt --check clean for bun_jsc and bun_runtime, also for x86_64-pc-windows-msvc; debug (ASAN) build. Against it: spawn-signal (incl. the spawnSync AbortSignal.timeout case, which reads the hold through abort_signal_ref()), spawn-maxbuf, websocket-upgrade-signal-gc (the ctx to socket move), websocket-server close/upgrade cases, bun-server.test.ts (only the three localhost connection tests fail, identically with the released binary in this sandbox), serve-pending-promise-abort-leak, serve-async-stream-client-abort, node-http2.test.js -t "abort|signal", fetch-abort-queued, fetch-abort-stream-body pass. abortsignal-leak-fixture.ts run directly (7,650 server requests creating and releasing a PendingActivityRef each, 5,100 of them aborted) ends with 1 / 2 / 1 live AbortSignals after a full GC, so the counts balance. Its bun test wrapper and fetch-abort-socket-close-race only exceed their 5s ceilings here because this container is running at load average ~70; the latter passed at 4.1s earlier on a build whose fetch code was byte-identical.


[review] gate passed · iteration 0 · 13 files touched

fails on main (without fix)
ASAN without fix: 3 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/refcount-release-owner.test.ts test/js/bun/spawn/spawn-signal.test.ts
bun test v1.4.0 (ad5b7ecbc)

test/internal/source-lints/refcount-release-owner.test.ts:
(pass) scans a non-empty set of tracked Rust sources [7.16ms]
107 |   expect(scanned).toBeGreaterThan(0);
108 | });
109 | 
110 | for (const [shim, { owner }] of Object.entries(RELEASE_SHIMS)) {
111 |   test(`${shim} is declared once and called only from ${owner}`, () => {
112 |     expect(usage.get(shim)).toEqual({ declarations: 1, ownerCalls: 1, other: [] });
                                  ^
error: expect(received).toEqual(expected)

  {
    "declarations": 1,
-   "other": [],
+   "other": [
+     "src/jsc/AbortSignal.rs:167 (in fn unref): WebCore__AbortSignal__unref(self)",
+   ],
    "ownerCalls": 1,
  }

- Expected  - 1
+ Received  + 3

      at <anonymous> (/workspace/bun/test/internal/source-lints/refcount-release-owner.test.ts:112:29)
(fail) WebCore__AbortSignal__unref is declared once and called only from ext_deref [7.73ms]
(pass) JSC__ArrayBu
... (truncated)

release without fix: 3 FAILED
bun test v1.4.0-canary.1 (da3851e57)

test/internal/source-lints/refcount-release-owner.test.ts:
(pass) scans a non-empty set of tracked Rust sources [0.11ms]
107 |   expect(scanned).toBeGreaterThan(0);
108 | });
109 | 
110 | for (const [shim, { owner }] of Object.entries(RELEASE_SHIMS)) {
111 |   test(`${shim} is declared once and called only from ${owner}`, () => {
112 |     expect(usage.get(shim)).toEqual({ declarations: 1, ownerCalls: 1, other: [] });
                                  ^
error: expect(received).toEqual(expected)

  {
    "declarations": 1,
-   "other": [],
+   "other": [
+     "src/jsc/AbortSignal.rs:167 (in fn unref): WebCore__AbortSignal__unref(self)",
+   ],
    "ownerCalls": 1,
  }

- Expected  - 1
+ Received  + 3

      at <anonymous> (/workspace/bun/test/internal/source-lints/refcount-release-owner.test.ts:112:29)
(fail) WebCore__AbortSignal__unref is declared once and called only from ext_deref [0.27ms]
(pass) JSC__ArrayBuffer__deref is declared once and called only from ext_deref
(pass) NapiEnv__deref is declared once and called only from ext_deref
(pass) CookieMap__deref is declared once and called only from drop

test/js/bun/spawn/spawn
... (truncated)
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/refcount-release-owner.test.ts test/js/bun/spawn/spawn-signal.test.ts
bun test v1.4.0 (ad5b7ecbc)

test/internal/source-lints/refcount-release-owner.test.ts:
(pass) scans a non-empty set of tracked Rust sources [2.51ms]
(pass) WebCore__AbortSignal__unref is declared once and called only from ext_deref [2.13ms]
(pass) JSC__ArrayBuffer__deref is declared once and called only from ext_deref [0.49ms]
(pass) NapiEnv__deref is declared once and called only from ext_deref [0.36ms]
(pass) CookieMap__deref is declared once and called only from drop [0.45ms]

test/js/bun/spawn/spawn-signal.test.ts:
(pass) spawn AbortSignal works after spawning [30.56ms]
(pass) spawn AbortSignal throws if already aborted [17.83ms]
(pass) spawn AbortSignal already aborted carries signal.reason as cause [9.03ms]
(pass) spawn AbortSignal already aborted throws before resolving the executable [7.66ms]
(pass) spawnSync AbortSignal throws if already aborted [6.76ms]
(pass) spawn AbortSignal args validation [5.20ms]
(pass) spawnSync AbortSigna
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1169ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/9] 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/9] 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_core v0.0.0 (/workspace/bun/src/bun_core)
^[[1m^[[92m   Compiling^[[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
^[[1m^[[92m   Compiling^[[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
^[[1m^[[92m   Compiling^[[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
^[[1m^[[92m   Compiling^[[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
^[[1m^[[92m   Compiling^[[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
^[[1m^[[92m   Compiling^[[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
^[[1m^[[92m   Compiling^[[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
^[[1m^[[92m   Compiling^[[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
^[[1m^[[92m   Compiling^[[0m bun_brotli 
... (truncated)
diff hotspot
src/jsc/AbortSignal.rs                             |  87 +++++++++++++---
 src/runtime/api/bun/h2_frame_parser.rs             |  40 +++-----
 src/runtime/api/bun/js_bun_spawn_bindings.rs       |  67 ++++--------
 src/runtime/api/bun/subprocess.rs                  |  29 ++----
 src/runtime/server/RequestContext.rs               |  84 ++++-----------
 src/runtime/server/ServerWebSocket.rs              |  51 +++------
 src/runtime/server/mod.rs                          |  11 +-
 src/runtime/server/server_body.rs                  |  38 ++-----
 src/runtime/webcore/Response.rs                    |  25 ++---
 src/runtime/webcore/fetch.rs                       |  10 +-
 src/runtime/webcore/fetch/FetchTasklet.rs          |  14 +--
 .../source-lints/refcount-release-owner.test.ts    | 114 +++++++++++++++++++++
 test/js/bun/spawn/spawn-signal.test.ts             |  35 +++++++
 13 files changed, 331 insertions(+), 274 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/jsc/AbortSignal.rs                                        6     18      0
src/runtime/api/bun/h2_frame_parser.rs                        6     10      0
src/runtime/api/bun/js_bun_spawn_bindings.rs                  8     11      0
src/runtime/api/bun/subprocess.rs                             6     10      0
src/runtime/server/RequestContext.rs                         13     12      0
src/runtime/server/ServerWebSocket.rs                         9     16      0
src/runtime/server/mod.rs                                     2      2      0
src/runtime/server/server_body.rs                            10      9      0
src/runtime/webcore/Response.rs                               3      5      0
src/runtime/webcore/fetch.rs                                  1      2      0
src/runtime/webcore/fetch/FetchTasklet.rs                     2      3      0
…st/internal/source-lints/refcount-release-owner.test.ts      0      2      0
test/js/bun/spawn/spawn-signal.test.ts                        4      3      0

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 21ed1db5-6f14-4d24-bc8c-3786aae0c0cc

📥 Commits

Reviewing files that changed from the base of the PR and between e11190c and 00b27e5.

📒 Files selected for processing (2)
  • src/runtime/api/bun/subprocess.rs
  • test/js/bun/spawn/spawn-signal.test.ts

Walkthrough

The change replaces raw AbortSignal ownership and manual cleanup with AbortSignalRef and PendingActivityRef. Spawn, server, HTTP/2, response, and fetch paths use managed references. Tests cover timeout behavior after child-process exit.

Changes

Abort signal ownership

Layer / File(s) Summary
Managed reference primitives
src/jsc/AbortSignal.rs
AbortSignal::new returns AbortSignalRef. PendingActivityRef owns signal references, listeners, and pending activity.
Spawn signal integration
src/runtime/api/bun/js_bun_spawn_bindings.rs, src/runtime/api/bun/subprocess.rs
Spawn and subprocess paths store PendingActivityRef and release it through ownership drops.
Server request and WebSocket integration
src/runtime/server/...
Request contexts and WebSocket upgrades use managed signal references for abort and cleanup handling.
HTTP and WebCore signal integration
src/runtime/api/bun/h2_frame_parser.rs, src/runtime/webcore/Response.rs, src/runtime/webcore/fetch.rs, src/runtime/webcore/fetch/FetchTasklet.rs
HTTP/2, response, and fetch cleanup adopt typed signal references and remove manual release calls.
Ownership and timeout validation
test/internal/source-lints/refcount-release-owner.test.ts, test/js/bun/spawn/spawn-signal.test.ts
Source-lint checks enforce release-shim ownership. Spawn tests verify timeout signals after asynchronous and synchronous process exit.

Possibly related PRs

  • oven-sh/bun#37666: Tests exercise related AbortSignal.timeout() lifetime behavior in subprocess paths.
  • oven-sh/bun#37703: Both changes modify FetchTasklet reference-release handling.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the removal of unsafe release methods and the addition of PendingActivityRef, which are the main changes.
Description check ✅ Passed The description explains the changes and provides extensive verification details, despite using headings different from the template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:05 AM PT - Aug 12th, 2026

@robobun, your commit 00b27e5 has some failures in Build #93117 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 37609

That installs a local version of the PR into your bun-37609 executable, so you can run:

bun-37609 --bun

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced on main as an API-level hole (the snippet in the description compiles with no unsafe and double-releases; nothing in tree did it). Two tests pin the change: test/internal/source-lints/refcount-release-owner.test.ts fails on main at src/jsc/AbortSignal.rs:167 (in fn unref): WebCore__AbortSignal__unref(self), and the two new spawn-signal.test.ts cases (AbortSignal.timeout() given to Bun.spawn / Bun.spawnSync still fires after the child exits; each asserts the child exited first, then waits for the event) time out on main; all pass on this branch. The holders were reworked into abort_signal::PendingActivityRef after the design comment below, review follow-ups (dead second attach block in spawn, test precondition, spawnSync case, abort_signal_ref() returning an owned ref, wider exit-to-timer margin in the test) landed, and the branch is rebased onto current main (it had started conflicting with #37477); affected runtime paths (spawn, http2, Bun.serve request signals, WebSocket upgrade, fetch, the server abort-signal leak fixture) were run against the debug build, details in the description.

Merge order relative to the in-flight PRs, none of which blocks this one:

CI: the diff is green. Build 93117 (head 00b27e5, a re-run of 94e81cc with no code change) ran 179 jobs and every one that ran passed; the build is marked failed only because the two darwin 26 aarch64 - test-bun shards expired without ever getting an agent, and its two report entries (retry-flag, child_process) failed in a parallel batch and passed when re-run alone. The previous build's one red test, the Windows crash in test/bake/deinitialization.test.ts, is a pre-existing flake (same signature on unrelated branches, e.g. builds 92591 and 92641), did not recur, and has been reported for triage separately. Nothing here touches code in this PR, so I am not re-rolling again; ready for a maintainer.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it restructures AbortSignal refcount release across six memory-safety-critical subsystems (spawn, http2, Bun.serve, WebSocket upgrade, fetch, FetchTasklet) and coordinates with three in-flight PRs, a human look would still be worthwhile.

Checked: release order vs. clean_native_bindings/pending_activity_unref is preserved at every site; the one intentional swap (h2 SignalRef::Drop now derefs parser before the signal field drops) is benign since the listener is already removed and ~AbortSignal runs no callbacks. Verified Cell<Option<AbortSignalRef>> on ServerWebSocket is only ever .take()n (no Copy-requiring .get()), JsCell::set/replace on Subprocess.abort_signal correctly drops the prior None, and the spawn scopeguard removal is covered by AbortSignalRef's own Drop on every early return. Each unsafe { adopt(...) } site's SAFETY comment traces to the matching ref_()/new().

Extended reasoning...

Overview

This PR deletes the safe AbortSignal::unref(&self) and AbortSignal::detach(&self, ctx) methods and routes every release of a WebCore::AbortSignal ref through AbortSignalRef (ExternalShared<AbortSignal>)'s Drop. Ten files: the FFI handle itself, six runtime call sites (http2 stream signals, Bun.spawn option parsing + Subprocess storage, Bun.serve request-context release, WebSocket upgrade handover + ServerWebSocket storage, fetch's SignalRef guard, FetchTasklet::clear_abort_signal), and a new source-lint test that pins each of four release shims to a single declaration + a single call inside its owner's release hook.

Security risks

None user-facing. The change is purely about who is allowed to call the release shim from Rust; no attacker-reachable input shapes any of these paths differently than before. The risk category is memory safety (double-free / UAF / leak) if a release site's ownership proof is wrong.

Level of scrutiny

High. This is the repo's most-blocked review category (native memory safety, reference-count balance across FFI). Field types change on two GC-finalized structs, one drop ordering is intentionally swapped, and every converted site now carries an unsafe { adopt(...) } whose SAFETY comment states the +1 provenance. I traced each site against ExternalShared::{adopt, Drop, Deref} and JsCell::{get, set, replace} semantics: the spawn early-return path (scopeguard branch removed) is covered by the local Option<AbortSignalRef> dropping; ServerWebSocket.signal as Cell<Option<AbortSignalRef>> is sound because only Cell::new/Cell::take are used (no .get()); Subprocess.abort_signal_ref() returning a borrow tied to &self is fine at its one caller (spawnSync timeout probe) which does not re-enter; RequestContext::signal_release and FetchTasklet::clear_abort_signal preserve the original pending_activity_unref → unref / clean_native_bindings → pending_activity_unref → unref orderings via scope-exit drop. The h2 SignalRef::Drop reorder (parser deref now runs before the signal field's implicit drop) matches the PR description's justification and is not observable — the abort listener was removed on the line above and ~AbortSignal on the C++ side does not call back into Rust.

Other factors

The PR description is unusually thorough (per-site rationale, verification against the abort-signal leak fixture and every affected test suite under debug+ASAN, cross-platform cargo check). The new source-lint test is a reasonable ratchet and correctly guards against an empty scan. That said, three coordination points (#32009, #37549, #37577) mean a maintainer should confirm merge order, and the sheer breadth of refcount-lifecycle edits across core runtime paths puts this outside what I'd auto-approve without a human maintainer's sign-off.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we instead have a AbortSignal::PendingActivityRef as an Option? And that holds both the signal ref / unref and cleaning native bindings?

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Yes, that is a better shape. What the native holders actually own is a bundle: the ref, a pending-activity count, and (for the ones that listen rather than fire) one native listener keyed by a ctx pointer. This PR made only the first of those RAII and left the other two as hand-written sequences at each release site, which is the same class of thing it set out to remove.

Reworking it as abort_signal::PendingActivityRef (nearest Rust spelling of AbortSignal::PendingActivityRef): owns an AbortSignalRef, takes the pending-activity count in new, has a listen(ctx, callback) that records the ctx, derefs to &AbortSignal, and its Drop removes the listener, drops the pending-activity count, and releases the ref. Holders store Option<PendingActivityRef> and release by taking it: Subprocess.abort_signal, RequestContext.signal (so the WebSocket upgrade hand-off becomes a move into ServerWebSocket.signal instead of an adopt), and Response's BodyAbortListener, which is already this exact struct by hand. FetchTasklet and the two node:fs holders are the same pattern but #37549 and #37420 are in the middle of rewriting those fields, so they stay as they are here and become one-line conversions once those land. The http2 SignalRef takes no pending activity (its native listener already counts as a timeout observer), so it keeps a plain AbortSignalRef.

One thing that fell out of picking the Drop order: releasing pending activity before removing the listener (the current Subprocess order) lets eventListenersDidChange see zero observers and cancel an AbortSignal.timeout() timer that the caller may still be holding, so on main Bun.spawn({ signal: AbortSignal.timeout(100) }) whose child exits leaves signal.aborted false forever, while fetch (listener removed first) aborts it on time. The shared Drop uses the fetch order, which makes spawn match; I will add a test for that. The eager cancel itself also affects plain addEventListener/removeEventListener on a held timeout signal and is a separate C++ fix.

Comment thread src/jsc/AbortSignal.rs Outdated
Comment thread src/jsc/AbortSignal.rs Outdated
Comment thread src/jsc/AbortSignal.rs
Comment thread src/jsc/AbortSignal.rs Outdated
Comment thread src/jsc/AbortSignal.rs Outdated
Comment thread src/jsc/AbortSignal.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/h2_frame_parser.rs Outdated
Comment thread src/runtime/api/bun/js_bun_spawn_bindings.rs Outdated
Comment thread src/runtime/api/bun/js_bun_spawn_bindings.rs
Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread src/runtime/server/ServerWebSocket.rs Outdated
Comment thread src/runtime/server/ServerWebSocket.rs Outdated
Comment thread src/runtime/server/ServerWebSocket.rs Outdated
Comment thread src/runtime/server/ServerWebSocket.rs Outdated
Comment thread src/runtime/server/server_body.rs Outdated
Comment thread src/runtime/server/server_body.rs
Comment thread src/runtime/webcore/Response.rs Outdated
Comment thread src/jsc/AbortSignal.rs
Comment thread src/jsc/AbortSignal.rs
Comment thread src/jsc/AbortSignal.rs
Comment thread src/jsc/AbortSignal.rs
Comment thread src/runtime/server/RequestContext.rs
Comment thread src/runtime/server/RequestContext.rs
Comment thread src/runtime/server/ServerWebSocket.rs
@robobun robobun changed the title jsc: remove AbortSignal::unref/detach, release refs only by dropping an AbortSignalRef jsc: remove AbortSignal::unref/detach; add abort_signal::PendingActivityRef as the owned hold on a signal Aug 11, 2026
@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed the rework described above (f713940, plus a comment trim in 87848df); title and description updated to match.

abort_signal::PendingActivityRef now owns the ref, the pending-activity count and the registered listener, and Subprocess, RequestContext -> ServerWebSocket (the upgrade is a move of the Option now) and Response's BodyAbortListener hold it. FetchTasklet, the two node:fs holders and the http2 SignalRef are left for follow-ups, with the reasons in the description. The drop order it settles on is the fetch one; spawn-signal.test.ts gets the case where the old spawn order cancelled a still-held AbortSignal.timeout().

The remaining multi-line comments flagged by the comment lint are the rustdoc for the new type, the note on why unref() no longer exists, and the two places where the order or the receiver type is deliberate; each thread says which.

Comment thread src/runtime/api/bun/js_bun_spawn_bindings.rs Outdated
Comment thread test/js/bun/spawn/spawn-signal.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both earlier inline findings are addressed in fb968d4 — the dead second abort_signal.take() block on the spawnSync path is gone, and the new spawn-signal test now asserts signal.aborted === false after proc.exited instead of branching on it. This pass found no further issues. Given the scope — a new PendingActivityRef abstraction with load-bearing Drop ordering, threaded through six AbortSignal holders across FFI (Subprocess, RequestContext → ServerWebSocket, Response, fetch/FetchTasklet, h2) — a human look is still worthwhile before merge.

What was reviewed:

  • Refcount balance at each converted holder: every prior unref()/pending_activity_unref()/clean_native_bindings() sequence maps to a PendingActivityRef drop or an AbortSignalRef::adopt at the same program point.
  • set_signal_aborted taking its own signal_ref() clone so re-entrant abort listeners emptying the cell don't invalidate the borrow.
  • The h2 SignalRef drop-order change (parser deref now after clean_native_bindings, before the AbortSignalRef field drop) — neither release observes the other.
  • The source-lint test's exact-count assertions and the tracked-files/realpath guards.
Extended reasoning...

Overview

The PR removes AbortSignal::unref(&self) and AbortSignal::detach(&self, ctx) — safe methods on an opaque ZST handle that could release a ref the caller did not own — and routes all releases through AbortSignalRef's Drop (ExternalSharedDescriptor::ext_deref). It introduces abort_signal::PendingActivityRef, an owned bundle of (ref, pending-activity count, one native listener) whose Drop releases all three in a fixed order, and converts Subprocess.abort_signal, RequestContext.signalServerWebSocket.signal, and Response::BodyAbortListener to hold it as Option<PendingActivityRef>. FetchTasklet, fetch's SignalRef guard, and the h2 SignalRef are minimally updated to AbortSignalRef::adopt at their existing release points. Two tests are added: a source lint pinning that each release shim is called only from its RAII owner's release hook, and a spawn test for the one observable change (an AbortSignal.timeout() handed to spawn keeps its timer after the child exits).

Security risks

None identified. This is internal refcount/lifetime plumbing; no new untrusted-input parsing, no auth/crypto/permission surface. The change reduces the surface for accidental double-release by removing the safe unref() escape hatch.

Level of scrutiny

High. This is native memory-safety code — intrusive C++ refcounts across FFI, GC pending-activity accounting, and Drop ordering that affects whether an AbortSignal.timeout() timer is cancelled — spread across six holders in production paths (spawn, Bun.serve request/WebSocket, fetch Response, http2). REVIEW.md flags this as the most-blocked category. The design was already steered by a maintainer (the PendingActivityRef shape), and verification was thorough (ASAN debug build, the abortsignal-leak fixture, multiple targeted test suites), but the breadth and the interaction with three in-flight PRs (#37549, #37420, #32009, plus the #37577 lint overlap) make it inappropriate to approve without a human look.

Other factors

My two prior nits (a dead code block in spawn_maybe_sync and a vacuous-pass window in the new test) were both fixed in fb968d4. The comment-cop bot flags were all resolved (trimmed or justified). The remaining unsafe uses are narrowly scoped AbortSignalRef::adopt calls at documented +1 transfer points, each with a SAFETY comment naming where the ref came from.

…tSignalRef

`AbortSignal::unref(&self)` and `detach(&self, ..)` released the C++
intrusive refcount from a shared borrow. `AbortSignal` is an opaque ZST
handle, so `&AbortSignal` is reachable from any pointer and from every
`AbortSignalRef`'s Deref; safe code could therefore release a ref it did
not own (double release on the owner's Drop). Delete both methods so the
only release path is dropping an `AbortSignalRef`, and adopting a raw
`+1` into one stays `unsafe`.

Callers:
- h2 `SignalRef` and `Subprocess.abort_signal` / the spawn locals now hold
  an `AbortSignalRef`; `ServerWebSocket.signal` too, adopting the request
  context's ref at the upgrade hand-off.
- `RequestContext::signal_release`, fetch's `SignalRef` guard and
  `FetchTasklet::clear_abort_signal` adopt the raw `+1` they already own at
  the release point.

Add a source lint pinning release shims of this kind
(`WebCore__AbortSignal__unref`, `JSC__ArrayBuffer__deref`, `NapiEnv__deref`,
`CookieMap__deref`) to their owner's release hook.
…umers keep on a signal

Bundles what a native operation holds on an AbortSignal while in flight:
the ref, the pending-activity count, and the native listener it
registered (keyed by ctx). Drop removes the listener, then drops the
pending-activity count, then releases the ref. Holders store it as
Option<PendingActivityRef> and release by taking it out.

Converted: Subprocess.abort_signal, RequestContext.signal (the WebSocket
upgrade now moves the hold into ServerWebSocket.signal instead of
adopting a raw pointer), and Response's BodyAbortListener, which was this
struct written out by hand. AbortSignal::new() returns an AbortSignalRef
since every caller wrapped it.

The Drop order removes the listener before dropping pending activity.
Subprocess did the reverse, which let cleanNativeBindings see an
unobserved signal and cancel an AbortSignal.timeout() the caller was
still holding; spawn now behaves like fetch here, with a test.
…condition

The abort listener is attached unconditionally before the sync-only tail
of spawn_maybe_sync, so the second attach inside watch_or_reap's Ok arm
could never see a signal. The new spawn-signal test now asserts the child
exited before the timer fired instead of silently skipping the wait.
@robobun
robobun force-pushed the farm/74f3f996/abort-signal-unref-unsafe branch from fb968d4 to e11190c Compare August 12, 2026 10:44
@robobun

robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main: the only conflicts were the upgrade hand-off lines in server_body.rs against #37477's UpgradeState, resolved to upgrader.signal.replace(None) next to the new enum. Also added the Bun.spawnSync twin of the timeout case (e11190c), since the sync path releases the hold the same way and also never fired on main. Rebuilt and re-ran the lint, spawn-signal, upgrade, bun-server abort, http2 signal and websocket close tests plus the server abort-signal leak fixture on the rebased branch; all pass with balanced counts.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/api/bun/subprocess.rs`:
- Around line 364-368: Update abort_signal_ref to return an owned cloned
AbortSignalRef rather than a borrowed &AbortSignal, cloning the value obtained
from JsCell::get() and adjusting its signature and callers as needed while
preserving None when no signal is attached.

In `@test/js/bun/spawn/spawn-signal.test.ts`:
- Around line 140-147: Increase the timeout window used by both spawn signal
tests, selecting a larger TIMEOUT_MS when isASAN or isDebug is enabled and
retaining the existing default otherwise. Import isASAN and isDebug from harness
alongside bunEnv and bunExe, and use AbortSignal.timeout(TIMEOUT_MS) in both
tests. Keep expectTimeoutAfterExit’s exitCode and signal.aborted preconditions
unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: df8a4e53-69ff-4b78-85b2-fbe04d403225

📥 Commits

Reviewing files that changed from the base of the PR and between 9a543cc and e11190c.

📒 Files selected for processing (13)
  • src/jsc/AbortSignal.rs
  • src/runtime/api/bun/h2_frame_parser.rs
  • src/runtime/api/bun/js_bun_spawn_bindings.rs
  • src/runtime/api/bun/subprocess.rs
  • src/runtime/server/RequestContext.rs
  • src/runtime/server/ServerWebSocket.rs
  • src/runtime/server/mod.rs
  • src/runtime/server/server_body.rs
  • src/runtime/webcore/Response.rs
  • src/runtime/webcore/fetch.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • test/internal/source-lints/refcount-release-owner.test.ts
  • test/js/bun/spawn/spawn-signal.test.ts

Comment thread src/runtime/api/bun/subprocess.rs Outdated
Comment thread test/js/bun/spawn/spawn-signal.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed after fb968d4/e11190c and both earlier nits are addressed (dead second attach block deleted; the timeout test now asserts signal.aborted === false before waiting and covers spawnSync). No new issues found this run.

Given the scope — refcount/pending-activity/listener lifetime rework across Subprocess, RequestContext→ServerWebSocket, Response, h2, and the fetch adopt sites, plus an intentional Drop-order behavior change and merge-order coupling with #37577/#37549/#32009/#37420 — a human pass over the ref balance at each converted holder is still worthwhile.

What was reviewed

  • PendingActivityRef Drop order vs. eventListenersDidChange timer cancel; matches the fetch order and is pinned by the new spawn/spawnSync tests.
  • Each converted holder releases at the same program point as before (take-then-drop replaces the hand-written unref sequences); set_signal_aborted clones a ref before firing so re-entrant on_abort/finalize_without_deinit emptying the cell can't UAF.
  • ServerWebSocket.signal moved from Cell<Option<NonNull>> to Cell<Option<PendingActivityRef>> — checked that on_close's take() + scopeguard + as_deref() reads and finalize's box drop cover all release paths.
  • The source-lint test's exact-count assertion and FN_HEADER scan look correct for the four shims listed.
Extended reasoning...

Overview

This PR removes the safe AbortSignal::unref(&self) / detach(&self, ctx) methods (which allowed safe code to release a ref it did not own) and replaces the hand-written ref/pending-activity/listener release sequences at each native holder with a new RAII type, abort_signal::PendingActivityRef. Converted holders: Subprocess.abort_signal, RequestContext.signal (moved into ServerWebSocket.signal on upgrade), and Response::BodyAbortListener. FetchTasklet, fetch's SignalRef, and h2's SignalRef are updated to adopt into AbortSignalRef at the release point but not fully converted (documented follow-ups pending #37549/#37420). AbortSignal::new() now returns AbortSignalRef directly. Three NonNull shim helpers in RequestContext.rs are deleted. A dead second attach block in the spawnSync tail (my prior finding) is removed. Two tests are added: a source lint asserting the four release shims are called only from their RAII owner's release hook, and a spawn/spawnSync case for the one behavior change (timeout signal still fires after child exit).

Security risks

None identified. This is internal refcount plumbing; no user-input parsing, auth, or crypto is touched. The change closes a soundness hole (safe double-release) rather than opening one.

Level of scrutiny

High. Per REVIEW.md, native memory safety around intrusive refcounts across FFI is the most-blocked category. Every converted site must release exactly the same counts (ref, pending-activity, listener) at exactly the same program points as before, on every path including error/abort/re-entry. The Drop order in PendingActivityRef is load-bearing (swapping the two lines regresses the new test). The set_signal_aborted change from borrowing the cell to cloning a ref before firing is specifically there because abort listeners can synchronously re-enter and empty the cell — that's the kind of subtlety a human should confirm across all the fire-then-drop sites (on_abort, finalize_without_deinit, ServerWebSocket::on_close).

Other factors

  • The design shape (PendingActivityRef) was suggested by a maintainer and iterated on in-thread; the final revision hasn't had a human review pass.
  • Merge-order interactions with four in-flight PRs are documented but require whoever merges to be aware of them.
  • My two prior nit-level findings were addressed exactly as suggested; the follow-up commit e11190c extended the test to spawnSync.
  • Verification in the description is thorough (ASAN debug build, leak fixture with per-request signal creation, the affected test suites) but self-reported.
  • The comment-cop bot flagged several multi-line comments; the author trimmed most and justified the four that remain (rustdoc, load-bearing order note, the "why no unref" note). Those justifications read as correct to me.

Comment thread src/runtime/api/bun/subprocess.rs Outdated
Comment thread test/js/bun/spawn/spawn-signal.test.ts Outdated
Use a child that does not boot a JS VM, give debug/ASAN builds a 3s
window instead of 1s, and run the two cases concurrently so the wider
window is paid once.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — 94e81cc addresses the timeout-window concern well (the --version child + 3s debug/ASAN window + describe.concurrent is a better fix than what I'd proposed, since it stays under the 5s per-test ceiling). No further findings this run. Given the scope — refcount/pending-activity/listener lifecycle rewired across spawn, RequestContext, ServerWebSocket, http2, Response and fetch, plus one intentional behavior change to the Drop order — a maintainer look is still worthwhile.

What was reviewed:

  • PendingActivityRef::drop ordering vs. eventListenersDidChange timer cancellation, and that every converted holder releases at the same program point as before.
  • The upgrade hand-off (RequestContext.signalServerWebSocket.signal) and set_signal_aborted taking its own signal_ref() so re-entrant abort listeners emptying the cell can't dangle.
  • abort_signal_ref() now returning an owned AbortSignalRef (dfa1b41) — the spawnSync caller no longer holds a JsCell borrow across anything that could clear it.
  • The h2 SignalRef::drop reorder (parser deref before signal ref drop) — neither release observes the other.
Extended reasoning...

Overview

This PR removes AbortSignal::unref(&self) / detach(&self, ctx) — safe methods that could double-release a C++ intrusive refcount — and replaces the hand-balanced (ref, pending-activity count, native listener) triple at each holder with a new RAII type, abort_signal::PendingActivityRef, whose Drop releases all three in a fixed order. Converted holders: Subprocess.abort_signal, RequestContext.signal (moved to ServerWebSocket.signal on upgrade), and Response::BodyAbortListener. FetchTasklet, fetch's SignalRef, and h2's SignalRef are minimally adapted (adopt-at-release) pending in-flight PRs that replace those fields. AbortSignal::new() now returns AbortSignalRef directly. A new source-lint test enforces that the four intrusive-release FFI shims are only called from their RAII owners' release hooks.

Security risks

None identified. This is an internal memory-ownership refactor with no user-input parsing, auth, or network-protocol surface changes. The one behavior change (spawn's AbortSignal.timeout() now keeps running after the child exits, matching fetch) is a bug fix, not a security-relevant change.

Level of scrutiny

High. Per REVIEW.md, native memory safety is the most-blocked review category, and this PR rewires refcount / pending-activity / listener lifecycle across six subsystems (spawn, server request context, server WebSocket, http2, Response, fetch). Each converted site must release at exactly the same program points as before, on every path including error/abort/finalize. The unified Drop order is an intentional behavior change whose correctness depends on eventListenersDidChange semantics on the C++ side. This is not a mechanical change a bot should approve alone.

Other factors

All three of my prior inline findings (dead second attach block, vacuous test precondition, tight timeout window) were addressed across fb968d4 / dfa1b41 / ad5b7ec / 94e81cc, and both CodeRabbit threads are resolved. The PR has strong test coverage (source lint fails on main, spawn-signal timeout cases time out on main, leak fixture verified with balanced counts under ASAN), the design was suggested by a maintainer per the description, and the verification section lists the relevant abort/leak/upgrade suites as passing. The bug hunting system found nothing this run. Deferring rather than approving because the breadth of memory-lifecycle changes across critical paths warrants a human maintainer's sign-off.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three rounds of prior feedback are addressed (94e81cc's --version child + 3s debug/ASAN window + describe.concurrent is the right fix — my 10s suggestion would have overshot the 5s per-test ceiling), and this run found no bugs. Given the scope — refcount/pending-activity/listener lifecycle rewired across Subprocess, RequestContext→ServerWebSocket, Response, http2 and fetch, a new public PendingActivityRef, and one intentional observable change to AbortSignal.timeout drop ordering — a maintainer sign-off is still worthwhile.

What was reviewed:

  • PendingActivityRef::drop order (listener → pending-activity → ref) and that every converted holder releases at the same program point as before.
  • The RequestContext→ServerWebSocket signal move on upgrade, and set_signal_aborted cloning its own ref before firing listeners that can empty the cell.
  • http2 SignalRef::drop reordering (parser deref now precedes signal drop) — neither release observes the other.
  • The new source-lint's declaration/owner-call counting against the four release shims.
Extended reasoning...

Overview

Removes AbortSignal::unref/detach (safe &self methods that could double-release) and routes the FFI release shim exclusively through AbortSignalRef's Drop. Introduces abort_signal::PendingActivityRef — an owned bundle of (ref, pending-activity count, one native listener) whose Drop releases all three in a fixed order — and converts Subprocess, RequestContext/ServerWebSocket, and Response::BodyAbortListener to hold Option<PendingActivityRef>. FetchTasklet, fetch's SignalRef, and http2's SignalRef are minimally adapted (adopt-then-drop) pending sibling PRs. Adds a source-lint test pinning each release shim to its single owner, and two spawn/spawnSync tests for the observable drop-order fix.

Security risks

None identified. This is an internal memory-ownership refactor; no user-controlled input parsing, auth, or boundary validation is touched.

Level of scrutiny

High. This is native memory-safety code in REVIEW.md's most-blocked category: intrusive C++ refcounts balanced across FFI, GC pending-activity, and listener lifecycle, spread across 11 source files with a hand-off (RequestContext→ServerWebSocket on upgrade) and one intentional behavior change. The design was maintainer-suggested (Jarred-Sumner), so a maintainer confirming the converted holders and the deferred ones (fetch/fs left for #37549/#37420) match intent is appropriate.

Other factors

Three prior automated review rounds each surfaced a nit that was fixed: the unreachable second spawnSync attach block was deleted (fb968d4), the timeout tests gained a hard precondition assertion (fb968d4), and the timing window was widened for debug/ASAN with a lighter child and concurrent execution (94e81cc). CodeRabbit's two findings (owned abort_signal_ref return, timeout scaling) were also addressed (dfa1b41, 94e81cc). The author ran the leak fixture (7,650 requests, balanced counts) and the full spawn/server/websocket/http2/fetch abort test set on a debug+ASAN build. All review threads are resolved. No bugs were found in this run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants