shell: close the stdio pipes of a subprocess whose stdin writer failed to start - #38367
shell: close the stdio pipes of a subprocess whose stdin writer failed to start#38367robobun wants to merge 1 commit into
Conversation
…d to start A PipeReader that was never started still holds the parent end of its pipe in stdio_result; abort_after_failed_start dropped it without closing that fd, and left the buffer-stdin StaticPipeWriter (and the fd its failed start left behind) unreleased, so every shell command whose stdin writer failed to start leaked three sockets. PipeReader now owns the fd until start() hands it to the reader and closes it in drop otherwise, and abort_after_failed_start closes and releases the stdin writer before tearing the subprocess down.
|
Warning Review limit reached
Next review available in: 31 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 |
|
Status
|
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it touches refcount balancing and fd-ownership transfer on an unsafe-Rust error path with re-entrant callbacks (close() → on_close → on_close_io while a raw *mut ShellSubprocess is live), a human look would still be worthwhile.
Checked: the stdio_result.take() in PipeReader::start prevents a double-close (Drop only fires when start() never ran); refcount balance in abort_after_failed_start for both arms (writer's start() failed → started=false, on_close skips its deref; writer already started → on_close releases start's ref, buffer.deref() releases create's); the stdin slot is emptied through the raw this before close() re-enters on_close_io, so the re-entry sees Writable::Ignore and can't double-deref. All new Rust code is #[cfg(not(windows))]-gated.
Extended reasoning...
Overview
Fixes a three-fd leak per shell command whose buffer-stdin writer (< ${blob} / < ${bytes}) fails to register with epoll. Two changes in src/runtime/shell/subproc.rs: (1) PipeReader now owns its stdio_result fd until start() transfers it to the reader via .take(), and Drop closes any fd never handed over; (2) abort_after_failed_start now empties the Writable::Buffer stdin slot, calls close() on the writer, and releases the slot's create() ref. Plus doc-comment corrections (the old Writable::finalize comment referenced a RefPtr::Drop that doesn't exist). The test adds a SHELL_FAIL_EPOLL_WRITABLE shim mode that fails only EPOLLOUT registrations on process-created socketpairs, and a fixture that runs three cat < ${...} commands and asserts /proc/self/fd returns to baseline.
Security risks
None. This is an error-path resource leak fix; no user-facing input parsing, auth, or crypto.
Level of scrutiny
High. This is native memory-safety code — the repo's most-blocked review category. It touches intrusive-refcount balancing across a re-entrant callback (StaticPipeWriter::close → writer.close() → on_close → P::on_close_io back into the same ShellSubprocess), fd ownership transfer, and raw-pointer aliasing invariants under Stacked Borrows. I traced both arms that reach abort_after_failed_start (writer start failed vs. reader start failed after a successful writer start) and the refcounts balance in each, matching the shape of the neighbouring deinit_in_flight_io. The .take() change ensures the new Drop close and the started reader's own close are mutually exclusive. But the reasoning is subtle enough — particularly around whether writer.close() on a never-registered writer synchronously fires on_close and closes the poll's fd — that a maintainer familiar with StaticPipeWriter / PosixStreamingWriter internals should confirm.
Other factors
The PR description is exceptionally thorough (traces every ref, names the composing open PR #38354, lists every test file re-run, includes before/after repro output). The test is well-designed: bounded-poll wait for the async Closer thread pool, fd listing by inode so recycled numbers can't hide a leak, both Blob and ArrayBuffer stdin variants, both .quiet() and non-quiet reader setups, and the shim's new forget_fd on syscall(SYS_close) fixes a latent fd-recycling issue in the existing shim modes. The expectShellFault refactor into runShellFaultFixture is a clean extraction. Cross-platform checked (cargo check for darwin and windows targets). No prior human or bot reviews to consider.
|
On the one point left open above, what With #38354 applied the failed I added this trace to the Fix section of the description as well. |
|
Updated 2:37 AM PT - Aug 14th, 2026
✅ @robobun, your commit df239b5e7535a606f09ac293298e3855987199ea passed in 🧪 To try this PR locally: bunx bun-pr 38367That installs a local version of the PR into your bun-38367 --bun |
Problem
cat < ${blob},< ${bytes}) whose stdin writer fails to start leaks three sockets for the life of the process. The command itself fails as intended (bun: Cannot allocate memory, exit 1); the leak is in the cleanup. Reproduced on bun 1.4.0 and on main with an LD_PRELOAD shim that fails the writer'sepoll_ctlregistration, which is whatfs.epoll.max_user_watchesexhaustion does.ShellSubprocess::spawn_maybe_sync_impl(src/runtime/shell/subproc.rs) starts the stdin writer before the stdout/stderr readers. When that start fails,abort_after_failed_startdrops the subprocess. The twoPipeReaders were never started, so the parent end of each pipe is still inPipeReader::stdio_result, and nothing closes it:PosixBufferedReaderonly closes an fd it was started with. Two of the three fds.Writable::Bufferholds thecreate()ref of theStaticPipeWriter, normally released byon_close_iowhen the writer closes, and a writer whosestart()failed never closes.Writable::finalizeonly unrefs it; its comment relied on aRefPtrdrop that does not exist (RefPtrhas noDrop; the Zig version calledderef()here). The writer, its Blob/ArrayBuffer source and the fd its failed start left in its poll all leak. The third fd.Fix
PipeReaderowns the pipe fd untilstart()hands it to the reader (stdio_result.take()), and itsDropcloses an fd that was never handed over. Correct because the reader closes the fd on every path where it was started, so the two never both close it, and a reader dropped without starting holds a fresh pipe end that nothing else references. This covers any path that drops an unstarted reader, not just today's stdin-first ordering.abort_after_failed_startempties the stdin slot, closes the writer and releases the slot's ref. The slot is emptied first becauseclose()reports back throughon_close_io, which must find nothing left to release; it runs through the raw pointer before theBoxis reclaimed so that re-entry aliases nothing. Same shape asdeinit_in_flight_iobelow it. It is also right for the other arm that reaches this function (a reader start failing after the writer started):close()then releases the writer's ownstart()ref viaon_close, and this releasescreate()'s.close()does to a writer whose registration failed, on main today:PosixBufferedWriter::starthad already put the fd in a poll beforeepoll_ctlfailed, soPosixBufferedWriter::closegoes throughPollOrFd::close_impl, which releases that poll (unregistering is a no-op, nothing was registered), hands the fd toCloser, and callson_closesynchronously.on_closeskips thestart()ref becausestartedis false and reacheson_close_io, which sees the emptied slot. Thederef()that follows is the last ref, and the writer'sDropfinds the handle already closed. With io: leave the fd with the caller when a POSIX pipe writer fails to start #38354 the handle is alreadyClosedwhen we get here, soclose()does nothing andderef()alone frees the writer.StaticPipeWriter::startclose its fd itself on failure and hands the reader leak off to this PR. The two compose: with it,close()here finds no fd andderef()still frees the writer; the reader change is independent of it.Writable::finalizeis unchanged in behavior; its comment and theWritable::Bufferdoc now name the sites that release the ref.test/js/bun/shell/shell-pipe-read-fault.test.ts, new shim modeSHELL_FAIL_EPOLL_WRITABLE. It failsEPOLL_CTL_ADDwithEPOLLOUTon sockets the process itself created withsocketpair(), so exactly the child's stdin writer fails and the shell's writers to the fixture's own stdio keep working. The fixture runs< ${blob}and< ${bytes}quietly and one un-quieted< ${blob}(capture readers), then lists/proc/self/fdby inode until it returns to the baseline. Unfixed: nine leaked sockets (three per command), all three commands failing with ENOMEM. Fixed: the same failures and an empty list. The wait is bounded at 2s so the unfixed run reports the list rather than timing out. 5/5 runs on the ASAN debug build.syscall(SYS_close), which is how bun closes fds),bunshell.test.ts(423 pass),leak.test.ts,shell-write-fault,epipe,shelloutput,bunshell-file,bunshell-instance,exec,shell-hang,throw,lazy,pipeline_stack,shell-worker-terminate-leak,shell-cmdsub-crash;cargo clippy -p bun_runtime,cargo fmt,cargo check -p bun_runtimeforaarch64-apple-darwinandx86_64-pc-windows-msvc.leak.test.ts'smemleak_*cases andshell-blocking-pipe's heap-snapshot case time out on this machine identically with and without the change.Background
ShellSubprocess. Each piped stdio is a socketpair: the child gets one end, the parent end is wrapped in aPipeReader(stdout/stderr) or, for a Blob/ArrayBuffer stdin, aStaticPipeWriterthat writes the bytes into the child. The wrappers are created first and started afterwards; starting registers the fd with the event loop (epoll_ctlon Linux).start()is the only stdio start that returns an error to the spawn, and therefore the only wayabort_after_failed_startis reached today.RefPtris bun's intrusive refcount handle. Dropping it does not release the ref; every holder mustderef()explicitly. AStaticPipeWriterhas two refs:create()'s, held by the owner's stdin slot and released inon_close_io, andstart()'s, held while a write is in flight.on_close_iois how a stdio object tells itsShellSubprocessit closed; for stdin it empties the slot and releasescreate()'s ref. It runs from inside the writer'sclose(), which is why teardown has to empty the slot before callingclose().Closer), which is why the test waits for the fd set to drain instead of reading it once.Repro outside the test
Noticed on the way and handed off separately: the SIGTERM'd child on this path is never reaped (one zombie per failed command), because
close_processtears the watcher down before the child has exited.