Skip to content

test: wait for the child to exit before touching stdin in spawn-stdin-destroy - #41522

Closed
robobun wants to merge 2 commits into
mainfrom
robobun/ff5d8e46/spawn-stdin-destroy-race
Closed

robobun wants to merge 2 commits into
mainfrom
robobun/ff5d8e46/spawn-stdin-destroy-race

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • test/js/bun/spawn/spawn-stdin-destroy.test.ts fails on Windows 2019 x64 with EPIPE: broken pipe, write at child.stdin.write (build 110853, red on every retry).
  • The test slept 80 ms and then touched child.stdin. On a slow runner the child is still alive at that point. The sink then wraps a live pipe, the child exits while the overlapped write is in flight, and libuv reports EPIPE. The fixed sleep is a race, not a condition. The sleep dates from Fix regression from #13414 #14092.

Fix

  • Replace await Bun.sleep(80) with await child.exited. The test covers a stdin sink that is created after the process has exited (Writable::to_js in src/runtime/api/bun/subprocess/Writable.rs:349, the has_exited() branch that calls FileSink::on_attached_process_exit). Waiting for exited reaches that branch every time.
  • With the process gone, FileSink::write sees done and returns Done instead of writing. No EPIPE is possible.
  • Verified: bun bd test test/js/bun/spawn/spawn-stdin-destroy.test.ts passes on Linux. The same file passed 30 of 30 runs on Windows x64 with the release build.

Background

  • Bun.spawn creates the stdin FileSink lazily on first access. If the process has exited already, the sink is marked done and writes are no-ops. If the process is alive, the sink owns a real pipe and a write can fail when the reader goes away.
  • The child fixture reads stdin once and then throws, so it exits about 20 to 80 ms after spawn depending on the machine.
Notes

Reproduction on Windows x64 with the release build, 15 runs per value of the sleep before child.stdin.write:

  • 0 ms: 0 EPIPE (the write lands in the pipe buffer before the child starts)
  • 20 ms: 10 EPIPE
  • 40 ms, 60 ms, 80 ms: 0 EPIPE

So the failure needs the sink to be created in the window where the child is alive but about to exit. On the CI runner that window is at 80 ms.

The test-only change does not alter what the test exercises. The intended path (stdin created after exit) is now reached deterministically instead of most of the time.


[auto-merge] gate passed · iteration 1 · 1 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/js/bun/spawn/spawn-stdin-destroy.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/js/bun/spawn/spawn-stdin-destroy.test.ts
bun test v1.4.3 (f42e98025)

test/js/bun/spawn/spawn-stdin-destroy.test.ts:
1 | process.stdin.read();
2 | 
3 | throw new Error("test");
              ^
error: test
      at /workspace/bun/test/js/bun/spawn/bad-fixture.js:3:11

Bun v1.4.3-debug+f42e98025 (Linux x64)
(pass) stdin destroy after exit crash [1303.19ms]

 1 pass
 0 fail
 2 expect() calls
Ran 1 test across 1 file. [3.58s]
Exit: 0
diff hotspot
test/js/bun/spawn/spawn-stdin-destroy.test.ts | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

gate history · 2 passed · 0 rejected · iteration 1

evidence per changed file
file                                           reads  edits  tests
test/js/bun/spawn/spawn-stdin-destroy.test.ts      1      2      9

root cause · written by the author bot

The test used a fixed 80 ms sleep before writing to the child's stdin, and on Windows the bad-fixture.js child could exit first, so the write hit a live pipe whose reader was gone and threw EPIPE before the test reached its real assertion about child.exited. The fix replaces the sleep with await child.exited, so the write deterministically lands on the has_exited() path in Writable.rs that the test was always meant to exercise. The fixture exits on its own without reading stdin, so awaiting exit cannot deadlock, and the existing assertions are unchanged.

…-destroy

The test slept 80 ms and then created the stdin sink. On a slow Windows
runner the child was still alive at that point, so the sink wrapped a
live pipe and the in-flight write failed with EPIPE when the child
exited. Await child.exited instead. That is the state the test covers:
a stdin sink created after the process has exited.
@coderabbitai

coderabbitai Bot commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 14 days. After that, they cost $0.25 per reviewed file.

Or wait 32 seconds for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 3a9b792c-cd37-450e-89aa-67df45b1f9f5

📥 Commits

Reviewing files that changed from the base of the PR and between 16eb85a and 8a8f281.

📒 Files selected for processing (1)
  • test/js/bun/spawn/spawn-stdin-destroy.test.ts

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

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 AM PT - Sep 6th, 2026

❌ @robobun, your commit 8a8f281 has 2 failures in Build #110900 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 41522

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

bun-41522 --bun

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

LGTM — replaces a fixed sleep with await child.exited, which is exactly the condition the test needs.

What was reviewed:

  • Confirmed the fixture (bad-fixture.js) exits on its own without stdin data, so awaiting child.exited before writing cannot deadlock.
  • Verified the referenced has_exited() branch in Writable.rs exists and is the path this test targets — the de-flake preserves what the test exercises.
  • Checked that the existing assertions (out === "", exited === 1) are unchanged, so the test is not weakened.
Extended reasoning...

Overview

This PR touches a single test file, test/js/bun/spawn/spawn-stdin-destroy.test.ts, replacing await Bun.sleep(80) with await child.exited before the test first accesses child.stdin. A comment explains the Windows EPIPE failure mode that motivated the change. No production code is modified.

Security risks

None. This is a test-only change with no effect on shipped code, no new inputs, no network or filesystem behavior changes.

Level of scrutiny

Low. The change is a textbook application of the repo's own rule: await the observable condition rather than sleeping for a fixed duration. The fixture (process.stdin.read(); throw new Error("test")) exits unconditionally without needing any stdin bytes and produces no stdout, so await child.exited cannot hang on an unread pipe or a blocked write. The has_exited() → on_attached_process_exit branch in src/runtime/api/bun/subprocess/Writable.rs matches the PR's claim about which code path the test is meant to hit, and awaiting exit reaches it deterministically rather than probabilistically.

Other factors

The test's assertions (expect(out).toBe(""), expect(exited).toBe(1)) and the GC/crash-guard structure are untouched, so the de-flake does not weaken what the original test protected. The added comment is a "why" comment naming the concrete failure scenario (EPIPE on Windows when the sink wraps a live pipe), which is the kind of context REVIEW.md asks for on timing-sensitive test changes. The bug hunt exited on dry_streak with no findings and no outstanding reviewer objections in the timeline.

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: closed. #45873 made the same change on main (await child.exited in place of Bun.sleep(80)).

@robobun

robobun commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: this is covered by #45873, which merged on 2026-10-20.

On main, test/js/bun/spawn/spawn-stdin-destroy.test.ts:15 awaits child.exited before the first use of child.stdin. This PR made the same replacement for Bun.sleep(80). The two versions of the file differ only in the comment text.

Build 130816 of main (38afee6) passed. Its Windows 2019 x64 lane ran this test.

@robobun robobun closed this Oct 22, 2026
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.

1 participant