Skip to content

fix(tty): prevent tty.ReadStream from auto-closing the file descriptor - #28219

Closed
robobun wants to merge 2 commits into
mainfrom
claude/fix-tty-readstream-autoclose-27285
Closed

robobun wants to merge 2 commits into
mainfrom
claude/fix-tty-readstream-autoclose-27285

Conversation

@robobun

@robobun robobun commented Mar 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Fix tty.ReadStream to pass autoClose: false to the underlying fs.ReadStream, matching the behavior already used by tty.WriteStream
  • This prevents the PTY master fd from being auto-closed when the stream encounters read errors, which was causing node-pty (used by Gemini CLI) to crash with ioctl(2) failed, EBADF

Root Cause

tty.ReadStream extends fs.ReadStream which defaults autoClose (and thus autoDestroy) to true. When the stream encountered a read error on a PTY fd, it would auto-destroy itself, calling fs.close(fd) on the PTY master fd. This closed the PTY master, causing the kernel to send SIGHUP to the child process and making subsequent ioctl(TIOCSWINSZ) calls from node-pty's native addon fail with EBADF.

tty.WriteStream already passed autoClose: false — this applies the same fix to ReadStream.

In Node.js, tty.ReadStream extends net.Socket (not fs.ReadStream) and doesn't auto-close the fd, so this aligns Bun's behavior with Node.js.

Closes #27285

Test plan

  • New regression test test/regression/issue/27285.test.ts verifies autoClose: false and fd validity after errors
  • Test fails with system bun (autoClose:true, fd closed), passes with fix
  • All existing tty tests pass (tty.test.ts, tty-readstream-ref-unref.test.ts, tui-app-tty-pattern.test.ts)
  • Verified with actual @lydell/node-pty reproduction — resize succeeds instead of EBADF crash

🤖 Generated with Claude Code

tty.ReadStream inherited autoClose:true from fs.ReadStream, causing it
to auto-destroy and close the fd on read errors. This broke node-pty
(used by Gemini CLI and others) which stores the PTY master fd and calls
ioctl(TIOCSWINSZ) for resize - the fd was already closed, causing EBADF.

tty.WriteStream already passed autoClose:false; apply the same to
ReadStream to match Node.js behavior where tty streams don't own the fd.

Closes #27285

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@robobun

robobun commented Mar 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:01 AM PT - Mar 18th, 2026

❌ Your commit c27e8383 has 1 failures in Build #39956 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 28219

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

bun-28219 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 8 issues this PR may fix:

  1. node-pty: PTY spawns but onData callback never fires (no data received) #25822 - node-pty PTY spawns but onData callback never fires (PTY file descriptor communication issues)
  2. tty.WriteStream fails with EINVAL: invalid argument, kqueue on macOS when opening /dev/tty #24158 - tty.WriteStream fails with EINVAL on macOS (TTY stream initialization issue)
  3. Bun.Terminal PTY allocation fails after extended runtime/sleep on macOS #25912 - Bun.Terminal PTY allocation fails after extended runtime/sleep (PTY file descriptor allocation issues)
  4. [ERROR] TTY initialization failed: uv_tty_init returned EINVAL #10729 - TTY initialization failed: uv_tty_init returned EINVAL (TTY initialization with invalid file descriptors)
  5. createReadStream seems to crash the program/make the file handles hang permanently #7957 - createReadStream crashes/makes file handles hang permanently (autoClose and file descriptor management issues)
  6. readline.createInterface setRawMode throws error in non-interactive terminal when it shouldn't #5832 - readline.createInterface setRawMode throws error in non-interactive terminal (TTY/readline interaction with file descriptor issues)
  7. Bun.Terminal.write() bypasses PTY line discipline - Ctrl+C doesn't generate SIGINT #25779 - Bun.Terminal.write() bypasses PTY line discipline (PTY behavior issues that could be file descriptor-related)
  8. Bun console/tty log not working properly #13648 - Bun console/tty log not working properly (general TTY logging issues)

If this is helpful, consider adding Fixes #<number> to the PR description to auto-close the issue on merge.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Mar 18, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

This PR modifies the tty.ReadStream initialization to set autoClose: false, preventing automatic file descriptor closure, and adds regression tests to validate file descriptor validity through the ReadStream lifecycle.

Changes

Cohort / File(s) Summary
tty.ReadStream Initialization
src/js/node/tty.ts
Adds autoClose: false option to ReadStream initialization to prevent automatic file descriptor closure.
Regression Tests
test/regression/issue/27285.test.ts
Adds two regression tests validating tty.ReadStream behavior: one checking autoClose and autoDestroy flags, another verifying file descriptor validity after errors.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: preventing tty.ReadStream from auto-closing the file descriptor, which directly addresses the root cause of the linked issue #27285.
Description check ✅ Passed The PR description provides a comprehensive summary of the fix, root cause analysis, and test plan. It exceeds the basic template requirements with detailed explanation of the problem and verification steps.
Linked Issues check ✅ Passed The code changes directly address issue #27285 by setting autoClose: false in tty.ReadStream to prevent premature fd closure, matching tty.WriteStream behavior and aligning with Node.js. The regression test verifies autoClose: false and fd validity after errors.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the tty.ReadStream auto-close issue: minimal modification to src/js/node/tty.ts and focused regression test file without unrelated alterations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

📝 Coding Plan
  • Generate coding plan for human review comments

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

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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/27285.test.ts`:
- Around line 67-75: The test uses a fixed 200ms setTimeout to check fd validity
(setTimeout(..., 200)) which is flaky; replace this with an
event-loop/condition-based wait that repeatedly attempts fs.fstatSync(fd) until
it succeeds or a short test-timeout is reached (use an async loop with await new
Promise(resolve => setImmediate(resolve)) or a Promise.race with a timeout), log
"FD_STILL_VALID:true"/"FD_STILL_VALID:false" based on the condition, and end the
test by resolving/rejecting the test promise instead of calling process.exit(0);
update the code around setTimeout, fs.fstatSync, fd and process.exit to use the
async polling pattern.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 26c5bb0a-ddca-4d6e-89b2-99c4e58dfc4f

📥 Commits

Reviewing files that changed from the base of the PR and between 76f4f38 and 241a115.

📒 Files selected for processing (2)
  • src/js/node/tty.ts
  • test/regression/issue/27285.test.ts

Comment thread test/regression/issue/27285.test.ts Outdated
Comment on lines +67 to +75
setTimeout(() => {
try {
fs.fstatSync(fd);
console.log("FD_STILL_VALID:true");
} catch {
console.log("FD_STILL_VALID:false");
}
process.exit(0);
}, 200);

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.

⚠️ Potential issue | 🟡 Minor

Replace timer-based wait with condition/event-loop based check.

Line 67 to Line 75 uses setTimeout(..., 200), which makes this regression timing-sensitive and can be flaky.

🔧 Proposed change
-// After 200ms, check that the fd is still valid.
-// Before the fix, the fd would be closed within the first event loop tick
-// because autoDestroy:true caused the stream to destroy itself on errors.
-setTimeout(() => {
+// Check that the fd is still valid on the next event loop turn.
+// Before the fix, the fd could be closed within the first event loop tick.
+setImmediate(() => {
   try {
     fs.fstatSync(fd);
     console.log("FD_STILL_VALID:true");
   } catch {
     console.log("FD_STILL_VALID:false");
   }
   process.exit(0);
-}, 200);
+});

As per coding guidelines: “Do not use setTimeout in tests; instead await the condition to be met - you are testing the CONDITION, not TIME PASSING.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
setTimeout(() => {
try {
fs.fstatSync(fd);
console.log("FD_STILL_VALID:true");
} catch {
console.log("FD_STILL_VALID:false");
}
process.exit(0);
}, 200);
setImmediate(() => {
try {
fs.fstatSync(fd);
console.log("FD_STILL_VALID:true");
} catch {
console.log("FD_STILL_VALID:false");
}
process.exit(0);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/regression/issue/27285.test.ts` around lines 67 - 75, The test uses a
fixed 200ms setTimeout to check fd validity (setTimeout(..., 200)) which is
flaky; replace this with an event-loop/condition-based wait that repeatedly
attempts fs.fstatSync(fd) until it succeeds or a short test-timeout is reached
(use an async loop with await new Promise(resolve => setImmediate(resolve)) or a
Promise.race with a timeout), log "FD_STILL_VALID:true"/"FD_STILL_VALID:false"
based on the condition, and end the test by resolving/rejecting the test promise
instead of calling process.exit(0); update the code around setTimeout,
fs.fstatSync, fd and process.exit to use the async polling pattern.

Address review feedback: use setImmediate-based event loop tick polling
instead of a fixed 200ms setTimeout to check fd validity. This directly
targets the bug (fd closed within first event loop tick) and avoids
timing-dependent flakiness.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

@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 — one-line fix matching the existing autoClose: false pattern already used by tty.WriteStream.

Extended reasoning...

Overview

This PR modifies a single line in src/js/node/tty.ts to pass autoClose: false when constructing ReadStream, and adds a new regression test file. The fix aligns ReadStream with WriteStream, which already passes autoClose: false on line 117 of the same file.

Security risks

None. The change prevents an fd from being prematurely closed, which is the correct behavior matching Node.js. No auth, crypto, or permissions code is involved.

Level of scrutiny

This is a minimal, mechanical fix following an established pattern in the same file. The intent is clear, the change is one line, and it matches Node.js semantics. Low scrutiny is appropriate.

Other factors

The first test directly asserts autoClose:false and autoDestroy:false on the stream, providing solid regression coverage. The bug report filed is a nit about the second test quality — these are minor observations that do not affect the correctness of the actual fix. The PR has no CODEOWNERS concerns, no outstanding reviewer comments, and the CI build was triggered.

Comment thread test/regression/issue/27285.test.ts

@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 — single-line fix matching the existing WriteStream pattern, aligns with Node.js behavior.

Extended reasoning...

Overview

This PR adds autoClose: false to tty.ReadStream initialization in src/js/node/tty.ts (one line changed), matching what tty.WriteStream already does. A new regression test file test/regression/issue/27285.test.ts is added with two tests.

Security risks

None. The change prevents premature fd closure, which is a correctness fix. It does not introduce new attack surface or modify any security-sensitive code paths.

Level of scrutiny

This is a minimal, mechanical fix. The production change is a single option added to a constructor call, following an identical pattern already established by WriteStream on line 117 of the same file. The fix aligns Bun with Node.js behavior (where tty.ReadStream extends net.Socket and does not auto-close). This warrants low scrutiny.

Other factors

The setTimeout concern raised by both coderabbit and my previous review has been addressed — the current diff uses setImmediate polling. The /dev/null fallback nit I raised is minor and does not affect the validity of the core regression coverage (the first test directly asserts autoClose:false and autoDestroy:false). Build failures in CI appear to be infrastructure-related (asan/musl build failures), not caused by this change. No CODEOWNERS concerns for these files.

@github-actions

Copy link
Copy Markdown
Contributor

Closing this PR because it has been inactive for more than 90 days.

@github-actions github-actions Bot closed this Jun 17, 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.

Gemini CLI crash - ERROR ioctl(2) failed, EBADF

1 participant