Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
324e927731 fix: ignore SIGTTIN/SIGTTOU to prevent Bun from stopping when child takes terminal foreground
When a child process spawned via `Bun.spawn` runs an interactive shell
(e.g. `bash -ci`), the child calls `tcsetpgrp()` to become the foreground
process group. This puts Bun in a background process group, and any
subsequent terminal I/O causes the kernel to send SIGTTIN/SIGTTOU, whose
default action is to stop (suspend) the parent process.

Fix by ignoring SIGTTIN and SIGTTOU during process initialization, matching
the behavior of shell implementations that manage child processes.

Closes https://github.com/oven-sh/bun/issues/17500

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-20 02:33:48 +00:00
2 changed files with 95 additions and 0 deletions

View File

@@ -515,6 +515,15 @@ extern "C" void bun_initialize_process()
close(devNullFd_);
}
// Ignore SIGTTIN and SIGTTOU to prevent Bun from being suspended when
// a child process (e.g. `bash -ci`) takes foreground control of the
// terminal via tcsetpgrp(). Without this, Bun gets stopped by the
// kernel when it attempts terminal I/O while in a background process
// group. This matches how shells handle job control.
// See: https://github.com/oven-sh/bun/issues/17500
signal(SIGTTIN, SIG_IGN);
signal(SIGTTOU, SIG_IGN);
// Restore TTY state on exit
if (anyTTYs) {
struct sigaction sa;

View File

@@ -0,0 +1,86 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe } from "harness";
test("Bun.spawn with bash -ci does not hang or stop the process", async () => {
// Spawn a bun process that runs bash -ci multiple times.
// Before the fix, the parent bun process could be stopped by SIGTTIN/SIGTTOU
// when bash -ci takes foreground control of the terminal via tcsetpgrp().
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
for (let i = 0; i < 3; i++) {
const child = Bun.spawn(["bash", "-ci", "echo iteration" + i], {
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
]);
console.log(stdout.trim());
}
console.log("done");
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
let timeout = false;
const timer = setTimeout(() => {
timeout = true;
proc.kill();
}, 10000);
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
clearTimeout(timer);
expect(timeout).toBeFalse();
expect(stdout).toContain("iteration0");
expect(stdout).toContain("iteration1");
expect(stdout).toContain("iteration2");
expect(stdout).toContain("done");
expect(exitCode).toBe(0);
});
test("SIGTTIN and SIGTTOU signals do not stop the process", async () => {
// Verify that Bun ignores SIGTTIN and SIGTTOU by sending them to itself.
// Before the fix, these signals would use the default handler (stop the process).
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`
const os = require("os");
// Send SIGTTIN to self - should be ignored
process.kill(process.pid, os.constants.signals.SIGTTIN);
// Send SIGTTOU to self - should be ignored
process.kill(process.pid, os.constants.signals.SIGTTOU);
// If we reach here, the signals were properly ignored
console.log("signals ignored successfully");
`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
let timeout = false;
const timer = setTimeout(() => {
timeout = true;
proc.kill();
}, 5000);
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
clearTimeout(timer);
expect(timeout).toBeFalse();
expect(stdout.trim()).toBe("signals ignored successfully");
expect(exitCode).toBe(0);
});