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?
6f866ac to
8e980dc
Compare
|
Retargeted onto main after #40478. That PR gave What changed against the earlier revision:
Rebuilt and ran |
There was a problem hiding this comment.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟡
packages/bun-usockets/src/bsd.c:171— nit:bsd_recvmmsggained amax_packetsparameter (used by loop.c to cap shared cluster UDP sockets to 1 packet per dispatch viau->shared_fd ? 1 : LIBUS_UDP_RECV_COUNT), but the_WIN32branch was not updated: theforloop at line 150 and thereturnat line 171 still hard-codeLIBUS_UDP_RECV_COUNT, so the new argument (and the clamp at line 148) is dead on Windows.Extended reasoning...
On Windows, a
node:clusterworker adopts a shared UDP socket (via the newbsd_socket_import/us_create_udp_socket_from_fd(..., shared=1, ...)path added in this diff —src/js/node/dgram.tssets$sharedFd,udp_socket.rs:670forwards it,udp.c:209recordsudp->shared_fd = 1). When the poll fires readable,loop.c:991callsbsd_recvmmsg(fd, &recvbuf, MSG_DONTWAIT, 1); on Linux/macOS this reads at most one datagram so other workers sharing the duplicated kernel socket get a fair share, but on Windows the loop still drains up toLIBUS_UDP_RECV_COUNTpackets per event. Relative to the base branch (which had nomax_packetsand no shared-UDP path) this is not a regression, but it defeats the intent of the change on one platform: a single Windows worker can monopolise the shared receive queue while its siblings starve. Fix: replace bothLIBUS_UDP_RECV_COUNToccurrences in the_WIN32arm withmax_packets.Verification: nit — The
_WIN32branch ofbsd_recvmmsgwas not updated to use the newmax_packetsargument. Atpackages/bun-usockets/src/bsd.c:150the loop isfor (int i = 0; i < LIBUS_UDP_RECV_COUNT; i++)and at line 171 it returnsLIBUS_UDP_RECV_COUNT, while the Apple branch (lines 175, 180, 192) and Linux branch (line 195) were changed to usemax_packets. The clamp at line 148 (`if (max_packets
|
Closing this as superseded by #40204 and #40516. #40204 converts the same function the same way, in a larger scope: Merging this first would only give both of them conflicts in One finding from this revision carries over to #40204 once it is rebased onto #40478 (where |
Problem
spawn_maybe_sync(src/runtime/api/bun/js_bun_spawn_bindings.rs), which builds aSubprocessforBun.spawnandBun.spawnSync.Subprocessas&mutfor its whole length, but stdin setup, an already aborted signal, and thespawnSyncloop all re-enter the object before it returns. Each collision had its own raw-pointer workaround. The ref count started at 2 for two holders named in a comment, and every exit balanced it by hand.Fix
RefPtr<Subprocess>. 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.finalizeturns the wrapper'sBoxback into itsRefPtr. ThespawnSynctail releases its own through a scope guard.&Subprocess(every field isCell-style), so the workarounds become plain field access.Subprocessbefore and still do, now with an explicitinto_raw(): a release there would free the object without its teardown while pipes still point at it.test/js/bun/spawn/(35 files) andtest/js/node/child_process/under the debug build. Windowscargo checkand clippy are clean.Background
Subprocessis intrusively ref-counted: the last holder to release frees it. Holders are the JS wrapper, the exit handler, and the stdin pipe while open.RefPtr<T>(bun_ptr, since bun_ptr: RefPtr releases on Drop; remove ScopedRef/IntrusiveRc/DestructorCtx #40478) is one counted ref held as a value:clonetakes a ref, drop releases it,into_raw/from_rawmove it across an FFI boundary.m_ctxback as aBox<Subprocess>. Other holders may still be alive then, sofinalizemust not drop it as aBox.spawnSyncnever creates a JS wrapper. It runs an isolated event loop until the child exits, then tears theSubprocessdown itself.Notes
This PR was first stacked on #37665, which added an
OwnedRef<T>type for this purpose. #40478 gaveRefPtr<T>that role (release on drop,Clone,into_raw/from_raw), so the PR was redone on top of main withRefPtrin place ofOwnedRef.The leak fix the earlier revision carried (the
spawnSynctail returned withoutfinalizewhen building the result threw) landed on main in #39564. The scope guard here covers the same exits.to_js_from_ptr(*mut)becomesto_js_from_ref(RefPtr), which moves the wrapper's ref intom_ctx.Writable::inittakes&Subprocessand passesas_ctx_ptr()to the fourStaticPipeWriter::createsites. The stdin source handle is aBackRef::new, the abort signal is stored through theCell, and the failed-watch()exit notification holds its ownRefPtrclone instead of a raw pointer behind a flag. Thesubprocess_ipc_ownerhelper (a null check on a pointer that cannot be null) is gone.Ref count per exit, before and after.
Writable::initerror: two derefs vs one drop, both reach 0. Handler installed, then the stdinReadableStreamsetup throws: leaked before, leaked now (into_raw()). Buffer-stdinstart()fails inspawnSync: same. Windows IPC setup error: one deref vs one drop. NormalBun.spawnreturn: wrapper + handler both times.Bun.spawnSync: the function's ref is consumed byfinalize_ownedwherefinalize(Box::from_raw(..))ran before. The earlierOwnedRefrevision dropped the function's ref on the two leaking exits as well. That is the one place this revision differs from it. On Windows the uv exit callback fires withoutwatch(), so the handler's release could then free the object withfinalize_streamsnever run, while the readers and the extra stdiouv_pipe_thandles still point at it.Test results in the container used here: two tests in
spawnsync-isolated-event-loop.test.ts(the GC keep-alive ones from #40508) hit the 5 s timeout under the debug ASAN build. Their fixtures printOKin about 7 s with and without this change.spawn_waiter_thread.test.tsfails its CPU-time bound the same way on main's build.child_process.test.ts"default shell" needs$SHELL. Everything else passes: spawn.test.ts 140 pass, the other 34 spawn files 207 pass, child_process 130 pass.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.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. Each collision had its own workaround. The binding became the shared form every field ofSubprocessis built for,Writable::inittakes&Subprocess, and the workarounds became plain field access. The allocation became anOwnedRef: the function's ref is a local, the exit handler and the JS wrapper each take a clone where they are installed, and a release is a drop.