fix(prompt): prevent freeze when pasting >1023 bytes to stdin

When stdin is an interactive TTY in canonical mode (ICANON), input is
line-buffered until newline with a kernel buffer limit of ~1024 bytes.
Pasting more than this limit without a newline causes a deadlock: the
terminal blocks on write (paste) while Bun blocks on read (waiting for
newline).

This fix adds a new TTY mode (mode 3) that disables canonical mode but
keeps echo and signals enabled, providing user-friendly behavior while
avoiding the buffer limit deadlock. The mode is applied to prompt(),
confirm(), and alert() when stdin is a TTY.

Fixes #26458

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Bot
2026-01-26 11:56:36 +00:00
parent bfe40e8760
commit 3387729a16
3 changed files with 156 additions and 0 deletions

View File

@@ -150,6 +150,21 @@ extern "C" int Bun__ttySetMode(int fd, int mode)
case 2: // io
uv__tty_make_raw(&tmp);
std::call_once(reset_once_flag, [] {
Bun__atexit([] {
uv_tty_reset_mode();
});
});
break;
case 3: // prompt - disable canonical mode but keep echo and signals
// This mode is used by prompt() to avoid deadlock when pasting large input.
// In canonical mode, the terminal buffers input until newline (~1024 bytes limit).
// When pasting more than the buffer size without newline, both read and write block.
// Disabling ICANON allows immediate byte-by-byte reading while keeping user-friendly echo.
tmp.c_lflag &= ~(ICANON);
tmp.c_cc[VMIN] = 1;
tmp.c_cc[VTIME] = 0;
std::call_once(reset_once_flag, [] {
Bun__atexit([] {
uv_tty_reset_mode();

View File

@@ -45,6 +45,19 @@ fn alert(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSErr
// * Not pertinent to use their complex system in a server context.
bun.Output.flush();
// On POSIX, switch to non-canonical TTY mode to avoid deadlock when pasting large input.
const stdin_is_tty = if (comptime Environment.isPosix) std.posix.isatty(std.posix.STDIN_FILENO) else false;
if (comptime Environment.isPosix) {
if (stdin_is_tty) {
_ = Bun__ttySetMode(std.posix.STDIN_FILENO, 3);
}
}
defer if (comptime Environment.isPosix) {
if (stdin_is_tty) {
_ = Bun__ttySetMode(std.posix.STDIN_FILENO, 0);
}
};
// 7. Optionally, pause while waiting for the user to acknowledge the message.
var stdin = std.fs.File.stdin();
var stdin_buf: [1]u8 = undefined;
@@ -95,6 +108,19 @@ fn confirm(globalObject: *jsc.JSGlobalObject, callframe: *jsc.CallFrame) bun.JSE
// * Not relevant in a server context.
bun.Output.flush();
// On POSIX, switch to non-canonical TTY mode to avoid deadlock when pasting large input.
const stdin_is_tty = if (comptime Environment.isPosix) std.posix.isatty(std.posix.STDIN_FILENO) else false;
if (comptime Environment.isPosix) {
if (stdin_is_tty) {
_ = Bun__ttySetMode(std.posix.STDIN_FILENO, 3);
}
}
defer if (comptime Environment.isPosix) {
if (stdin_is_tty) {
_ = Bun__ttySetMode(std.posix.STDIN_FILENO, 0);
}
};
// 6. Pause until the user responds either positively or negatively.
var stdin = std.fs.File.stdin();
var stdin_buf: [1024]u8 = undefined;
@@ -261,6 +287,22 @@ pub const prompt = struct {
}
};
// On POSIX, switch to non-canonical TTY mode to avoid deadlock when pasting large input.
// In canonical mode (ICANON), the terminal buffers input until newline with ~1024 byte limit.
// When pasting more than the buffer size without newline, both read and write block.
// Mode 3 disables ICANON but keeps echo and signals enabled for user-friendly behavior.
const stdin_is_tty = if (comptime Environment.isPosix) std.posix.isatty(std.posix.STDIN_FILENO) else false;
if (comptime Environment.isPosix) {
if (stdin_is_tty) {
_ = Bun__ttySetMode(std.posix.STDIN_FILENO, 3);
}
}
defer if (comptime Environment.isPosix) {
if (stdin_is_tty) {
_ = Bun__ttySetMode(std.posix.STDIN_FILENO, 0);
}
};
// 7. Pause while waiting for the user's response.
const reader = bun.Output.buffered_stdin.reader();
var second_byte: ?u8 = null;
@@ -351,3 +393,5 @@ const bun = @import("bun");
const Environment = bun.Environment;
const c = bun.c;
const jsc = bun.jsc;
extern fn Bun__ttySetMode(fd: c_int, mode: c_int) c_int;

View File

@@ -0,0 +1,97 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
// https://github.com/oven-sh/bun/issues/26458
// Bun freezes when pasting >1023 bytes to stdin without trailing newline
// This was caused by terminal canonical mode buffering (~1024 byte limit)
// The fix disables ICANON during prompt/alert/confirm reads on POSIX systems
//
// Note: The actual freeze only occurs when stdin is an interactive TTY (not a pipe).
// This test uses stdin: "pipe" so it can't reproduce the exact freeze scenario,
// but it verifies that prompt/confirm/alert can handle large input correctly.
describe("stdin large input handling", () => {
// Generate test data larger than the canonical mode buffer limit (~1024 bytes)
const largeInput = "x".repeat(2048);
test("prompt() handles large input without hanging", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log(prompt('Enter:'))"],
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
// Write large input followed by newline
proc.stdin!.write(largeInput + "\n");
await proc.stdin!.end();
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// The output should contain our large input (after the "Enter: " prompt)
expect(stdout).toContain(largeInput);
expect(exitCode).toBe(0);
});
test("confirm() handles large input without hanging", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log(confirm('Confirm:'))"],
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
// Write large input (not starting with y/Y) followed by newline
proc.stdin!.write(largeInput + "\n");
await proc.stdin!.end();
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// confirm() should return false for non-y input
expect(stdout).toContain("false");
expect(exitCode).toBe(0);
});
test("alert() handles large input without hanging", async () => {
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", "alert('Hello'); console.log('done')"],
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
// Write large input followed by newline (alert just waits for Enter)
proc.stdin!.write(largeInput + "\n");
await proc.stdin!.end();
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// alert() should complete and print "done"
expect(stdout).toContain("done");
expect(exitCode).toBe(0);
});
test("prompt() handles very large input (10KB)", async () => {
const veryLargeInput = "y".repeat(10240);
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", "console.log(prompt('Enter:').length)"],
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
proc.stdin!.write(veryLargeInput + "\n");
await proc.stdin!.end();
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should output the length of our input
expect(stdout).toContain("10240");
expect(exitCode).toBe(0);
});
});