spawn: release the stdio PipeReader through its pointer, not a &mut receiver - #37879
spawn: release the stdio PipeReader through its pointer, not a &mut receiver#37879robobun wants to merge 3 commits into
Conversation
…eceiver PipeReader::on_reader_done/on_reader_error ended with PipeReader::deref(self). on_close_io has already dropped the Readable's ref by then, so that deref is normally the reader's last one and frees the allocation while the &mut self receiver is still a live, protected argument. on_close_io also reaches back into the reader through the Readable's own pointer while that receiver is live. Both callbacks now take the parent pointer the BufferedReader registered and share a tail (finish) that adopts the start() ref into a ScopedRef, records the state and calls on_close_io through statement-scoped field accesses, and releases once those have ended. start() and read_all(), which can reach the same teardown synchronously (a failed registration, EOF on the first read), take the pointer as well; the spawn bindings pass the Readable's pointer and re-read the slot in between. kind() compares addresses only. Adds a source lint for the bare deref(self) / ScopedRef::adopt(self) spelling, with the remaining http instances ratcheted at their current counts.
|
Warning Review limit reached
Next review available in: 1 minute 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 (3)
Comment |
|
Status: reproduced as the source shape at src/runtime/api/bun/subprocess/SubprocessPipeReader.rs:244 and :359 on main (the new lint lists exactly those two lines with |
|
Updated 3:05 PM PT - Aug 12th, 2026
❌ @robobun, your commit 0f7364b has 2 failures in
🧪 To try this PR locally: bunx bun-pr 37879That installs a local version of the PR into your bun-37879 --bun |
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it reworks the unsafe refcount-release path that runs at the end of every Bun.spawn stdout/stderr pipe, a human look would still be worthwhile.
What was reviewed:
ScopedRef::adoptsemantics (src/ptr/ref_count.rs:992) — takes ownership of an existing ref without incrementing, sofinishcorrectly consumes thestart()ref rather than adding one.- Refcount balance in
finish: order (state →on_close_io→ guard drop) matches the oldon_reader_done/on_reader_error, and no&mut *thisoutlives the statement it's formed in. - The vtable forwarding matches the established pattern in
src/runtime/shell/subproc.rs:2230; the lint's ALLOW counts match the current tree (h2: 2, h3: 1, ProxyTunnel: 1).
Extended reasoning...
Overview
This PR converts PipeReader::on_reader_done, on_reader_error, start, read_all, and kind in src/runtime/api/bun/subprocess/SubprocessPipeReader.rs from &mut self receivers to raw *mut Self, and updates the two call sites in js_bun_spawn_bindings.rs to pass pipe.as_ptr(). The motivation is a Stacked/Tree Borrows aliasing-model violation: the old on_reader_done(&mut self) ended with PipeReader::deref(self), which frees the allocation while the receiver's protector is still live. A new shared finish(this, state) helper adopts the start() ref into a ScopedRef guard so the release goes through the raw pointer after all borrows have ended. A source-lint test ratchets the remaining deref(self) instances in src/http/.
Security risks
None. This is an internal memory-model correctness fix with no user-facing API surface, no parsing of untrusted input, and no auth/crypto involvement.
Level of scrutiny
High. Per REVIEW.md this is the "most-blocked category" — native memory safety with intrusive refcounting on a path that runs at the end of every Bun.spawn/spawnSync/child_process piped stdio. The change is not mechanical: it rewrites ~150 lines of unsafe across cfg(windows)/cfg(unix) branches, and the correctness argument depends on the distinction between ScopedRef::new (takes a new ref) and ScopedRef::adopt (takes ownership of an existing one), plus the drop ordering of the _start_ref guard relative to on_close_io. I verified ScopedRef::adopt at src/ptr/ref_count.rs:992 does not increment, so finish releases exactly the ref start() took (matching the old explicit deref(self)), and confirmed the pattern mirrors src/runtime/shell/subproc.rs:2223-2231 which already forwards the raw *mut Self for the same reason.
Other factors
The PR description is unusually thorough — it includes a standalone Miri reduction that fails under both aliasing models on the old shape and passes on the new one, and reports passing the full spawn/child_process suite on a debug ASAN build plus cargo check for both host and x86_64-pc-windows-msvc. The lint's ALLOW list matches what I found by grepping src/http/ (2 in h2 ClientSession, 1 in h3 ClientSession, 1 in ProxyTunnel). The sibling lints referenced (self-receiver-reclaim.test.ts, fn-long-mut-reborrow.test.ts) and the globAllSources helper all exist. Nothing looks wrong, but the refcount-balance and Windows-arm reasoning are subtle enough that a maintainer familiar with #37703/#37755 should confirm.
…tching instead of with lookbehinds With the lookbehinds at the head of the pattern the scan of the tree took about 6 s; matching the release token first and checking the preceding text brings it under 0.1 s, in line with the sibling lints.
| // Both calls go through the reader's own pointer because either may end | ||
| // the reader's life before returning (a failed start, or EOF inside | ||
| // read_all), at which point on_close_io has already replaced the slot, so | ||
| // the slot is re-read in between instead of reusing `pipe`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Drives the reader synchronously. EOF or an error inside the read | ||
| /// reaches `on_reader_done`/`on_reader_error`, which may release the last | ||
| /// ref, so `*this` may be gone on return. | ||
| /// | ||
| /// # Safety | ||
| /// `this` must point to a live, started `PipeReader`; no `&`/`&mut` to | ||
| /// `*this` may be live across the call. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Takes the reader's own ref for the read in flight; `finish` releases it | ||
| /// once the read ends. If registering the pipe fails, `finish` runs before | ||
| /// this returns and `*this` is freed on return, so callers must re-read the | ||
| /// `Readable` slot instead of reusing `this`. | ||
| /// | ||
| /// # Safety | ||
| /// `this` must point to a live `PipeReader` from `create()`; no `&`/`&mut` | ||
| /// to `*this` may be live across the call. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // The failure path below releases both the Readable's ref (via | ||
| // on_close_io) and the ref taken above; the guard keeps `*this` | ||
| // allocated until this returns. Its drop is then the final | ||
| // release, made through `this` with no borrow of `*this` live. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // PosixBufferedReader::start() always returns Ok(()); if poll | ||
| // registration fails it dispatches on_reader_error synchronously, | ||
| // which releases both the Readable's ref (via on_close_io) and the | ||
| // ref taken above. The guard keeps `*this` allocated for the state | ||
| // check below; its drop is then the final release, made through | ||
| // `this` with no borrow of `*this` live. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // on_reader_error already ran; `_keepalive`'s drop | ||
| // releases the last ref and deinit() closes the handle. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// `BufferedReaderParent::on_reader_done`; see [`Self::finish`] for why it | ||
| /// takes the parent pointer the reader holds rather than `&mut self`. | ||
| /// | ||
| /// # Safety | ||
| /// See [`Self::finish`]. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// `BufferedReaderParent::on_reader_error`; also the teardown `start()` | ||
| /// runs when the pipe cannot be registered. | ||
| /// | ||
| /// # Safety | ||
| /// See [`Self::finish`]. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Records the terminal `state`, tells the Subprocess this pipe is closed | ||
| /// (which drops the `Readable::Pipe` ref and takes the buffered output | ||
| /// through the Readable's own pointer into `*this`), then releases the ref | ||
| /// `start()` took. That release is normally the last one, so it has to be | ||
| /// made through the pointer the reader registered as its parent: a `&mut | ||
| /// self` receiver would still be live (and, as a function argument, | ||
| /// protected) while the allocation is freed, and `on_close_io`'s access | ||
| /// would alias it. | ||
| /// | ||
| /// # Safety | ||
| /// `this` must point to a live `PipeReader` whose `start()` ref is still | ||
| /// held, with no `&`/`&mut` to `*this` live across the call. `*this` may be | ||
| /// freed on return. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Which of the Subprocess's slots holds this reader. Compares addresses | ||
| /// only, so it never forms a reference to `*this`. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // `on_reader_done`/`on_reader_error` forward the raw `*mut Self` rather than | ||
| // autoref-ing it: they usually free `*this` (see `finish`), which must not | ||
| // happen under a `&mut self` receiver. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
Keeps each SAFETY line and the reason finish() takes the pointer; drops the prose that restated them, and hoists the lazy flag so the two start() calls fit on a line.
| // Either call may free the reader (a failed start, EOF inside read_all), | ||
| // which also clears the slot, so the slot is re-read rather than `pipe` reused. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// EOF or an error inside the read reaches [`Self::finish`], so `*this` | ||
| /// may be freed on return. | ||
| /// | ||
| /// # Safety | ||
| /// `this` is a live, started `PipeReader` with no `&`/`&mut` to it live. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Takes the ref [`Self::finish`] releases. A pipe that cannot be | ||
| /// registered is finished synchronously, so `*this` may be freed on return. | ||
| /// | ||
| /// # Safety | ||
| /// `this` is a live `PipeReader` from `create()` with no `&`/`&mut` to it live. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// # Safety | ||
| /// See [`Self::finish`]. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// # Safety | ||
| /// See [`Self::finish`]. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| /// Records the terminal `state`, has the Subprocess drop its `Readable::Pipe` | ||
| /// ref, then releases the `start()` ref, which is normally the last one. | ||
| /// Raw `this`, not `&mut self`: `on_close_io` reaches back into `*this` | ||
| /// through the Readable's pointer, and the release frees `*this`; neither | ||
| /// may happen while a receiver borrow of it is live. | ||
| /// | ||
| /// # Safety | ||
| /// `this` is a live `PipeReader` still holding its `start()` ref, with no | ||
| /// `&`/`&mut` to it live. `*this` may be freed on return. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // `on_reader_done`/`on_reader_error` usually free `*this` (see `finish`), so | ||
| // they get the raw pointer rather than a `&mut *this` autoref. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
There was a problem hiding this comment.
I reviewed this PR and the automated bug hunt found no issues. Because it reworks the unsafe intrusive-refcount release path for spawn's stdio PipeReader (converting &mut self receivers to raw *mut Self across start/read_all/on_reader_done/on_reader_error and their call sites), a human look at the aliasing and ref-balance reasoning is still warranted.
What was reviewed:
finish()order (adopt start ref → set state → take backref →on_close_io) matches the two functions it replaces; no borrow of*thisoutliveson_close_ioor the guard's drop.- Call sites in
spawn_maybe_syncre-read theReadableslot betweenstart()andread_all()(the failed-start path replaces the slot);lazy_readshoist preserves!IS_SYNC && lazy. - Windows arm of
start(): the_keepaliveguard is now built fromthisand lives acrosson_reader_error; the lazy branch's&raw mut (*this).readeravoids a receiver borrow. - Source-lint test: pattern checked against its positive/negative fixture set; the
ALLOWratchet entries name real files with the shape.
Extended reasoning...
Overview
The PR converts PipeReader::on_reader_done, on_reader_error, start, read_all, and kind from &mut self methods to unsafe fn(this: *mut Self, ..), introduces a shared finish(this, state) tail that adopts the start() ref into a ScopedRef and calls on_close_io with no receiver borrow live, updates the vtable macro lines to forward this directly, and updates the two call sites in spawn_maybe_sync to pass pipe.as_ptr(). It also adds test/internal/source-lints/self-receiver-deref.test.ts, a ratcheted grep-lint that bans releasing an intrusive refcount through a method's own receiver.
Security risks
None user-facing. This is an internal aliasing-model fix — the observable behavior (state → on_close_io → release) is unchanged and no new inputs are parsed. The risk category is memory safety: an error in the ref-balance or borrow-scope reasoning here would be a UAF or leak on every Bun.spawn stdout/stderr pipe.
Level of scrutiny
High. This is the most-blocked category in this repo's review history (unsafe refcount lifetimes across FFI vtables), it runs on every subprocess spawn, and it has platform-gated arms (#[cfg(windows)] vs POSIX) with different failure paths. The reasoning about which release is the last one, and which _keepalive guard keeps *this live across which call, is subtle enough that a maintainer familiar with the io-layer's raw-pointer conventions (#36571, #37703, #37755) should confirm it.
Other factors
- CI on
cd71f5a5shows build failures on 🐧 aarch64 / 🍎 aarch64 / FreeBSD and docker-unavailable test failures; these read as infra, but the latest commit's CI status isn't in the timeline yet. - The
comment-copbot fired on the latest commit (18:15) at several SAFETY / doc-comment blocks. Those are contract docs required by the repo's own "SAFETY comments are required above use ofunsafe" rule, not workaround justifications, so I read them as bot noise rather than an unaddressed concern — but a maintainer may want to trim further. - The description includes a Miri reduction and Windows-debug verification; the source-lint test is a real ratchet (fails on main at exactly the two removed lines), so regression coverage exists.
Problem
Bun.spawn,spawnSyncandchild_processstdout or stderr pipe. Same class as fetch: release the FetchTasklet through its raw pointer, not a &mut receiver #37703 and io: report to pipe writer parents through raw pointers, release StaticPipeWriter through its backref #37755.&mut selfreceiver, so the release that normally frees the reader runs while a protected borrow of it is still live.reborrow through <475> at alloc296[0xc] is forbidden; Stacked Borrows:not granting access to tag <490> because that would remove [Unique for <1841>] which is strongly protected) and passes with the shape used here.Fix
&mut self. Done and error share one tail: adopt the start ref into a scoped guard, record the state, ask the Subprocess to drop its ref, and let the guard release when it drops.&mutstill formed lasts one statement and ends before either happens. No refs are added or removed, only the pointer the existing releases go through changes.&mut selfstart beneath the failure path, plus close and watch (follow-ups noted at their sites since Make re-entrant runtime objects &self-only; delete AnyTask #36571), and the detach in to_js, whose release is never the last one.Background
derefdecrements it, and the decrement that reaches zero runs the destructor and frees the allocation, so any given release may be the last one.&mut selfincluded, is protected for the whole call. Freeing its target, or touching it through another pointer, is undefined behavior during the call even if the reference is never used again; a raw*mut Selfargument creates no protector.Readable::Pipeslot and start takes a second for the in-flight read.on_close_iois the Subprocess taking the buffered output and dropping its slot ref; the callback then releases the start ref, which is why that release normally frees the reader.*mut Selfon done and error; a macro line per parent decides whether the method receives that pointer or an autoref'd&mut. The layers below were already made raw for this reason in Make re-entrant runtime objects &self-only; delete AnyTask #36571.ScopedRef: a guard over a refcounted pointer.newtakes an extra ref and releases it on drop (a keepalive);adopttakes over a ref the caller already holds and releases it on drop, which places the release after everything else in the scope.Original description
Problem
PipeReader::on_reader_done(&mut self)andon_reader_error(&mut self)in src/runtime/api/bun/subprocess/SubprocessPipeReader.rs (lines 244 and 359 on main) both ended withThe reader has two refs while a read is in flight: the one
Readable::Pipeholds and the onestart()takes. A line earlier,process.on_close_io(kind)has made the Subprocess drop theReadable::Piperef, so thisderefis normally the last one anddeinitfrees the allocation while the&mut selfreceiver is still live. A reference passed as a function argument is protected for the whole call, and both aliasing models reject freeing protected memory, whether or notselfis used again afterwards. The same function also breaks the protector before that point:on_close_ioreaches back into the reader through theReadable's own pointer (Readable::pipe_reader_mut(&pipe).state, to take the buffered output) while the receiver that just wrotestateis live, which is a foreign access to protected memory.The chain below these two functions is already raw:
PosixBufferedReader::done/on_error/read/register_polltake*mutprecisely so that "the (maybe-freeing) dispatch runs under no receiver protector" (#36571, and the comment on theinject_stdio_read_errortesting hook says the same), and the vtable hands the parent a*mut Self. The macro lineon_reader_done = |this| (*this).on_reader_done();then re-introduced the protected&mutfor the one hop that does the freeing. This runs at the end of everyBun.spawn/spawnSync/child_processstdout or stderr pipe; there is no known crash from it (nothing touches the memory after the free), it is the contract that is wrong, same as #37703 (FetchTasklet) and #37755 (StaticPipeWriter).A standalone reduction of exactly this shape (owner holding one ref, the callback asking the owner to drop it and then releasing its own through the receiver) fails under Miri with both models, and passes with the shape this PR uses:
(With the owner's access removed, the trailing release alone fails at the deallocation instead; that variant is the reduction in #37703.)
Reduction
cargo miri run -- beforeandMIRIFLAGS=-Zmiri-tree-borrows cargo miri run -- beforereport the errors quoted above;-- afterexits 0 under both.Fix
The functions that can end the reader's life take the pointer the reader registered as its parent, and release through it:
on_reader_done(this)/on_reader_error(this, err)share a tail,finish(this, state): it adopts thestart()ref into abun_ptr::ScopedReffirst, records the state and takes theprocessbackref through statement-scoped field accesses, callson_close_iowith no borrow of the reader live, and the guard releases when it drops. The order of operations (state,on_close_io, release) is the one the two functions had; the only&mutformed is the call-scoped one forto_owned_slice(), which is over beforeon_close_ioruns.kind()takes the address it compares instead of&self, so nothing in this tail forms a reference to the reader. The vtable lines forwardthis, as the shell's reader in src/runtime/shell/subproc.rs already does.read_all(this): the spawn bindings call it right afterstart(), and EOF on that first read reacheson_reader_donesynchronously (the io-layerreadis already raw), so its&mut selfwas the one protected frame on thespawnSync/ non-lazy spawn path.start(this, ..): when registering the pipe fails, the teardown runs insidestart()and the keepalive guard's drop at the end ofstart()is the final release. The guard is now built fromthis(it wasScopedRef::new(ptr::from_mut(self))), and the Windows arm callson_reader_error(this, err). The io layer's ownstart(&mut self)over the reader field is still beneath the dispatch on this path; that receiver is listed as a follow-up at its site in src/io/PipeReader.rs (from Make re-entrant runtime objects &self-only; delete AnyTask #36571), along withclose/watch, which is whyPipeReader::close()/watch()are left as they are here.spawn_maybe_syncpasspipe.as_ptr()and re-read theReadableslot betweenstart()andread_all()(they already did, since a failed start replaces the slot).to_js'sdetachis not touched: its release is never the last one, becauseReadable::to_jsstill holds the slot's ref and releases it afterwards inpipe_detach.No refs are added or removed; the diff changes which pointer the existing releases go through.
Tests
test/internal/source-lints/self-receiver-deref.test.tsbans a release from thedereffamily, orScopedRef::adopt, applied to the receiver itself (deref(self),deref_with_context(self, ..),deref(&mut *self)), which is the spelling the lint in #37703 does not cover (that one looks for a pointer spelled out from a reference,deref(ptr::from_mut(self))and friends). It checks its pattern against positive and negative examples, skipsfn deref(self)items andDeref::deref(self), and ratchets the other instances in the tree at their current counts with the reason for each (h2ClientSession::on_close/maybe_release, h3ClientSession::detach,ProxyTunnel::detach_and_deref; these are the same shape and are to be converted separately, the entries keep them from multiplying). Withsrc/at main it reports exactlyand passes with this branch; the rest of
test/internal/source-lints/still passes.Verification
On the debug (ASAN) build: test/js/bun/spawn/spawn.test.ts (140 pass, 6 skip); spawnSync, spawn-pipe-read-error-leak (both the epoll variant and the
injectStdioReadErrorone, which driveon_reader_errorthrough the raw io entry), spawnsync-isolated-event-loop, spawn-unread-stdout-gc, spawn-stdout-iterate-leak, spawn-stdout-filereader-gc-uaf, spawn-noread-leak, spawn-maxbuf, spawn-many-teardown, spawn-streaming-stdout, exit-code, spawn-pipe-stale-fd-unregister, readablestream-helpers (80 pass, 3 skip); test/js/node/child_process/{child_process,child-process-stdio,child_process-node,child-process-exec} (110 pass). The three child_process failures in that run are this container's:$SHELLis unset ("should allow us to spawn in the default shell"), "it accepts stdio passthrough" passes when run alone, and the fixture of "extra stdio pipes are not double-closed on GC" (stdout/stderrignore, so no pipe reader is involved) printsOKin about 6 s on this build against the test's 5 s limit.cargo check -p bun_runtimefor the host and forx86_64-pc-windows-msvc,cargo clippy -p bun_runtimeandcargo fmt --checkare clean.On a Windows x64 debug build of this branch: test/js/bun/spawn/spawn-pipe-start-error.test.ts (Windows debug only; it fault-injects the
start_with_current_pipefailure, so it is the test of the Windows arm ofstart()and of the keepalive's drop being the final release) and spawn-pipe-read-error-leak.test.ts (the injected variant, which enterson_reader_errorfrom the libuv reader) pass; spawn.test.ts (124 pass, 19 skip, 3 todo) and spawnSync, spawn-unread-stdout-gc, spawn-maxbuf, spawn-streaming-stdout, exit-code (26 pass, 13 skip) pass as well.