Files
bun-src/test/regression/issue/tty-reopen-after-stdin-eof.test.ts
2026-08-27 21:09:14 +00:00

152 lines
4.7 KiB
TypeScript

import { expect, test } from "bun:test";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { join } from "path";
// Run `sh -c <cmd>` inside a fresh PTY session and return the child's output
// as LF-normalized lines. Bun.spawn's inline `terminal:` option creates the
// PTY and runs setsid()+TIOCSCTTY in the child, so the child is always the
// foreground process group of its own controlling terminal. That avoids the
// SIGTTOU stop the no-`script` fallback used to hit under
// `bun test --parallel`, where the worker is a background pgrp.
async function runInPty(shellCmd: string, cwd: string): Promise<{ lines: string[]; exitCode: number }> {
const chunks: Uint8Array[] = [];
await using proc = Bun.spawn({
cmd: ["sh", "-c", shellCmd],
env: bunEnv,
cwd,
terminal: {
data(_term, data) {
chunks.push(data.slice());
},
},
});
const exitCode = await proc.exited;
const output = Buffer.concat(chunks).toString("utf8");
const lines = output
.split(/\r?\n/)
.map(l => l.trim())
.filter(Boolean);
return { lines, exitCode };
}
// Skip on Windows as it doesn't have /dev/tty
test.skipIf(isWindows)("can reopen /dev/tty after stdin EOF for interactive session", async () => {
// This test ensures that Bun can reopen /dev/tty after stdin reaches EOF,
// which is needed for tools like Claude Code that read piped input then
// switch to interactive mode.
const testScript = `
const fs = require('fs');
const tty = require('tty');
let inputData = '';
process.stdin.on('data', (chunk) => {
inputData += chunk;
});
process.stdin.on('end', () => {
console.log('GOT_INPUT:' + inputData.trim());
try {
const fd = fs.openSync('/dev/tty', 'r+');
console.log('OPENED_TTY:true');
const ttyStream = new tty.ReadStream(fd);
console.log('CREATED_STREAM:true');
console.log('POS:' + ttyStream.pos);
console.log('START:' + ttyStream.start);
if (typeof ttyStream.setRawMode === 'function') {
ttyStream.setRawMode(true);
console.log('SET_RAW_MODE:true');
ttyStream.setRawMode(false);
}
ttyStream.destroy();
fs.closeSync(fd);
console.log('SUCCESS:true');
process.exit(0);
} catch (err) {
console.log('ERROR:' + err.code);
process.exit(1);
}
});
if (process.stdin.isTTY) {
console.log('ERROR:NO_PIPED_INPUT');
process.exit(1);
}
`;
using dir = tempDir("tty-reopen", { "test.js": testScript });
const scriptPath = join(String(dir), "test.js");
const { lines, exitCode } = await runInPty(`echo 'test input' | ${bunExe()} ${scriptPath}`, String(dir));
expect(lines).toEqual([
"GOT_INPUT:test input",
"OPENED_TTY:true",
"CREATED_STREAM:true",
"POS:undefined",
"START:undefined",
"SET_RAW_MODE:true",
"SUCCESS:true",
]);
expect(exitCode).toBe(0);
});
// Skip on Windows as it doesn't have /dev/tty
test.skipIf(isWindows)("TTY ReadStream should not set position for character devices", async () => {
// This test ensures that when creating a ReadStream with an fd (like for TTY),
// the position remains undefined so that fs.read uses read() syscall instead
// of pread() which would fail with ESPIPE on character devices.
const testScript = `
const fs = require('fs');
const tty = require('tty');
try {
const fd = fs.openSync('/dev/tty', 'r+');
const ttyStream = new tty.ReadStream(fd);
console.log('POS_TYPE:' + typeof ttyStream.pos);
console.log('START_TYPE:' + typeof ttyStream.start);
const originalRead = fs.read;
let capturedPosition = 'NOT_CALLED';
let readCalled = false;
fs.read = function(fd, buffer, offset, length, position, callback) {
capturedPosition = position;
readCalled = true;
process.nextTick(() => callback(null, 0, buffer));
return originalRead;
};
ttyStream.on('data', () => {});
ttyStream.on('error', () => {});
console.log('POSITION_PASSED:' + capturedPosition);
console.log('POSITION_TYPE:' + typeof capturedPosition);
console.log('READ_CALLED:' + readCalled);
ttyStream.destroy();
fs.closeSync(fd);
process.exit(0);
} catch (err) {
console.log('ERROR:' + err.code);
process.exit(1);
}
`;
using dir = tempDir("tty-position", { "test.js": testScript });
const scriptPath = join(String(dir), "test.js");
const { lines, exitCode } = await runInPty(`${bunExe()} ${scriptPath}`, String(dir));
expect(lines).toEqual([
"POS_TYPE:undefined",
"START_TYPE:undefined",
"POSITION_PASSED:NOT_CALLED",
"POSITION_TYPE:string",
"READ_CALLED:false",
]);
expect(exitCode).toBe(0);
});