Skip to content

shell: forward SIGTERM and SIGHUP from the bun shell runner to its commands - #41802

Open
robobun wants to merge 3 commits into
mainfrom
robobun/1b2d8c43/bun-shell-forward-term-hup
Open

robobun wants to merge 3 commits into
mainfrom
robobun/1b2d8c43/bun-shell-forward-term-hup

Conversation

@robobun

@robobun robobun commented Sep 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • SIGTERM or SIGHUP to the pid of bun run --shell=bun <script>, bun exec '<cmd>' or bun x.sh kills bun at once (exit 143, no message) and leaves the running command reparented to init. docker stop and kill orphan the script. The system-shell path forwards the signal and waits (Bun__registerSignalsForForwarding, c-bindings.cpp:1091).
  • Cause: the Bun-shell runner (Interpreter::init_and_run_impl) only installs the Ctrl+C deferral (src/spawn/ctrl_c.rs). SIGTERM and SIGHUP stay SIG_DFL. As container PID 1 the kernel drops them and the runner never stops.

Fix

  • New src/runtime/shell/forward_signals.rs hooks both signals around the tick loop (an inherited SIG_IGN is left alone). The handler records the signal and wakes the loop. Between ticks Interpreter::forward_signal sends it to every live subprocess and keeps waiting.
  • The script runs no further command after the signal (Interpreter::interrupted). With no subprocess alive (a builtin, or nothing) the handler re-raises with SIG_DFL at once, _exit(128 + n) if PID 1 drops it.
  • bun run then matches the system-shell path: exit code 128 + signal prints script "x" was terminated by signal SIGTERM and re-raises. A command that handles the signal keeps its exit code.
  • Verified: test/cli/run/run-shell.test.ts (6 new tests, 4 fail on 1.4.3 with the child alive), shell and bun run suites, cargo check for linux, darwin, windows. Self-reviewed: 2 concerns, both addressed (Notes).

Background

  • With --shell=bun, bunfig [run] shell = "bun", or on Windows by default, bun run interprets scripts in-process on MiniEventLoop, a JS-free usockets loop. Each external command is a Cmd node owning a ShellSubprocess.
  • The loop's poll retries on EINTR, so a signal alone never returns control. us_wakeup_loop (eventfd write, mach message on macOS) takes no lock, so it is signal-safe.
  • sh -c <one command> execs the command, so the system-shell path's signal lands on the command itself. SIGINT stays unforwarded: the terminal sends it to the whole group (ctrl_c.rs), whose Child::alive() counter the handler reads.
Notes

Reproduction (bun 1.4.3):

printf '{"name":"p","scripts":{"srv":"sleep 30"}}' > package.json
bun run --shell=bun srv & sleep 1; kill -TERM $!; wait $!; echo rc=$?; pgrep -a sleep
# rc=143, "sleep 30" still running, ppid 1
bun exec 'sleep 30' & sleep 1; kill -TERM $!; wait $!; pgrep -a sleep
# same

With this branch (debug build):

  • bun run --shell=bun srv, SIGTERM: prints error: script "srv" was terminated by signal SIGTERM (Polite quit request), dies by SIGTERM (rc 143), sleep gone. SIGHUP: rc 129, same message with SIGHUP.
  • bun exec 'sleep 30' and bun s.sh (sleep 30): rc 143, sleep gone.
  • sleep 30 | sleep 30: both members receive the signal.
  • sleep 30; echo after, sleep 30 && echo after, sleep 30 || echo after: stop at sleep, after is not printed, rc 143.
  • A script whose command handles SIGTERM and exits 7: bun run reports exited with code 7, rc 7.
  • A command that ignores SIGTERM keeps running after the first signal and the runner stays with it. A second signal is forwarded again (no SA_RESETHAND). The system-shell path behaves the same way.
  • bun exec 'echo ready; yes > /dev/null' (builtin only, never yields to the event loop): SIGTERM ends it at once, death by signal.
  • sh -c 'trap "" TERM; exec bun run --shell=bun wait': SIGTERM stays ignored by the runner and the child, the script completes, rc 0.

Review follow-ups: install now runs before interp.run(), which spawns the first command before it returns, so no window is left where a child exists and the handler is not armed. Background jobs (&) are rejected by the parser today, so the ctrl_c::Child foreground count is the full set of subprocesses that can exist.

Self-review concerns and what changed:

  1. A builtin-only phase (or the gap between two commands) would have swallowed the signal until the script ended on its own. The handler now re-raises at once when ctrl_c::Child::alive() == 0. A builtin can hold the main thread without returning to the event loop (yes > /dev/null), so this cannot wait for a tick.
  2. a; b would have continued to b after a died from the forwarded signal. Interpreter::interrupted now stops every sequencing state (Script, Stmt, Binary, If) once a signal was received.

The handler saves and restores errno around the wakeup write. PREVIOUS dispositions live in a RacyCell<MaybeUninit<[sigaction; 2]>>, written on the CLI thread before the hook and read after the unhook.

PID 1: installing a handler is what makes the kernel deliver SIGTERM to init at all, so the runner now reacts there too. The final re-raise of bun run goes through Global::raise_ignoring_panic_handler; #38877 turns a dropped re-raise into _exit(128 + signo) inside that function. The handler's own no-subprocess path already has the fallback. Not run here: unshare --pid is not permitted in the development container.

Windows: install and uninstall only record the loop pointer. Console control events keep going through bun_spawn::ctrl_c. The new tests are skipped there (proc.kill() is TerminateProcess).

Related, not overlapping: #41478 does the same for --filter / --parallel (a different runner), #38877 covers the PID 1 exit status of the re-raise.

Suites run against the debug build: test/cli/run/run-shell.test.ts (9 pass, 3 runs), test/js/bun/shell/exec.test.ts, bunshell.test.ts, bunshell-file.test.ts, test/cli/run/run_command.test.ts, test/cli/run/shell-keepalive.test.ts. test/js/bun/shell/shell-hang.test.ts (700 ms budgets) and four --bun cases of test/regression/issue/ctrl-c.test.ts time out in this container on main as well, against the ASAN debug build. Neither touches this path.

@coderabbitai

coderabbitai Bot commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: b2e425e7-74c2-43fa-b00d-91456684d1df

📥 Commits

Reviewing files that changed from the base of the PR and between ae3c3ad and b993a16.

📒 Files selected for processing (6)
  • src/runtime/cli/run_command.rs
  • src/runtime/shell/forward_signals.rs
  • src/runtime/shell/interpreter.rs
  • src/runtime/shell/mod.rs
  • src/runtime/shell/states/Binary.rs
  • test/cli/run/run-shell.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The shell runtime now captures SIGTERM and SIGHUP, forwards them to active subprocesses, interrupts command sequences, restores signal dispositions, and reports signal-derived exits. POSIX tests cover forwarding, exit codes, sequencing, direct termination, and inherited ignored signals.

Changes

Shell signal handling

Layer / File(s) Summary
Signal capture and lifecycle
src/runtime/shell/forward_signals.rs
The runtime captures SIGTERM and SIGHUP, records pending signals, wakes the event loop, preserves ignored dispositions, restores handlers, and detects signal-derived exits.
Interpreter subprocess forwarding
src/runtime/shell/mod.rs, src/runtime/shell/interpreter.rs, src/runtime/shell/states/Binary.rs
The mini event loop installs signal forwarding, sends signals to live shell subprocesses, and stops command sequences after interruption.
Runner reporting and validation
src/runtime/cli/run_command.rs, test/cli/run/run-shell.test.ts
The runner reports and re-raises signal termination. POSIX tests cover forwarding, exit codes, sequencing, direct termination, and ignored dispositions.

Suggested reviewers: alii, jarred-sumner

Merge Risk: ⚪ Minimal · up to b993a

The change adds signal forwarding and termination handling for Bun shell runners with targeted POSIX coverage; no actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the main change: forwarding SIGTERM and SIGHUP from the Bun shell runner to its commands.
Description check ✅ Passed The description explains the problem, implementation, behavior, edge cases, and verification results. It does not use the template headings exactly, but the required content is present under equivalen…

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread src/runtime/shell/interpreter.rs Outdated
Comment thread src/runtime/shell/forward_signals.rs
…mmands

A signal sent to the pid of bun run --shell=bun, bun exec, or bun x.sh
ended bun at once and left the command it was running reparented to
init. The runner now records the signal, forwards it to every live
subprocess, waits for them, runs no further command, and exits the way
the system-shell path does. With no subprocess alive the signal ends the
runner at once, as before.
@robobun
robobun force-pushed the robobun/1b2d8c43/bun-shell-forward-term-hup branch from b993a16 to 892be33 Compare September 8, 2026 00:38
Comment thread src/runtime/shell/forward_signals.rs Outdated
Comment thread src/runtime/shell/forward_signals.rs Outdated
Comment thread src/runtime/shell/forward_signals.rs Outdated
Comment thread src/runtime/shell/forward_signals.rs Outdated
Comment on lines +135 to +138
/// The signal the script was ended by: one arrived and was forwarded, and the
/// script's exit code is the one a shell reports for a command that signal
/// killed. A command that handled the signal and exited on its own terms
/// keeps its exit code.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +757 to +758
// `forward_signal` runs between ticks, so no task holds a borrow of
// the node arena.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +938 to +941
/// Send `sig` to every subprocess that is still running (see
/// `forward_signals`). Every command the script runs, pipeline members and
/// command substitutions included, is a `Cmd` node in the arena. Call
/// between ticks only: no callback holds a borrow of a subprocess then.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +966 to +968
/// where it is instead of running its next command. After a termination
/// signal (see `forward_signals`) every sequence stops, so the script ends
/// with the commands that received it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Comment on lines +1 to +5
//! SIGTERM / SIGHUP sent to the pid of a bun-shell script runner (`bun run
//! --shell=bun`, `bun exec`, `bun x.sh`): recorded here, forwarded to the live
//! subprocesses by the interpreter between ticks, which then waits for them and
//! starts no further command. SIGINT is `bun_spawn::ctrl_c`'s (the terminal
//! signals the whole process group, so there is nothing to forward).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants