Files
bun.sh/test/regression/issue/tui-app-tty-pattern.test.ts
robobun 9b97dd11e2 Fix TTY reopening after stdin EOF (#22591)
## Summary
- Fixes ENXIO error when reopening `/dev/tty` after stdin reaches EOF
- Fixes ESPIPE error when reading from reopened TTY streams  
- Adds ref/unref methods to tty.ReadStream for socket-like behavior
- Enables TUI applications that read piped input then switch to
interactive TTY mode

## The Problem
TUI applications and interactive CLI tools have a pattern where they:
1. Read piped input as initial data: `echo "data" | tui-app`
2. After stdin ends, reopen `/dev/tty` for interactive session
3. Use the TTY for interactive input/output

This didn't work in Bun due to missing functionality:
- **ESPIPE error**: TTY ReadStreams incorrectly had `pos=0` causing
`pread()` syscall usage which fails on character devices
- **Missing methods**: tty.ReadStream lacked ref/unref methods that TUI
apps expect for socket-like behavior
- **Hardcoded isTTY**: tty.ReadStream always set `isTTY = true` even for
non-TTY file descriptors

## The Solution
1. **Fix ReadStream position**: For fd-based streams (like TTY), don't
default `start` to 0. This keeps `pos` undefined, ensuring `read()`
syscall is used instead of `pread()`.

2. **Add ref/unref methods**: Implement ref/unref on tty.ReadStream
prototype to match Node.js socket-like behavior, allowing TUI apps to
control event loop behavior.

3. **Dynamic isTTY check**: Use `isatty(fd)` to properly detect if the
file descriptor is actually a TTY.

## Test Results
```bash
$ bun test test/regression/issue/tty-reopen-after-stdin-eof.test.ts
✓ can reopen /dev/tty after stdin EOF for interactive session
✓ TTY ReadStream should not set position for character devices

$ bun test test/regression/issue/tty-readstream-ref-unref.test.ts
✓ tty.ReadStream should have ref/unref methods when opened on /dev/tty
✓ tty.ReadStream ref/unref should behave like Node.js

$ bun test test/regression/issue/tui-app-tty-pattern.test.ts
✓ TUI app pattern: read piped stdin then reopen /dev/tty
✓ tty.ReadStream handles non-TTY file descriptors correctly
```

## Compatibility
Tested against Node.js v24.3.0 - our behavior now matches:
-  Can reopen `/dev/tty` after stdin EOF
-  TTY ReadStream has `pos: undefined` and `start: undefined`
-  tty.ReadStream has ref/unref methods for socket-like behavior
-  `isTTY` is properly determined using `isatty(fd)`

---------

Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
2025-09-13 01:00:57 -07:00

142 lines
4.5 KiB
TypeScript

import { expect, test } from "bun:test";
import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness";
// This test replicates the pattern used by TUI apps
// where they read piped stdin first, then reopen /dev/tty for interactive input
test("TUI app pattern: read piped stdin then reopen /dev/tty", async () => {
// Skip on Windows - no /dev/tty
if (process.platform === "win32") {
return;
}
// Check if 'script' command is available for TTY simulation
const scriptPath = Bun.which("script");
if (!scriptPath) {
// Skip test on platforms without 'script' command
return;
}
// Create a simpler test script that mimics TUI app behavior
const tuiAppPattern = `
const fs = require('fs');
const tty = require('tty');
async function main() {
// Step 1: Check if stdin is piped
if (!process.stdin.isTTY) {
// Read all piped input
let input = '';
for await (const chunk of process.stdin) {
input += chunk;
}
console.log('PIPED_INPUT:' + input.trim());
// Step 2: After stdin EOF, try to reopen /dev/tty
try {
const ttyFd = fs.openSync('/dev/tty', 'r');
const ttyStream = new tty.ReadStream(ttyFd);
// Verify TTY stream has expected properties
if (!ttyStream.isTTY) {
console.error('ERROR: tty.ReadStream not recognized as TTY');
process.exit(1);
}
// Verify ref/unref methods exist and work
if (typeof ttyStream.ref !== 'function' || typeof ttyStream.unref !== 'function') {
console.error('ERROR: ref/unref methods missing');
process.exit(1);
}
// Test that we can call ref/unref without errors
ttyStream.unref();
ttyStream.ref();
console.log('TTY_REOPENED:SUCCESS');
// Clean up - only destroy the stream, don't double-close the fd
ttyStream.destroy();
} catch (err) {
console.error('ERROR:' + err.code + ':' + err.message);
process.exit(1);
}
} else {
console.log('NO_PIPE');
}
}
main().catch(err => {
console.error('UNCAUGHT:' + err.message);
process.exit(1);
});
`;
using dir = tempDir("tui-app-test", {
"tui-app-sim.js": tuiAppPattern,
});
// Create a simple test that pipes input
// macOS and Linux have different script command syntax
const isMacOS = process.platform === "darwin";
const cmd = isMacOS
? [scriptPath, "-q", "/dev/null", "sh", "-c", `echo "piped content" | ${bunExe()} tui-app-sim.js`]
: [scriptPath, "-q", "-c", `echo "piped content" | ${bunExe()} tui-app-sim.js`, "/dev/null"];
const proc = Bun.spawn({
cmd,
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [exitCode, stdout, stderr] = await Promise.all([proc.exited, proc.stdout.text(), proc.stderr.text()]);
// First snapshot the combined output to see what actually happened
const output = stdout + (stderr ? "\nSTDERR:\n" + stderr : "");
// Use JSON.stringify to make control characters visible
const jsonOutput = JSON.stringify(normalizeBunSnapshot(output, dir));
// macOS script adds control characters, Linux doesn't
const expected = isMacOS
? `"^D\\b\\bPIPED_INPUT:piped content\\nTTY_REOPENED:SUCCESS"`
: `"PIPED_INPUT:piped content\\nTTY_REOPENED:SUCCESS"`;
expect(jsonOutput).toBe(expected);
// Then check exit code
expect(exitCode).toBe(0);
});
// Test that tty.ReadStream works correctly with various file descriptors
test("tty.ReadStream handles non-TTY file descriptors correctly", () => {
const fs = require("fs");
const tty = require("tty");
const path = require("path");
const os = require("os");
// Create a regular file in the system temp directory
const tempFile = path.join(os.tmpdir(), "test-regular-file-" + Date.now() + ".txt");
fs.writeFileSync(tempFile, "test content");
try {
const fd = fs.openSync(tempFile, "r");
const stream = new tty.ReadStream(fd);
// Regular file should not be identified as TTY
expect(stream.isTTY).toBe(false);
// ref/unref should still exist (for compatibility) but may be no-ops
expect(typeof stream.ref).toBe("function");
expect(typeof stream.unref).toBe("function");
// Clean up - only destroy the stream, don't double-close the fd
stream.destroy();
} finally {
try {
fs.unlinkSync(tempFile);
} catch (e) {
// Ignore cleanup errors
}
}
});