Bun.spawn: close stdout/stderr pipes after a timeout kill - #35012
Conversation
When a Bun.spawn child is killed by the timeout option, a grandchild that inherited the pipe may still hold the write end open. Reading proc.stdout after proc.exited would then block until the grandchild exits, making the 'timeout kills the process' test flaky (the backing 'sleep 5' races the 5s test timeout). After on_process_exit drains whatever is readable, close any still-open stdout/stderr pipe readers when the kill came from timeout or maxBuffer. This mirrors what spawnSync already does and delivers the buffered bytes instead of waiting on an EOF that may never arrive.
|
Updated 6:27 PM PT - Jul 21st, 2026
❌ @robobun, your commit 0835776 has some failures in 🧪 To try this PR locally: bunx bun-pr 35012That installs a local version of the PR into your bun-35012 --bun |
WalkthroughChangesTimeout pipe teardown
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@src/runtime/api/bun/subprocess.rs`:
- Around line 708-711: Condense the comments at
src/runtime/api/bun/subprocess.rs:708-711,
src/runtime/api/bun/subprocess.rs:1037-1042, and
test/js/bun/spawn/spawn-maxbuf.test.ts:190-193 to three lines or fewer each,
retaining only the durable, non-obvious rationale about closing inherited pipe
writers after timeout/maxBuffer termination and the related regression coverage.
In `@test/js/bun/spawn/spawn-maxbuf.test.ts`:
- Around line 200-205: Update the `Bun.spawn` command in this test so the
intentionally surviving grandchild exits when the managed shell’s stdin closes,
or explicitly terminate that grandchild after the output assertions. Preserve
the timeout and signal behavior while ensuring successful runs leave no
descendant process running.
🪄 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: af89f441-f6fd-4ffd-8c99-a4e48cea96e1
📒 Files selected for processing (2)
src/runtime/api/bun/subprocess.rstest/js/bun/spawn/spawn-maxbuf.test.ts
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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/spawn/spawn-maxbuf.test.ts`:
- Around line 195-197: Remove the fixture-narration comment near the spawn
timeout regression test, leaving only the tracked issue URL as the
regression-test comment.
- Around line 198-203: Update the Bun.spawn fixture command in the timeout test
so the background grandchild is started before the readiness marker is emitted.
Keep the marker after the `sleep 60 &` launch, ensuring the test proceeds only
once a grandchild can retain the pipe writer.
🪄 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: 78047ee8-43d5-49a3-be23-29d605a085ea
📒 Files selected for processing (1)
test/js/bun/spawn/spawn-maxbuf.test.ts
There was a problem hiding this comment.
LGTM — the AbortSignal gap from the earlier round is now covered and the getter-before-exit ordering is explicitly scoped out in the description.
What was reviewed:
close_readable_pipes()placement inon_process_exit: runs after the drain reads, mirrors the spawnSync wait-loop call site, and re-entry viaon_close_ioonly touchesstdout/stderr+update_has_pending_activity(this_jsvaluewas already captured).ABORT_SIGNAL_KILLEDis only set when!has_exited(), andon_process_exitclears the abort listener first, so a natural exit followed by a late abort won't spuriously close pipes.- Test:
describe.eachthunk defersAbortSignal.timeout(200)creation to test time; grandchild PID is reaped before assertions so failure paths don't leak it.
Extended reasoning...
Overview
Two files: src/runtime/api/bun/subprocess.rs adds a Flags::ABORT_SIGNAL_KILLED bit, sets it in handle_abort_signal, and calls the pre-existing close_readable_pipes() from on_process_exit when the exit was Bun-initiated (timeout timer FIRED, maxBuffer, or the new abort flag). test/js/bun/spawn/spawn-maxbuf.test.ts adds a describe.each over timeout: and signal: AbortSignal.timeout() that spawns a sh grandchild holding the pipe write end and asserts stdout resolves with buffered data instead of hanging.
Security risks
None. No user-controlled input parsing, no auth/crypto, no path handling. The change tightens resource release on a path where Bun already decided to kill the child.
Level of scrutiny
Medium. on_process_exit is delicate (ref counting, re-entry through reader callbacks), but the added call reuses the exact helper spawnSync's wait loop already calls at the equivalent point, so the teardown sequence is not novel. I traced close_readable_pipes() → Readable::close() → PipeReader::close() → reader.close() → on_reader_done/on_close_io: the only Subprocess fields it touches are stdout/stderr (already drained) and this_value via update_has_pending_activity(), and on_process_exit captured this_jsvalue locally before this point, so downgrading the JsRef mid-body is harmless.
Other factors
A previous review pass from me flagged two sibling gaps; the author addressed the AbortSignal one with the new flag + test parametrization and explicitly scoped out the getter-before-exit ordering in the PR description with a Node.js comparison. CodeRabbit's fixture-ordering and grandchild-reaping nits were also addressed. The bug-hunting system found nothing on the current revision. The intentional behavior change — dropping grandchild output after a Bun-initiated kill instead of waiting for EOF — is the point of the fix and matches the spawnSync semantics the doc comment cites.
|
CI on build 77245: 193/196 green. The one red lane is Ready for review/merge; the darwin x64 lane needs its disk cleared or the job retried on a different agent. |
What
Reading
proc.stdout/proc.stderrafterawait proc.exitedcould hang indefinitely when the process was killed bytimeout,maxBuffer, or anAbortSignaland a grandchild still held the pipe's write end.Repro
This is what made
test/js/bun/spawn/spawn-maxbuf.test.ts"timeout kills the process > Bun.spawn" flaky:bun exec "sleep 5"is killed at 100ms but thesleepgrandchild survives the signal and keeps the stdout write end open for ~5s, racing the 5s test timeout.Cause
on_process_exitkeeps reading stdout/stderr until EOF so that output from grandchildren written after the direct child is reaped is not lost. When Bun itself kills the child viatimeout/maxBuffer/AbortSignal, the caller has already opted into a bounded wait, so waiting on a grandchild's EOF is the wrong trade-off.spawnSyncalready handles this by callingclose_readable_pipes()in its wait loop;spawn(async) did not.Fix
After draining the pipes in
on_process_exit, callclose_readable_pipes()when the exit was caused bytimeout(event_loop_timer.state == FIRED),maxBuffer, or anAbortSignal(newABORT_SIGNAL_KILLEDflag set inhandle_abort_signal). This closes the read end and delivers whatever was buffered instead of blocking on EOF.Scope
This only covers the case where
proc.stdout/proc.stderrare read afterproc.exitedresolves. If the getter is called before exit (e.g.Promise.all([proc.stdout.text(), proc.exited])), the reader has already been handed to a detachedFileReaderthatSubprocessno longer tracks, so it continues to wait for EOF. Node.js never closes the stream in either ordering, and that case is intentionally left unchanged here.Verification
The new test uses
sh -c "sleep 60 & echo $! >&2; echo from-child; read _"so the grandchild is spawned before the stdout marker even under a debug build, and its PID is reaped after the assertions.no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/spawn/spawn-maxbuf.test.ts