Skip to content

shell: fix use-after-free when a pipe read fails inside the spawn call - #32754

Merged
Jarred-Sumner merged 6 commits into
mainfrom
farm/7b4eaa34/shell-spawn-pipe-error-uaf
Jun 26, 2026
Merged

Jarred-Sumner merged 6 commits into
mainfrom
farm/7b4eaa34/shell-spawn-pipe-error-uaf

Conversation

@robobun

@robobun robobun commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Two heap use-after-frees in the Bun Shell, both from the same root cause: a pipe error surfacing synchronously from inside the spawn call tears down state that the spawn frame is still using. Found by syscall-fault-injection fuzzing against origin/main (release-asan).

  1. recv() failing on the eager pipe read frees the ShellSubprocess:
AddressSanitizer: heap-use-after-free (READ)
  use:   <Readable>::start_pipe_reader            shell/subproc.rs:1247
         <ShellSubprocess>::spawn_maybe_sync_impl shell/subproc.rs:888
         <ShellSubprocess>::spawn_async           shell/subproc.rs:568
         <Cmd>::transition_to_exec                shell/states/Cmd.rs:636
  freed: drop<Box<ShellSubprocess>>
         <Cmd>::deinit                            shell/states/Cmd.rs:907
         <Interpreter>::deinit_node               shell/interpreter.rs:1043
         ...
         <PipeReader>::run_yield_with             shell/subproc.rs:1920
         <PipeReader>::on_reader_error            shell/subproc.rs:2349
         <PipeReader>::read_all                   shell/subproc.rs:1991
  1. epoll_ctl() failing during poll registration frees the PipeReader:
AddressSanitizer: heap-use-after-free (READ)
  use:   <bun_io::pipes::PollOrFd>::get_poll      io/pipes.rs:41
         <PipeReader>::start                      shell/subproc.rs:2015
         <Readable>::start_pipe_reader            shell/subproc.rs:1254
         <ShellSubprocess>::spawn_maybe_sync_impl shell/subproc.rs:878
  freed: Arc<PipeReader> drop
         <PipeReader>::on_reader_error            shell/subproc.rs:2351

Cause

Cmd::transition_to_exec stages SubprocExec.interp = null before calling spawn_async, specifically so a synchronous Cmd::on_exit fired from inside the spawn cannot drive the trampoline into Cmd::deinit while the spawn frame still holds the ShellSubprocess. The backref is published, and any Done state picked up, only after the spawn returns.

The pipe callbacks bypass that gate in two ways:

  • PipeReader carries its own interp backref, populated at PipeReader::create time. spawn_maybe_sync_impl does an eager read_all() on the freshly created stdout/stderr pipes, so if that recv() fails (ENOMEM/EMFILE/ENFILE), on_reader_error reaches Cmd::buffered_output_close, which records the errno as the exit code, closes the stream, and, once every piped stream is closed, returns Yield::Next. PipeReader::finish_after_state_set runs that with its own ungated interp, landing in Cmd::deinit and freeing the Box<ShellSubprocess> that spawn_maybe_sync_impl keeps dereferencing. On a release build the follow-on access is a deterministic panic: expected Node::Cmd at Node#2, got Free.

  • The same on_reader_error can also fire from inside PipeReader::start: PosixBufferedReader::register_poll reports an epoll_ctl failure through the vtable and PosixBufferedReader::start then returns Ok(()) regardless. Cmd::buffered_output_close calls close_io, and Readable::finalize drops the Readable::Pipe slot's Arc<PipeReader> from inside the callback, so the callback's own guard becomes the last reference and the PipeReader is freed when it returns. PipeReader::start then reads self.reader.handle and writes self.reader.flags on the freed object, and Readable::start_pipe_reader held no keepalive of its own.

Fix

Two pieces, one per victim:

  • Cmd::buffered_output_close now applies the same null-interp gate Cmd::on_exit already has: if exec.interp is still null, the command is marked Done but the Yield is suspended, and transition_to_exec's existing CmdState::Done check resumes it once the spawn has unwound. This is the single choke point for every pipe-side completion path (on_reader_error, on_reader_done, on_captured_writer_done, CapturedWriter::on_iowriter_chunk).
  • Readable::start_pipe_reader now holds an Arc::clone of the PipeReader across start() and read_all(), since both can complete the reader synchronously and drop the Readable::Pipe slot's ref. This is the same keepalive PipeReader::guard_from_raw already provides for the on_reader_{done,error} callbacks themselves.

Also deleted in this PR: Cmd::spawn_arena_freed. It was only ever written and then discarded with let _ =, and the comment that justified keeping it ("initSubproc sets spawn_arena_freed = true before any pipe can close") states exactly the premise this bug disproves. The exec.interp gate now encodes the pre/post-spawn split directly.

Test

test/js/bun/shell/shell-pipe-read-fault.test.ts uses an LD_PRELOAD shim (same pattern as serve-epoll-add-fail.test.ts) with two env-selected fault modes: SHELL_FAIL_RECV makes recv() return ENOMEM, and SHELL_FAIL_EPOLL makes epoll_ctl(ADD|MOD) on pipe-like fds return ENOMEM. The epoll mode interposes syscall(2) because FilePoll registration goes through the raw syscall(SYS_epoll_ctl, ...) wrapper rather than the libc epoll_ctl symbol. Four tests cover recv and epoll each with stdout-only and stdout+stderr capture pipes. Linux-only, skipped when no C compiler is present.

Each half of the fix is independently load-bearing:

Build recv tests epoll tests
unfixed main fail (ShellSubprocess UAF) fail (PipeReader UAF)
gate only, no keepalive pass fail (PipeReader UAF)
gate + keepalive pass pass

Existing shell suites (bunshell.test.ts, bunshell-default.test.ts, shelloutput.test.ts, epipe.test.ts, exec.test.ts, file-io.test.ts, pipeline_stack.test.ts, yield.test.ts, shell-blocking-pipe.test.ts, and others, 560 tests) still pass.

Cmd::buffered_output_close now applies the same null-interp gate that
Cmd::on_exit already uses: if SubprocExec.interp has not been published
yet (transition_to_exec does that only after spawn_async returns), mark
the command Done and return Yield::Suspended instead of a runnable
Yield::Next. transition_to_exec picks the Done state up right after the
spawn.

Without this, an eager pipe read failing inside
ShellSubprocess::spawn_maybe_sync_impl (recv returning ENOMEM, EMFILE,
etc.) made PipeReader::on_reader_error drive the trampoline into
Cmd::deinit, freeing the Box<ShellSubprocess> that the spawn frame was
still dereferencing.
@robobun

robobun commented Jun 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:22 AM PT - Jun 26th, 2026

❌ @robobun, your commit 0ec6037 has 1 failures in Build #64996 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32754

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

bun-32754 --bun

@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 12 minutes and 37 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 credits.

🚦 How do rate 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 see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c1534178-e4e9-487f-8605-8be54f36112a

📥 Commits

Reviewing files that changed from the base of the PR and between 2767858 and 0ec6037.

📒 Files selected for processing (3)
  • src/runtime/shell/states/Cmd.rs
  • src/runtime/shell/subproc.rs
  • test/js/bun/shell/shell-pipe-read-fault.test.ts

Walkthrough

The PR removes spawn-arena bookkeeping, adds a spawn-time guard for buffered subprocess completion, adjusts pipe-reader lifetime handling, and adds Linux regression coverage for synchronous pipe-read failures.

Changes

Shell subprocess fault handling

Layer / File(s) Summary
Cmd lifecycle cleanup
src/runtime/shell/states/Cmd.rs
spawn_arena_freed is removed from Cmd, its initialization and assignment are removed, and the deinit comment no longer refers to it.
Spawn-time completion gate
src/runtime/shell/states/Cmd.rs
transition_to_exec documents the interp-null staging rule, and buffered_output_close returns Yield::suspended() when the subprocess interp is null.
Pipe reader keepalive
src/runtime/shell/subproc.rs
Readable::start_pipe_reader clones the Arc<PipeReader> into a local keepalive before deriving the mutable raw pointer used during start and read_all.
Shell pipe fault regression test
test/js/bun/shell/shell-pipe-read-fault.test.ts
A Linux-only test adds the preload shim, fixture scripts, temp build/setup, a shim runner, JSON parsing helper, and four fault-mode assertions for recv and epoll failures.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: a shell use-after-free triggered by a pipe read failure during spawn.
Description check ✅ Passed The description is detailed and includes the problem, fix, and verification, though it uses different headings than 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.

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

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

The second finding from the same campaign, the stale node-id panic, has the same root cause and is covered by the gate in this PR:

panic: expected Node::Cmd at Node#12, got Free
  <Interpreter>::as_cmd        shell/interpreter.rs:187
  <Cmd>::transition_to_exec    shell/states/Cmd.rs:655
  <Cmd>::next                  shell/states/Cmd.rs:307

plus the got Pipeline variant when a later alloc_node reuses the freed slot.

Independent reproduction on stock main, no fuzzing harness needed: an LD_PRELOAD shim that interposes libc syscall() and fails epoll_ctl(EPOLL_CTL_MOD) with ENOMEM for the capture socketpairs. The eager in-spawn read returns EAGAIN, the poll re-arm fails, and PipeReader::on_reader_error reaches Cmd::buffered_output_close from inside spawn_async, the same entry point gated here. Unfixed, that panics deterministically at Cmd.rs:655; with the gate the command finishes and reports exit code 12.

The same fix plus that epoll variant of the regression test is on farm/c0c246c6/shell-cmd-stale-node-spawn (6dcf19b) in case the extra coverage is useful. Not opening a second PR for it.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Hit this same crash from the inline repro in the report thread and ended up at the same fix (gating Cmd::buffered_output_close on exec.interp being published), so not opening a second PR. Independently confirmed fail-before/pass-after with an LD_PRELOAD recv shim: unfixed ASan reports the spawn_maybe_sync_impl use-after-free and the release build panics with expected Node::Cmd at Node#2, got Free.

One thing worth folding in here: the comment block kept right below the new guard still says the pre-spawn completion arm is "unreachable in practice" because initSubproc sets spawn_arena_freed = true before any pipe can close. That assumption is the bug this PR fixes, and with completion now keyed on exec.interp, spawn_arena_freed has no readers left at all. Equivalent fix plus that cleanup (field and stale comment removed) is on ce00394 if useful for comparison.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

The same crash also reproduces through a second path that this diff does not cover: epoll_ctl(EPOLL_CTL_ADD/MOD) failing for the shell pipe fd (ENOMEM under memory pressure, ENOSPC at the epoll watch limit). In that flavor the error is reported from register_poll inside PipeReader::start, not from the eager read_all, and with only the buffered_output_close gate applied it still aborts under ASan:

AddressSanitizer: heap-use-after-free (READ of size 4)
  <bun_io::pipes::PollOrFd>::get_poll        io/pipes.rs:41
  <PipeReader>::start                        shell/subproc.rs:2015
  <Readable>::start_pipe_reader              shell/subproc.rs:1254
  <ShellSubprocess>::spawn_maybe_sync_impl   shell/subproc.rs:878
freed by:
  Arc<PipeReader> drop (guard)               shell/subproc.rs:2351 (end of on_reader_error)

The gate stops the trampoline, but buffered_output_close_{stdout,stderr} still call close_io, and Readable::finalize drops the Readable::Pipe slot's ref from inside the callback. The on_reader_error guard is then the last reference, so the PipeReader is freed when the callback returns, while PipeReader::start and Readable::start_pipe_reader are still using it.

Branch farm/5733ab33/shell-spawn-reentrant-teardown has the same interp gate plus the missing piece (an Arc keepalive in Readable::start_pipe_reader held across start()/read_all()) and a regression test, test/js/bun/shell/shell-pipe-epoll-fail.test.ts, that injects the epoll_ctl failure with an LD_PRELOAD shim. Note the shim has to interpose syscall(2) as well as epoll_ctl: the FilePoll registration goes through the raw syscall(SYS_epoll_ctl, ...) wrapper, so interposing only the libc symbol never fires. Verified against this PR's gate-only diff: the epoll test still fails there and passes with the keepalive added.

Not opening a separate PR for this; folding those two pieces into this one keeps it to a single fix.

Comment thread src/runtime/shell/states/Cmd.rs Outdated
Comment thread src/runtime/shell/states/Cmd.rs Outdated
Comment thread test/js/bun/shell/shell-pipe-read-fault.test.ts Outdated
A second in-spawn failure surfaces the same re-entrant teardown:
epoll_ctl failing inside PosixBufferedReader::register_poll (called
from PipeReader::start) fires on_reader_error synchronously. That
callback reaches Cmd::buffered_output_close -> close_io ->
Readable::finalize, which drops the Readable::Pipe slot's Arc, so the
callback's guard was the last strong ref and the PipeReader was freed
when the callback returned. PipeReader::start then kept dereferencing
self (subproc.rs:2015); PosixBufferedReader::start returns Ok after
register_poll, so the error never reaches the caller.

Readable::start_pipe_reader now holds an Arc clone across start() and
read_all(), the same keepalive PipeReader::guard_from_raw already
provides for the on_reader_{done,error} callbacks themselves.

Also remove Cmd::spawn_arena_freed: it was only ever written and then
discarded with `let _ =`, and the comment that justified keeping it
(initSubproc sets it before any pipe can close) states the premise
this PR disproves. The buffered_output_close gate on exec.interp now
encodes the pre/post-spawn split directly.

The regression test gains epoll_ctl variants alongside the recv ones
(one LD_PRELOAD shim selects the fault via SHELL_FAIL_RECV or
SHELL_FAIL_EPOLL) and the tests run concurrently.
@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

#32759 fixes a third, independent use-after-free on this same error path: epoll_ctl(EPOLL_CTL_ADD) failing inside PosixBufferedReader::start() fires on_reader_error synchronously, and on_close_io dropping the Readable::Pipe slot is then the last Arc<PipeReader> strong ref while Readable::start_pipe_reader is still running on it (AddressSanitizer: heap-use-after-free at PollOrFd::get_poll, io/pipes.rs:41).

The freed objects differ (this PR: Box<ShellSubprocess> / interpreter node slot; #32759: the Arc<PipeReader> itself), so neither fix covers the other. The epoll reproduction in #32759 hits both bugs back to back, so that PR includes this one's Cmd::buffered_output_close hunk verbatim; either PR can merge first and the other rebases to a no-op on it. The recv()-fault test here stays the regression test for the Cmd half.

@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: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/shell/states/Cmd.rs (1)

573-584: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Condense the spawn-time gate comments to the 3-line limit.

The invariant is useful, but both new comment blocks exceed the repository rule. Keep the local explanation short and leave the longer narrative in the PR/test context.

Suggested condensation
-        // Stage the exec slot *before* spawning so PipeReader / process-exit
-        // callbacks (which deref `cmd_parent.exec`) see a populated `Subproc`
-        // with the correct `child` once `spawn_async` writes through
-        // `out_subproc`. `interp` is left null until `spawn_async` and the
-        // `did_exit_immediately` handling have returned: a synchronous
-        // `Cmd::on_exit` (process exit handler) or `Cmd::buffered_output_close`
-        // (an eager `read_all` on a pipe erroring inside the spawn) would
-        // otherwise drive the trampoline (`Yield::run(&*interp)`) while this
-        // frame still holds `&Interpreter`, tearing the Cmd down (and freeing
-        // `child`) underneath the live `subproc` borrow. With `interp` null,
-        // both record `exit_code`/`state = Done` and return; we resume via
-        // the Yield we hand back below.
+        // Stage `Exec::Subproc` before spawning so callbacks can record completion.
+        // Keep `interp` null during spawn-time callbacks; they must not run the
+        // trampoline until this frame unwinds and the `CmdState::Done` check resumes.
...
-            // Same gate as `on_exit`: `exec.interp` is null until
-            // `transition_to_exec` returns from `spawn_async`. A runnable
-            // Yield here would reach `Cmd::deinit` and free the
-            // `ShellSubprocess` the spawn frame still dereferences;
-            // `transition_to_exec` resumes via its `CmdState::Done` check.
+            // Same spawn-time gate as `on_exit`: before `exec.interp` is
+            // published, mark `Done` but let `transition_to_exec` resume.

As per coding guidelines, "Keep code comments to 3 lines max."

Also applies to: 966-970

🤖 Prompt for 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.

In `@src/runtime/shell/states/Cmd.rs` around lines 573 - 584, Condense the
spawn-time gate comments in Cmd::spawn_async-related logic to no more than 3
lines while preserving the key invariant: stage exec before spawn, keep interp
null until spawn_async/did_exit_immediately finish, and avoid running the
trampoline while the live subproc borrow exists. Keep the explanation brief in
the Cmd state transition comments and remove the long narrative, including the
duplicated explanation near the other commented block referenced by the review.

Source: Coding guidelines

🤖 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 `@test/js/bun/shell/shell-pipe-read-fault.test.ts`:
- Around line 173-184: The crash-mode subprocess test is asserting parsed stdout
before checking the process result, which can hide the useful stderr/exit
diagnostics when the native crash happens. Update the shell-pipe-read-fault
tests around lastJsonLine and the shell survives... cases to keep stdout,
stderr, and exitCode asserted together in one failure path, and avoid parsing
stdout in a way that masks crash output; apply the same pattern to the related
concurrent tests referenced in the comment.
- Around line 1-22: The comment block in shell-pipe-read-fault.test.ts contains
detailed ASAN stack history and debug narrative that should not live in the
test. Shorten the header to a minimal regression note, or replace it with just
the issue reference if available, and keep only the durable invariant around the
fault scenarios covered by the test.
- Around line 158-162: The shell pipe fault test is inheriting the inactive shim
mode from bunEnv/process.env, so both fault shims can be enabled at once. Update
the env setup in shell-pipe-read-fault.test.ts to explicitly unset the
non-target shim variable when setting the active one in the mode-specific cases,
so each test only exercises the intended fault. Use the existing bunEnv,
LD_PRELOAD, and mode-specific env keys (SHELL_FAIL_RECV/SHELL_FAIL_EPOLL) to
keep the test hermetic.

---

Outside diff comments:
In `@src/runtime/shell/states/Cmd.rs`:
- Around line 573-584: Condense the spawn-time gate comments in
Cmd::spawn_async-related logic to no more than 3 lines while preserving the key
invariant: stage exec before spawn, keep interp null until
spawn_async/did_exit_immediately finish, and avoid running the trampoline while
the live subproc borrow exists. Keep the explanation brief in the Cmd state
transition comments and remove the long narrative, including the duplicated
explanation near the other commented block referenced by the review.
🪄 Autofix (Beta)

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: 42e86f2c-253b-483e-bb50-a707294cf31a

📥 Commits

Reviewing files that changed from the base of the PR and between cd5ad9f and 2767858.

📒 Files selected for processing (3)
  • src/runtime/shell/states/Cmd.rs
  • src/runtime/shell/subproc.rs
  • test/js/bun/shell/shell-pipe-read-fault.test.ts

Comment thread test/js/bun/shell/shell-pipe-read-fault.test.ts Outdated
Comment thread test/js/bun/shell/shell-pipe-read-fault.test.ts
Comment thread test/js/bun/shell/shell-pipe-read-fault.test.ts Outdated
Unset both shim mode variables before enabling the one under test so a
preset SHELL_FAIL_RECV / SHELL_FAIL_EPOLL in the outer environment
cannot turn on both faults at once, and fold each test's checks into
one combined { parsed, stderr, exitCode } assertion so a native crash
surfaces the child's stderr and exit code in the same failure diff.

Trim the test header and the buffered_output_close gate comment; the
ASan stacks and the longer narrative live in the PR description.
@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Update to my comment above: it was accurate against this PR's state at the time (only the Cmd.rs gate), but 2767858f1c then added the same Arc<PipeReader> keepalive to Readable::start_pipe_reader that #32759 did, so this PR now covers both frees and #32759 is redundant. I've closed #32759 in favor of this one.

Verified: with this branch's current src/ diff applied to a clean origin/main, #32759's test/js/bun/shell/shell-epoll-add-fail.test.ts passes on both of its cases; on unmodified main it aborts under ASan with heap-use-after-free in <PollOrFd>::get_poll (io/pipes.rs:41) via <PipeReader>::start (shell/subproc.rs:2015).

Two small things from #32759's branch (farm/955d4332/shell-pipereader-epoll-uaf, head b270f4b7c2) would strengthen this PR and are not in it:

  1. That test. It is a second, independent, realistic trigger for the same family: epoll_ctl(EPOLL_CTL_ADD) returning ENOSPC (fs.epoll.max_user_watches exhaustion). Unlike the recv() fault here, it fires inside PosixBufferedReader::start -> register_poll, so it covers PipeReader::start continuing past the synchronous error (the get_poll read), which the recv() fault cannot reach because it fires later, inside the eager read_all. Drop-in: git cherry-pick -n b270f4b7c2 -- test/js/bun/shell/shell-epoll-add-fail.test.ts.
  2. PipeReader::start returning early once on_reader_error has already set state to Err, instead of setting the Socket flags on the already-torn-down reader. Not required for memory safety with the keepalive in place, but it restores parity with SubprocessPipeReader::start, which has both the keepalive and that check.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Builds 64945 and 64974 both go red on the same single job, the darwin 26 aarch64 - test-bun shard 0, which aborts before running any test:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

The darwin-aarch64-build-bun step it downloads from succeeds, and the sibling shard on the same lane downloads the same artifact fine, so this looks like an agent or network problem on that one runner. It hit twice in a row, so another retrigger is unlikely to help.

Everything else flagged on build 64974 is a CI-tagged flaky auto-retry in code this PR does not touch: HMR races in hot.test.ts and dev-and-prod.test.ts, an npm install in update_interactive_install.test.ts, and an R2 upload timeout in s3.test.ts. The Linux x64-asan test shards, the lanes that actually exercise the regression tests added here, have no failures.

The diff itself is verified on both ASAN-debug and release builds; the proof matrix is in the PR description. Getting this green needs someone with Buildkite access to retry that one darwin shard.

@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 of my earlier concerns were addressed in 2767858/aa7fcade and the current pass found nothing new, but this is a memory-safety fix in core shell subprocess code (unsafe raw-pointer access, re-entrant Arc<PipeReader> teardown, a new Yield::suspended() gate on the completion path) that warrants a human look — also note the musl build failures on 2767858 and robobun's two optional hardening suggestions from #32759.

Extended reasoning...

Overview

The PR fixes two heap use-after-frees in the Bun Shell that occur when a pipe syscall (recv or epoll_ctl) fails synchronously from inside ShellSubprocess::spawn_async. It touches src/runtime/shell/states/Cmd.rs (adds an exec.interp.is_null() gate in buffered_output_close, removes the dead spawn_arena_freed field), src/runtime/shell/subproc.rs (adds an Arc::clone keepalive in Readable::start_pipe_reader so the PipeReader survives re-entrant close_io → Readable::finalize), and adds a Linux-only LD_PRELOAD fault-injection regression test.

I previously raised three inline concerns on this PR (the gate-only diff missing the Arc keepalive for the epoll_ctl path; the stale spawn_arena_freed field/comment; test.concurrent). All three were addressed in 2767858, and the CodeRabbit test-hygiene nits (hermetic env, condensed header comment, combined assertion) were addressed in aa7fcad. The bug hunting system found nothing new on the current revision.

Security risks

None in the conventional sense (no auth/crypto/permissions). But this is a memory-safety patch: it changes when Cmd returns a runnable Yield (which drives Cmd::deinit and frees the Box<ShellSubprocess>) and how long the Arc<PipeReader> is pinned across re-entrant vtable callbacks. The subproc.rs change rewrites the body of start_pipe_reader, deriving a &mut PipeReader from a raw pointer obtained via arc_as_mut_ptr on a cloned Arc rather than the slot's Arc — the SAFETY comment was updated, but the aliasing argument is subtle (the re-entrant callbacks also hold raw *mut PipeReader, and Readable::finalize may swap the slot to Closed while p is live). I believe the change is correct, but it is exactly the kind of unsafe-Rust lifetime reasoning that benefits from a maintainer's eyes.

Level of scrutiny

High. This is production-critical native code in the shell subprocess pipeline. The fix is small in line count but the correctness argument depends on understanding the interleaving of spawn_maybe_sync_impl → start_pipe_reader → PipeReader::start/read_all → vtable on_reader_error → Cmd::buffered_output_close → close_io → Readable::finalize, plus the existing transition_to_exec post-spawn CmdState::Done resume. Not a config tweak or mechanical refactor.

Other factors

  • CI: robobun's status comment shows musl build failures (aarch64-musl, x64-musl, x64-musl-baseline) on 2767858. These may be infra-related, but should be confirmed green on aa7fcad before merge.
  • Outstanding suggestions: robobun's last comment (12:37 UTC) lists two optional, non-blocking hardening items from the now-closed #32759 — an additional ENOSPC epoll_ctl regression test, and an early-return in PipeReader::start once state == Err. Neither is required for memory safety with the keepalive in place, but a maintainer may want to weigh in on whether to fold them in here.
  • Test coverage: Strong. Four LD_PRELOAD fault-injection cases (recv × {stdout-only, both-pipes}, epoll × {stdout-only, both-pipes}), each independently load-bearing per the proof matrix in the description, plus 560 existing shell tests reported passing.

PosixBufferedReader::start reports a poll-registration failure through
the on_reader_error vtable callback, not its return value, so
PipeReader::start would continue into its Ok arm and set the Socket
flags on a reader that on_reader_error had already torn down. That is
harmless with the Arc keepalive in start_pipe_reader, but
SubprocessPipeReader::start (the Bun.spawn twin of this function) pairs
its keepalive with exactly this state check; the shell copy was missed
when that was added. This restores the parity.
@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Checked everything raised above.

musl: the "build failed on aarch64-musl / x64-musl / x64-musl-baseline" entries were on build 64966, which was canceled when aa7fcad superseded it; those are cancellation artifacts, not build errors. On build 64974 all three musl builds pass.

CI on aa7fcad (build 64974): test/js/bun/shell/shell-pipe-read-fault.test.ts is 4 pass, 0 fail on every Linux lane that ran it, including debian 13 x64-asan and the three Alpine (musl) lanes. The only two red jobs are both darwin agents that never reach a test: the 26 aarch64 shard times out downloading the build artifact, and the 14 x64 shard dies inside the CI runner script itself with uv_os_get_passwd returned ENOENT. Still needs someone with Buildkite access to retry those two shards.

On the two suggestions carried over from #32759:

  1. The epoll_ctl(EPOLL_CTL_ADD) regression test is already covered. The SHELL_FAIL_EPOLL mode added in 2767858 interposes the same raw syscall(SYS_epoll_ctl, ...) wrapper, fires on_reader_error from inside PosixBufferedReader::start -> register_poll, and reproduces the exact PollOrFd::get_poll / PipeReader::start (subproc.rs:2015) UAF on the unfixed build, which was how I verified the keepalive is load bearing. #32759's test uses ENOSPC where this one uses ENOMEM, but the fix does not branch on the errno, so that adds no new path.

  2. Added in 0ec6037: PipeReader::start now returns early once on_reader_error has set state to Err, instead of setting the Socket flags on a reader that is already torn down. Not required for memory safety with the keepalive in place, but SubprocessPipeReader::start (the Bun.spawn twin) pairs its keepalive with exactly this check and the shell copy was missed when that landed, so this restores the parity between the two.

@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 of my earlier findings are addressed (the Arc<PipeReader> keepalive, the spawn_arena_freed cleanup, and test.concurrent), and the latest pass found nothing new — deferring only because a UAF fix touching unsafe pointer derivation and re-entrant interpreter teardown is worth a human sanity check.

Extended reasoning...

Overview

This PR fixes two heap use-after-frees in the Bun Shell where a pipe error (recv() ENOMEM or epoll_ctl() ENOMEM/ENOSPC) surfacing synchronously inside ShellSubprocess::spawn_async tears down state still in use by the spawn frame. Three files are touched:

  • src/runtime/shell/states/Cmd.rs: adds an exec.interp.is_null() gate in buffered_output_close (mirroring the existing on_exit gate) so pipe-side completion suspends rather than driving the trampoline into Cmd::deinit while the spawn frame still holds the ShellSubprocess. Also removes the now-dead spawn_arena_freed field, its initializer, write site, and stale doc comment.
  • src/runtime/shell/subproc.rs: Readable::start_pipe_reader now holds an Arc::clone keepalive across start()/read_all() so a synchronous on_reader_error → close_io → Readable::finalize cannot drop the last strong ref while PipeReader::start is still on the stack. PipeReader::start also gained an early return when state == Err, matching SubprocessPipeReader::start.
  • test/js/bun/shell/shell-pipe-read-fault.test.ts: new Linux-only regression test with an LD_PRELOAD shim covering both fault modes × stdout-only / stdout+stderr (4 concurrent tests). The PR description includes a verified fail-before/pass-after matrix showing each half of the fix is independently load-bearing.

Prior review cycle

I left three inline comments on the gate-only revision (one 🔴 for the missing keepalive on the epoll path, two 🟡 nits). All three were addressed in 2767858 and are marked resolved. CodeRabbit's three follow-up comments (test header verbosity, env hermeticity, combined assertion) were addressed in aa7fcad. Commit 0ec6037 added the PipeReader::start early-return for parity with SubprocessPipeReader. The bug-hunting pass on the current head found nothing.

Security risks

None introduced. This hardens an error path against memory corruption; it does not touch auth, crypto, permissions, or any externally-reachable surface. The new test uses LD_PRELOAD only against a locally-compiled shim in a temp directory under the test harness's own bunEnv.

Level of scrutiny

High. The change is small and well-tested, but it edits memory-safety invariants in a critical native path: unsafe { &mut *arc_as_mut_ptr(...) } derivation, Arc strong-count reasoning under re-entrant callbacks, and the interpreter's Yield trampoline gating that decides when Cmd::deinit may run. The reasoning in the PR description and code comments is sound, and the existing 560-test shell suite plus the new fault-injection tests pass on all Linux lanes (the only CI red is darwin agent infrastructure unrelated to this change). Still, this is exactly the kind of lifetime/ownership change where a second pair of human eyes on the invariant is cheap insurance.

Other factors

CI on the latest commit passes everywhere the new test actually runs (including x64-asan and musl); the two red jobs are a darwin artifact-download timeout and a darwin agent uv_os_get_passwd failure, neither of which reaches a test. PR #32759 (which independently fixed the PipeReader half) has been closed in favor of this one and its remaining suggestion (the Err-state early return) was folded in.

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Same result on the current head: build 64996 (0ec6037ae2) has exactly one failure, the darwin 26 aarch64 - test-bun shard 0, aborting on the identical buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun' before running any test. That makes three consecutive builds, so it is not a transient flake; the darwin-26-aarch64 agent cannot fetch its own build artifacts. Nothing else is red.

Nothing about the diff has changed since my comment above: it is complete, every reviewer finding is resolved, and the regression tests are CI-verified on every Linux lane including x64-asan and Alpine/musl. A push cannot fix this; it needs someone with Buildkite access to retry that shard (or look at that agent).

@robobun

robobun commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator Author

Correction to my last comment now that build 64996 has finished: it ended with a second red job, the darwin 14 x64 - test-bun shard. That one is not the diff either. Its failures are terminal.test.ts > Bun.spawn with terminal option > creates subprocess with terminal attached (a 90s timeout, plus serve.test.ts, which CI already tags as flaky), and they only fail on that one agent: terminal.test.ts passes on the other 14 lanes in the same build, including darwin 14 aarch64, and this is the same darwin 14 x64 agent whose CI runner crashed with uv_os_get_passwd returned ENOENT on the previous build. Bun.spawn({ terminal }) is PTY code in src/runtime/api/bun/subprocess/, which this PR does not touch.

So the completed build is 284 of 286 jobs green, and both red jobs are the two darwin agents. Every Linux lane, including x64-asan and Alpine/musl, is green.

@Jarred-Sumner
Jarred-Sumner merged commit d75b273 into main Jun 26, 2026
76 of 78 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/7b4eaa34/shell-spawn-pipe-error-uaf branch June 26, 2026 20:41
Jarred-Sumner pushed a commit that referenced this pull request Jun 27, 2026
…Reader (#32848)

## Problem

`debug_assert!(process.is_some())` in
`PipeReader::try_signal_done_to_cmd` fires in Bun Shell under syscall
faults. Regression introduced by #32754 (reported by
syscall-fault-injection fuzzing against `origin/main@df92f8f`, never
seen on the previous build).

```
panic: assertion failed: process.is_some()
  at src/runtime/shell/subproc.rs:2189
  <PipeReader>::try_signal_done_to_cmd    shell/subproc.rs:2189
```

## Repro

`sleep 5` stands in for any child whose stdout stays open:

1. `PipeReader::start` registers the poll (epoll_ctl succeeds), the
eager `read_all()` begins.
2. `recv()` returns a chunk, then `EAGAIN`.
3. `read_with_fn`'s EAGAIN arm calls `register_poll()` BEFORE delivering
the drained chunk. That `epoll_ctl` fails with `ENOMEM`.
4. `on_reader_error` -> `try_signal_done_to_cmd` ->
`buffered_output_close` -> `close_io` -> `PipeReader::detach()` sets
`process = None`.
5. `read_with_fn` then still delivers `on_read_chunk(chunk, Drained)`,
which retries `register_poll()`; the resulting second `on_reader_error`
re-enters `try_signal_done_to_cmd`.
6. `process` is `None`, the assert fires.

Debug log from the repro:

```
[sys] register: FilePoll readable (13[socket])               <- second epoll_ctl, fails
[shell_subproc] PipeReader(0x...250) onReaderError errno: 12
[shell_subproc] signalDoneToCmd (stdout) isDone=true
[shell_subproc] PipeReader(0x...250, stdout) detach()        <- process = None
[shell_subproc] PipeReader(0x...250) onReadChunk(chunk_len=11, has_more=drained)
[sys] register: FilePoll readable (13[socket])               <- third epoll_ctl, fails
[shell_subproc] PipeReader(0x...250) onReaderError errno: 12
[shell_subproc] signalDoneToCmd (stdout) isDone=true
panic: assertion failed: process.is_some()
```

## Cause

The `None` backref set by `PipeReader::detach()` is the intentional
latch for late terminal callbacks (its own comment says so), and
`try_signal_done_to_cmd` already handles it with the `if let Some(proc)`
right below the assert. The assert claims a single-shot invariant that
the buffered reader never provided.

It was unreachable before #32754 only by accident: without that PR's
`Arc` keepalive in `Readable::start_pipe_reader`, step 4 dropped the
last reference to the `PipeReader`, so step 5 was a use-after-free (one
of the two UAFs that PR fixed). Now that the reader correctly survives,
the latent second callback reaches the assert. Same in the original Zig
(`if (bun.Environment.allow_assert) assert(this.process != null);`
followed by `if (this.process) |proc|`).

## Fix

Delete the assert. The `if let Some(proc)` is the real guard; a detached
reader yields `Yield::Suspended`, and the stream is already closed and
accounted for by the first signal.

## Test

New mode pair in `test/js/bun/shell/shell-pipe-read-fault.test.ts`'s
`LD_PRELOAD` shim:

- `SHELL_FAIL_EPOLL_AFTER`: the first `epoll_ctl` ADD on each AF_UNIX
socket succeeds, every later ADD/MOD on that fd fails with `ENOMEM`.
- `SHELL_RECV_ONE_CHUNK`: the first `recv()` on each AF_UNIX socket
returns a fixed chunk, later ones return `EAGAIN`, so the "read some
bytes, then stall" interleaving is deterministic instead of racing the
child.

Without the fix the new test fails in ~800ms with `panic: assertion
failed: process.is_some()`; with it all 5 tests in the file pass. The
rest of `test/js/bun/shell/` is unchanged (797 pass; the 21 failures in
my container reproduce identically on the pre-#32754 binary and are
root-user / slow-ASAN environment issues).

Reported by a syscall-fault-injection fuzzing campaign as a regression
between `c4d6844e7` and `df92f8fd`.
Jarred-Sumner pushed a commit that referenced this pull request Jun 28, 2026
…ll-driven read (#32986)

## Problem

Heap use-after-free in Bun Shell when `epoll_ctl` fails while
re-registering a pipe's `FilePoll` from a poll-driven read. Found by
syscall-fault-injection fuzzing against `origin/main`. Follow-up to
#32754, which fixed the same failure on the eager spawn-time read path.

```
ERROR: AddressSanitizer: heap-use-after-free
READ of size 1, thread T0
  #0 <bun_io::pipe_reader::BufferedReaderVTable>::link            io/PipeReader.rs:105
  #1 <bun_io::pipe_reader::BufferedReaderVTable>::on_read_chunk   io/PipeReader.rs:125
  #2 <bun_io::pipe_reader::PosixBufferedReader>::read_with_fn     io/PipeReader.rs:890
  #3 <bun_io::pipe_reader::PosixBufferedReader>::read_socket      io/PipeReader.rs:576
  #4 <bun_io::pipe_reader::PosixBufferedReader>::on_poll          io/PipeReader.rs:529
  #5 __bun_run_file_poll                                          runtime/dispatch.rs:677
freed by:
  <alloc::sync::Arc<bun_runtime::shell::subproc::PipeReader>>::drop
```

## Repro

1. `PipeReader::start` registers the poll and the eager spawn-time
`read_all()` hits `EAGAIN`, so `read_with_fn`'s `EAGAIN` arm
re-registers the poll and the spawn returns.
2. The child writes to stdout and the poll fires.
`__bun_run_file_poll`'s `BUFFERED_READER` arm dispatches straight into
`PosixBufferedReader::on_poll` with a bare `&mut *h` and no keepalive.
3. `read_with_fn` drains the chunk, `recv()` returns a real `EAGAIN`,
and `register_poll()` issues another `epoll_ctl`, which fails (`ENOMEM`
in the repro).
4. `register_poll` dispatches `on_reader_error`. The shell
`PipeReader::on_reader_error` signals the `Cmd`, the `Readable::Pipe`
`Arc` is dropped, and the callback's own `guard_from_raw` keepalive
becomes the last reference. The code already documents this: "Dropping
`guard` is the matching `deref()`; may free `this`."
5. Back in `read_with_fn`, the `EAGAIN` arm still delivers the drained
head: `parent.vtable.on_read_chunk(.., ReadState::Drained)` reads the
freed vtable.

Traced with the test's `LD_PRELOAD` shim:

```
[shim] epoll_ctl(ADD fd=13) unix call#1 -> ok       PipeReader::start
[shim] recv(fd=13)          unix call#1 -> EAGAIN   eager read, inside spawn
[shim] epoll_ctl(MOD fd=13) unix call#2 -> ok       re-register; spawn returns
[shim] recv(fd=13)          unix call#2             poll fired: the child's bytes
[shim] recv(fd=13)          unix call#3             real EAGAIN
[shim] epoll_ctl(MOD fd=13) unix call#3 -> ENOMEM   register_poll fails
[shell_subproc] PipeReader(0x..250) onReaderError errno: 12
[shell_subproc] PipeReader(0x..250, stdout) detach()
[shell_subproc] PipeReader(0x..250, stdout) deinit()
==ERROR: AddressSanitizer: heap-use-after-free
```

## Cause

`register_poll()`'s failure path dispatches `on_reader_error`, which the
`BufferedReaderParent` contract explicitly allows to free the parent,
but `register_poll` gave the caller no way to know that happened.
`read_with_fn`'s `EAGAIN` arm is the only call site that touches the
reader afterwards; every other `register_poll()` is in tail position.
The `SAFETY` comment above the `parent` rebind claimed the parent is
"never freed mid-call", which holds for `on_read_chunk` re-entry but not
for `on_reader_error`.

#32754 covered this exact sequence on the eager spawn-time entry by
holding an `Arc<PipeReader>` across `start()` and `read_all()` in
`Readable::start_pipe_reader`. The epoll dispatch has no equivalent
keepalive, so the poll-driven entry was still exposed.

## Fix

`PosixBufferedReader::register_poll()` now returns whether registration
succeeded. `false` means `on_reader_error` was dispatched and `self`
must not be touched again, so `read_with_fn`'s `EAGAIN` arm returns
there instead of delivering the drained head to a possibly freed parent.
The stream has already been completed with the registration error at
that point, so nothing is lost. All other `register_poll()` call sites
are tail calls and discard the result.

## Test

Two new modes in `test/js/bun/shell/shell-pipe-read-fault.test.ts`'s
`LD_PRELOAD` fault shim:

- `SHELL_RECV_EAGAIN_FIRST=1`: the first `recv()` on each `AF_UNIX`
socket returns `EAGAIN`, pushing the first successful read off the eager
spawn-time `read_all()` and onto the epoll dispatch.
- `SHELL_FAIL_EPOLL_FROM=N`: the Nth and later `epoll_ctl` `ADD`/`MOD`
on each `AF_UNIX` socket fail with `ENOMEM`. `N=3` lets the initial
registration and the eager read's re-registration succeed, then fails
the first poll-driven one.

The new test is `skipIf(!isASAN)` because the use-after-free is only
reliably observable under ASAN. With `src/io/PipeReader.rs` reverted to
`main` it fails in ~1.1s with the `heap-use-after-free` above; with the
fix all 6 tests in the file pass.

## Out of scope

Shell `PipeReader::on_read_chunk` also calls
`self.reader.register_poll()` from a `&mut self` method whose stated
contract is that it never frees `self`. If that inner registration
fails, the same free can happen under `read_with_fn`'s mid-loop flush
instead of its `EAGAIN` arm. Reaching it needs a large (>32 KB) burst in
one poll wake; I have not reproduced it, so it is not changed here.
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