spawn: hold the Subprocess as an OwnedRef across spawn_maybe_sync - #37700
spawn: hold the Subprocess as an OwnedRef across spawn_maybe_sync#37700robobun wants to merge 2 commits into
Conversation
|
Warning Review limit reached
Next review available in: 2 minutes 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 |
|
Updated 5:53 AM PT - Aug 12th, 2026
✅ @robobun, your commit 835077dd3c0304aa2149c6854d122869fe0b086a passed in 🧪 To try this PR locally: bunx bun-pr 37700That installs a local version of the PR into your bun-37700 --bun |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it reworks unsafe pointer/aliasing invariants in spawn_maybe_sync (provenance of subprocess_nn, the exit-notification guard's liveness reasoning), a human look would still be worthwhile.
Checked: NonNull::from(&Subprocess) provenance is sufficient for all consumers (R-2 interior mutability, BackRef<_, Shared> only yields &T); as_ctx_ptr() contract matches what StaticPipeWriter::create needs; the defer! guard only dereferences subprocess_nn when send_exit_notification is set, which happens only on the !IS_SYNC path where the JS wrapper owns the box past function return; the let-chain reordering in the stdin Pipe block is semantically equivalent; abort_signal.set ordering vs add_listener is unchanged.
Extended reasoning...
Overview
This PR changes spawn_maybe_sync to bind the freshly-boxed Subprocess as &SubprocessT<'static> instead of &mut, collapsing several per-site unsafe workarounds (raw (*subprocess_ptr) writes, a subprocess_ptr_exit copy, lifetime-erasing casts) into plain shared-field access. Writable::init drops its &mut Subprocess parameter to &Subprocess and routes the four StaticPipeWriter::create sites through the existing Subprocess::as_ctx_ptr() helper. Writable::pipe_sink is widened to pub(in crate::api) to match its siblings. Net: −1 unsafe block, −4 &mut→*mut casts, −2 lifetime-erasing casts.
Security risks
None. No user-facing behavior, validation, or resource-handling changes; this is a type-level refactor of how an already-boxed allocation is borrowed.
Level of scrutiny
High. spawn_maybe_sync is memory-safety-critical (the most-blocked category per REVIEW.md): it wires back-pointers that re-enter the same allocation, arms a defer! guard that runs after finalize on the sync path, and hands the box to a JS wrapper on the async path. The change is a strict tightening (&mut → &), but the soundness of NonNull::from(&T) provenance for downstream consumers, and the exit-guard's flag-gated dereference, deserve a maintainer's confirmation.
Other factors
I verified: as_ctx_ptr()'s documented contract ("callbacks deref it as &*const shared") matches the new call sites; BackRef::new and BackRef<_, Shared> only ever yield &T, so shared-read provenance from NonNull::from(subprocess) suffices given all Subprocess mutation goes through Cell/JsCell; send_exit_notification is only set on the !IS_SYNC branch (after to_js_from_ptr), so the guard's subprocess_nn.as_ref() never runs on the spawnSync path where finalize has already freed the box; the stdin Writable::Pipe let-chain checks the same two conditions as the old matches! && cond { if let } shape; the two abort_signal.set calls still happen after add_listener. 208 spawn tests pass per the description. No prior review comments to address.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Can we use a better RAII container type instead?
spawn_maybe_sync kept a &mut Subprocess live from construction to the end of the function while Writable::init, PipeReader::start, AbortSignal::add_listener and (for spawnSync) the isolated event loop re-entered the same allocation through their own back-pointers, and worked around the borrow per site with raw writes through subprocess_ptr. The binding is now a shared &Subprocess<'static>, which is the access mode every field of Subprocess is already designed for (Cell and JsCell, R-2), so the stdin source back-reference and the abort_signal stores go through it directly, subprocess_nn is NonNull::from(subprocess), and the unsafe block around BackRef::from_raw plus the subprocess_ptr_exit copy disappear. The exit-notification defer! keeps dereferencing a raw pointer (now subprocess_nn) because the guard outlives finalize on the spawnSync path; its SAFETY comment records that the flag is only set on the async path where the JS wrapper owns the allocation. Writable::init takes &Subprocess as well and hands StaticPipeWriter::create the existing as_ctx_ptr() accessor instead of casting the &mut four times; pipe_sink is widened to pub(in crate::api) so the bindings can use it. Only the borrow kind changes: &T and &mut T are the same pointer, the Cell accesses compile to the same loads and stores, and NonNull::from replaces an always-true null check.
6f866ac to
8e980dc
Compare
|
Reworked in 8e980dc, now stacked on #37665 ( |
…s it up spawn_maybe_sync built the Subprocess with two refs baked into the count (one standing for the JS wrapper, one for the exit handler), kept a raw pointer to it, released both by hand on the Writable::init error path, notified a failed watch() through a raw pointer in a scope guard, and on the spawnSync path reconstituted a Box from the pointer to call finalize; the throwing exits of the spawnSync tail skipped that and leaked the Subprocess. The allocation is now an OwnedRef held by the function for as long as it uses the object, and every other holder takes its ref where it is installed: the exit handler gets a clone turned into its raw owner pointer, to_js_from_ref consumes a clone for the wrapper, the exit delivery guard holds a clone of its own, and the spawnSync tail wraps the function's ref in a guard that runs finalize_owned, so the throwing exits tear the Subprocess down as well. finalize(Box<Self>) converts the wrapper's Box back into the same OwnedRef and shares finalize_owned with that tail. The Writable::init error path drops the ref instead of calling deref twice.
8e980dc to
835077d
Compare
There was a problem hiding this comment.
I re-reviewed after 835077d and found no further issues. The Windows IPC error-path fix is correct: with the stray subprocess.deref() removed, that return leaves the exit handler's ref as the sole holder, matching the pre-PR state. Given this reworks the ref-count model across every exit of spawn_maybe_sync (a path where the earlier revision did miss a cfg-gated release site), a human pass over the balance would still be worthwhile.
What was checked:
- Ref-count balance on every exit of
spawn_maybe_syncfor bothIS_SYNCvalues, including theWritable::initerror arm, thedeliver_exitguard, and the spawnSync scopeguard tail — all end at the same holders as the oldinit_exact_refs(2)model. - Drop ordering: the
deliver_exitguard drops before bothdeliver_exitandspawn_ref, so the Subprocess is alive when the deferredon_exitruns. finalize_ownedvs oldfinalize: the internalthis.deref()calls only fire when their matching refs exist, soownedcannot dangle before its owndrop.
Extended reasoning...
Overview
This PR replaces the raw-pointer + init_exact_refs(2) ownership of Subprocess in spawn_maybe_sync with an OwnedRef held by the function itself, from which the exit handler and JS wrapper each take an explicit clone at their installation sites. to_js_from_ptr becomes to_js_from_ref(OwnedRef), finalize(Box<Self>) reconstructs the wrapper's OwnedRef and delegates to a new finalize_owned, the failed-watch() guard carries its own ref instead of dereferencing a raw pointer, and the spawnSync tail arms a scopeguard so the throwing exits (has_exception, to_buffered_value?, create_resource_usage_object?) also tear the Subprocess down. Writable::init now takes &Subprocess and uses as_ctx_ptr() at its four StaticPipeWriter::create sites. Net: 4 fewer unsafe blocks in js_bun_spawn_bindings.rs, one added in subprocess.rs (the Box → OwnedRef reconstruction in finalize).
Prior review
My earlier pass flagged one bug: the Windows-only windows_configure_server error path still had a hand-written subprocess.deref() that, combined with spawn_ref's new Drop, would over-release and free the Subprocess while the exit handler still held a raw pointer. Commit 835077d removed that deref(), and I verified that under the new model this leaves the count at exactly the exit handler's ref on that return — the same state the old code left. The author also audited every remaining .deref() on this count (on_stdin_destroyed, the two in Writable::init's ReadableStream error arms, on_process_exit, the two in finalize_owned) and each pairs with a ref that still exists under the new model. My second finding (no test for the spawnSync-tail leak fix) was answered: those exits require a pending termination exception at a specific point or an OOM building the result, neither of which can be made deterministic; the fix is documented rather than guarded by a flaky test.
Security risks
None. This is an internal ownership refactor; no user-facing surface, parsing, or trust boundary changes.
Level of scrutiny
High. Bun.spawn/spawnSync are among the most-exercised APIs, the function has many exits (some cfg-gated), and the change swaps a pre-counted model for an explicit-clone model — every hand-written release site had to be re-evaluated. The first revision did miss one Windows-only site, which is exactly the failure mode REVIEW.md warns about for ref-count model changes ("reference counts provably balanced on every terminal path"). The current revision looks correct across every path I traced, but a maintainer familiar with the spawn lifecycle should confirm.
Other factors
The PR is stacked on #37665 (OwnedRef itself). The listed spawn test suites pass under ASAN debug. cargo check --target x86_64-pc-windows-msvc was run for the Windows path. No new tests, which is defensible for a behaviour-preserving refactor whose one incidental behaviour change (spawnSync-tail leak) is not deterministically reachable.
Problem
Bun.spawn. This is a lifetime cleanup of the one function that builds aSubprocessfor bothspawnandspawnSync.Subprocessas&mutfor its whole length, but what it wires up re-enters the object before it returns (stdin setup runs JS, an aborted signal fires at once,spawnSyncruns a whole event loop). Each collision had its own raw-pointer orunsafeworkaround.deref()calls and raw pointer dereferences, so every exit path had to balance the count by hand.spawnSyncpath, exits that throw after its event loop has finished returned without tearing theSubprocessdown, leaking it with itsProcessand buffers.Fix
&Subprocess(every field isCell-style, built for shared access), so the workarounds become plain field access. Commit 2 holds the allocation as anOwnedRef: the function's ref is a local, the exit handler and the JS wrapper eachclone()one where they are installed, and a release is a drop.spawnSynchas no wrapper and releases the function's ref at teardown).spawnSyncteardown runs from a scope guard armed once its event loop has drained, so the throwing exits tear theSubprocessdown too. This is the only behaviour change. The failed-watch()exit notification holds its own ref instead of a raw pointer behind a flag.cargo checkandclippyclean (Windows target too), ASAN debug build, existing spawn suites pass (spawn.test.ts plus eleven others, 0 failures). No new test; the leak fix has no test of its own. spawn-pipe-leak.test.ts timed out on the loaded ASAN build, RSS flat as far as it got.Background
Subprocessis intrusively ref-counted (bun_ptr::RefCount): the count lives inside the object and the allocation is freed when the last holder releases. Holders are the JS wrapper, the process exit handler, and open stdio pipes.OwnedRef<T>(bun_ptr) is one counted ref held as a value:newtakes the first ref,clonetakes another, drop releases, andinto_raw/from_rawcarry a ref across an FFI or callback boundary without touching the count. It derefs to&Tand is one pointer wide.Box<Subprocess>. That is whyfinalizeturns theBoxback into anOwnedRefinstead of dropping it: other holders may still be alive.spawnSyncnever creates a JS wrapper. It runs an isolated event loop until the child exits, builds a plain result object, and has to tear theSubprocessdown itself.Original description
Stacked on #37665 (the base of this PR is that branch). Two commits: the original one, which binds the Subprocess as
&Subprocessinstead of&mutacrossspawn_maybe_sync, and, following review ("Can we use a better RAII container type instead?"), a second one that holds it as anOwnedRef.What
1.
&Subprocess, not&mut(first commit)spawn_maybe_syncmaterialised the freshly boxedSubprocessas&mutand kept that borrow live to the end of the function, although the code in between hands out back-pointers to the same allocation and re-enters it (Writable::initruns JS,PipeReader::startcan reachon_close_io,AbortSignal::add_listenerfires immediately for an already aborted signal, thespawnSyncpath runs its whole isolated event loop). Each collision had its own workaround: anunsafeblock writing the stdin source through the raw pointer plusBackRef::from_raw, a lifetime-erasing cast plus null check forsubprocess_nn, asubprocess_ptr_exitcopy for the exit-notification guard, raw writes toabort_signal. The binding became the shared form every field ofSubprocessis built for (Cell/JsCell, the R-2 convention on the struct),Writable::inittakes&Subprocess(it only called&selfmethods and cast to*mutat its fourStaticPipeWriter::createsites, which now get the existingas_ctx_ptr()), and the workarounds became plain field access.2. The allocation is an
OwnedRef(second commit)What was left was the container itself:
heap::into_raw(Box::new(..)), anunsafe { &*ptr }for the body, a count constructed asinit_exact_refs(2)with a comment saying which two holders the 2 stood for, two hand-writtenderef()calls on theWritable::initerror path, a raw pointer dereference inside the exit-notificationdefer!, andBox::from_rawat the end of thespawnSyncpath so thatfinalize(Box<Self>)could be called. Now:spawn_refis the ref this function holds while it wires the object up, so the body reads it safely and it is alive for every use; on the async path it drops at every exit. The exit handler and the JS wrapper take their own refs where they are installed (on_process_exitandfinalizerelease them, as before), so once the function returns the count is the same 2 as before. TheWritable::initerror path, where neither exists yet, isdrop(spawn_ref)instead of twoderef()calls, and the Windows IPC setup error path (the one other place that released the wrapper's stand-in ref by hand) loses itsderef(), sincespawn_refdropping on the way out is that release now; with it left in, that path would have released one ref too many.to_js_from_ptr(*mut)becomesto_js_from_ref(OwnedRef<Self>), whichinto_raws the ref intom_ctx;finalize(self: Box<Self>), the codegen's spelling of that same ref, turns it back into anOwnedRefand calls the newfinalize_owned, which does the teardown and drops it. ThespawnSyncpath callsfinalize_ownedwithspawn_refitself, since there the function's own ref plays the wrapper's part; it does so from ascopeguardarmed once the isolated loop has finished, so the throwing exits of that tail (has_exception, the?onto_buffered_valueand oncreate_resource_usage_object) tear the Subprocess down too, where until now they returned withoutfinalizeand leaked it together with itsProcessand buffers. That is the one behaviour change; the state at those exits is the same as at the normalfinalizecall a few lines further down (the loop has drained), and the normal exit drops the guard at exactly the point wherefinalizewas called before.watch()holds anOption<OwnedRef<..>>(a clone taken only on that path) instead of dereferencing a raw pointer under a flag, so the guard is safe code and the object is alive by construction when it runs.spawn_ref.as_non_null()directly, so thesubprocess_ipc_ownerhelper (a null check on a pointer that cannot be null) is gone; the abort listener is registered withas_ctx_ptr(), the same expressionclear_abort_signaluses to unregister it;record_stdio_pipe_ownership(Windows) getsas_ptr().Across both commits
js_bun_spawn_bindings.rsgoes from 32unsafeblocks to 28 (theBackRef::from_rawwrite,&*subprocess_ptr, the guard's dereference,Box::from_raw) andsubprocess.rsgains the one infinalize; the 4&mut-to-*mutcasts inWritable::initare gone, and theref_countcomment explaining the 2 is replaced by the two clones at the two installation sites. No dependency changes.Why
Who holds a ref on a
Subprocessduring spawn, and for how long, is now spelled by values: the function's ref is a local, the handler's and the wrapper's are clones taken where they are installed, and a release is a drop, so none of the exits can forget one or release one twice; theunsafeblocks left in this stretch are about other handles (the VM pointer, theAbortSignal, theProcessref,ProcessExit::new's dispatch contract), none of them about whether the Subprocess itself is still alive. The cost on theBun.spawnpath is one extra non-atomic increment/decrement pair on the count (the wrapper's ref is a clone rather than the function's ref renamed), next to a fork/exec;spawnSyncperforms exactly the count operations it did before, andOwnedRefis aNonNull, so nothing changes layout.Part of a series of small type-system hardening changes.
Verification
cargo checkandcargo clippyare clean forbun_runtime, andcargo check --target x86_64-pc-windows-msvcpasses for it as well (the IPC path above is Windows-only). Debug (ASAN) build succeeds.bun bd teston spawnSync.test.ts, spawn-signal.test.ts, spawn.ipc.test.ts, spawn-pipe-start-error.test.ts, spawn-many-teardown.test.ts, spawn-maxbuf.test.ts, spawnsync-isolated-event-loop.test.ts, spawn-stdin-destroy.test.ts, exit-code.test.ts, spawn-stdin-readable-stream.test.ts and spawn-noread-leak.test.ts: 101 pass, 3 skip, 0 fail; spawn.test.ts run on its own: 140 pass, 6 skip, 0 fail. spawn-pipe-leak.test.ts (which istodounder ASAN in CI) hits its 30 s per-test timeout on this ASAN debug build on a loaded machine, at about 9 s per batch of 50 children; the RSS it prints stays flat across the batches it gets through (605, 611, 596, 598, 597 MB), which is what the test measures. The first commit had been verified earlier with spawn.test.ts, spawnSync.test.ts, spawn-signal.test.ts and spawn-stdin-readable-stream.test.ts: 200 pass, 8 skip, 0 fail.