Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
4033680bb6 fix(Terminal): generate signals for Ctrl+C/Ctrl+\/Ctrl+Z
Fixes #25779

`Bun.Terminal.write()` was bypassing PTY line discipline, causing
control characters like Ctrl+C to be delivered as stdin data instead
of generating signals (SIGINT, SIGQUIT, SIGTSTP).

Two issues were fixed:

1. In Terminal.zig, added `processSignalCharacters()` to detect
   signal-generating control characters in write data and send the
   appropriate signals via `TIOCSIG` ioctl to the foreground process
   group. The function respects the termios ISIG flag and uses the
   configured control characters (INTR, QUIT, SUSP).

2. In js_bun_spawn_bindings.zig, fixed PTY session setup when passing
   an existing Terminal object to spawn(). The `pty_slave_fd` was only
   being passed for newly created terminals, not existing ones. This
   caused spawned processes to not call setsid() and TIOCSCTTY, so they
   weren't session leaders and didn't have the PTY as their controlling
   terminal.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:40:37 +00:00
3 changed files with 129 additions and 2 deletions

View File

@@ -616,6 +616,16 @@ pub fn write(
return JSValue.jsNumber(0);
}
// On POSIX, check for signal-generating control characters and send
// appropriate signals via TIOCSIG ioctl. This is needed because data
// written to the PTY master bypasses the slave-side line discipline
// that would normally process these characters and generate signals.
if (comptime Environment.isPosix) {
if (this.master_fd != bun.invalid_fd) {
this.processSignalCharacters(bytes);
}
}
// Write using the streaming writer
const write_result = this.writer.write(bytes);
return switch (write_result) {
@@ -626,6 +636,51 @@ pub fn write(
};
}
/// TIOCSIG ioctl - sends a signal to the foreground process group of the PTY
/// On macOS: _IO('t', 95) = 0x2000745f
/// On Linux: _IOW('T', 0x36, int) = 0x40045436
/// Note: Despite Linux using _IOW (which normally implies pointer semantics),
/// the kernel actually expects the signal value to be passed directly as the
/// third argument, not as a pointer. This is a quirk of the Linux PTY ioctl.
const TIOCSIG: c_ulong = if (Environment.isMac) 0x2000745f else 0x40045436;
/// Process signal-generating control characters in the input data.
/// When ISIG is enabled in termios (cooked mode), control characters like
/// Ctrl+C should generate signals. Since data written to the PTY master
/// bypasses line discipline processing, we need to explicitly send signals
/// via TIOCSIG ioctl when we detect these characters.
fn processSignalCharacters(this: *Terminal, bytes: []const u8) void {
if (comptime !Environment.isPosix) return;
// Get current termios to check if ISIG is enabled and get control char mappings
const termios_data = getTermios(this.master_fd) orelse return;
// Check if signal generation is enabled (ISIG flag in local flags)
if (!termios_data.lflag.ISIG) return;
// Get the control characters from termios (they can be customized)
const intr_char = termios_data.cc[@intFromEnum(std.posix.V.INTR)]; // Usually Ctrl+C (0x03)
const quit_char = termios_data.cc[@intFromEnum(std.posix.V.QUIT)]; // Usually Ctrl+\ (0x1c)
const susp_char = termios_data.cc[@intFromEnum(std.posix.V.SUSP)]; // Usually Ctrl+Z (0x1a)
// Scan for signal-generating characters and send appropriate signals
for (bytes) |byte| {
const signal: c_int = if (byte == intr_char and intr_char != 0)
std.posix.SIG.INT
else if (byte == quit_char and quit_char != 0)
std.posix.SIG.QUIT
else if (byte == susp_char and susp_char != 0)
std.posix.SIG.TSTP
else
continue;
// Send signal to foreground process group via TIOCSIG
// Both macOS and Linux expect the signal value directly (not as a pointer),
// despite Linux's _IOW macro normally implying pointer semantics.
_ = std.c.ioctl(this.master_fd.cast(), TIOCSIG, signal);
}
}
/// Resize the terminal
pub fn resize(
this: *Terminal,

View File

@@ -571,10 +571,13 @@ pub fn spawnMaybeSync(
.extra_fds = extra_fds.items,
.argv0 = argv0,
.can_block_entire_thread_to_reduce_cpu_usage_in_fast_path = can_block_entire_thread_to_reduce_cpu_usage_in_fast_path,
// Only pass pty_slave_fd for newly created terminals (for setsid+TIOCSCTTY setup).
// For existing terminals, the session is already set up - child just uses the fd as stdio.
// Pass pty_slave_fd for both new and existing terminals.
// The child process needs to call setsid() + TIOCSCTTY to become the
// controlling terminal, which is required for proper job control and
// signal generation (SIGINT from Ctrl+C, SIGTSTP from Ctrl+Z, etc.)
.pty_slave_fd = if (Environment.isPosix) blk: {
if (terminal_info) |ti| break :blk ti.terminal.getSlaveFd().native();
if (existing_terminal) |et| break :blk et.getSlaveFd().native();
break :blk -1;
} else {},

View File

@@ -722,6 +722,75 @@ describe.todoIf(isWindows)("Bun.Terminal", () => {
proc.kill();
await proc.exited;
});
// Regression test for https://github.com/oven-sh/bun/issues/25779
// Ctrl+C (0x03) written to terminal should generate SIGINT to foreground process
test("Ctrl+C generates SIGINT for subprocess (issue #25779)", async () => {
const received: Uint8Array[] = [];
await using terminal = new Bun.Terminal({
cols: 80,
rows: 24,
data(term, data) {
received.push(new Uint8Array(data));
},
});
// Spawn bash running a sleep command - sleep 60 gives us plenty of time
const proc = Bun.spawn({
cmd: ["/bin/bash", "--norc", "--noprofile", "-c", "trap 'echo SIGINT_RECEIVED; exit 130' INT; sleep 60"],
terminal: terminal,
env: { ...bunEnv, PS1: "" },
});
// Wait for bash to start and set up the trap
await Bun.sleep(200);
// Send Ctrl+C - this should generate SIGINT and trigger the trap
terminal.write("\x03");
// Wait for signal to be processed and the process to exit
const exitedWithTimeout = await Promise.race([proc.exited.then(() => true), Bun.sleep(2000).then(() => false)]);
// Process should have exited from SIGINT (not still running)
expect(exitedWithTimeout).toBe(true);
// Check that the trap was triggered (SIGINT was received)
const output = Buffer.concat(received).toString();
expect(output).toContain("SIGINT_RECEIVED");
// Exit code 130 = 128 + 2 (SIGINT)
expect(proc.exitCode).toBe(130);
});
// Test Ctrl+\ generates SIGQUIT for subprocess
test("Ctrl+\\ generates SIGQUIT for subprocess", async () => {
await using terminal = new Bun.Terminal({
cols: 80,
rows: 24,
});
// Spawn a simple sleep process (SIGQUIT will kill it)
const proc = Bun.spawn({
cmd: ["/bin/sleep", "60"],
terminal: terminal,
env: bunEnv,
});
// Wait for sleep to start
await Bun.sleep(200);
// Send Ctrl+\ (0x1c) - generates SIGQUIT
terminal.write("\x1c");
// Wait for signal to be processed
const exitedWithTimeout = await Promise.race([proc.exited.then(() => true), Bun.sleep(2000).then(() => false)]);
expect(exitedWithTimeout).toBe(true);
// Sleep should be killed by SIGQUIT
expect(proc.signalCode).toBe("SIGQUIT");
});
});
describe("ANSI escape sequences", () => {