Skip to main content

command_stream/
signal.rs

1//! Signal delivery and the signal exit-code convention.
2//!
3//! Both runners stop processes the same way, so the signal vocabulary lives in
4//! one place instead of being duplicated per runner:
5//!
6//!   * [`ProcessRunner::kill`](crate::ProcessRunner::kill) /
7//!     [`ProcessRunner::kill_with`](crate::ProcessRunner::kill_with)
8//!   * [`OutputStream::kill`](crate::OutputStream::kill) /
9//!     [`OutputStream::kill_with`](crate::OutputStream::kill_with)
10//!
11//! The model mirrors the JavaScript implementation:
12//!
13//!   1. The requested signal is delivered to the child **and** its process
14//!      group, so grandchildren spawned by a shell are stopped too. The group
15//!      is skipped for a child that shares the caller's terminal, which stays
16//!      in the caller's process group by design so that CTRL+C keeps reaching
17//!      it.
18//!   2. The child is given a grace period ([`DEFAULT_KILL_GRACE_MS`]) to run its
19//!      own signal handler and exit on its own terms.
20//!   3. If it is still alive when the grace period expires, `SIGKILL` follows,
21//!      so a process that ignores the signal still terminates.
22//!   4. The reported exit code is the conventional `128 + signal` value
23//!      ([`signal_exit_code`]).
24//!
25//! A grace period of zero collapses steps 1 to 3 into `SIGKILL` alone: any work
26//! between the requested signal and the escalation is a window the child can be
27//! scheduled in, so delivering it first would make "no grace" a race rather
28//! than a guarantee. The exit code still reflects the signal that was asked
29//! for.
30//!
31//! Windows stops the tree immediately with `taskkill /PID <pid> /T /F` before
32//! the parent exits. There is no POSIX signal handler or grace period there;
33//! the caller falls back to terminating the direct child if taskkill fails.
34
35/// Default signal used to stop a process when no explicit signal is given.
36///
37/// Mirrors the JavaScript `killSignal` default.
38pub const DEFAULT_KILL_SIGNAL: &str = "SIGTERM";
39
40/// Default grace period (in milliseconds) between the requested signal and the
41/// forceful `SIGKILL` escalation.
42///
43/// Mirrors the JavaScript `killGrace` default. It is what makes a graceful
44/// shutdown possible: without it the child is killed before its own handler
45/// gets to run.
46pub const DEFAULT_KILL_GRACE_MS: u64 = 100;
47
48/// Map a signal name to its numeric value.
49///
50/// Unknown names fall back to `SIGTERM`, matching the JavaScript
51/// implementation's behavior for unrecognized signals.
52///
53/// ```
54/// use command_stream::signal::signal_number;
55///
56/// assert_eq!(signal_number("SIGINT"), 2);
57/// assert_eq!(signal_number("SIGKILL"), 9);
58/// assert_eq!(signal_number("SIGTERM"), 15);
59/// ```
60pub fn signal_number(signal: &str) -> i32 {
61    match signal {
62        "SIGHUP" => 1,
63        "SIGINT" => 2,
64        "SIGQUIT" => 3,
65        "SIGKILL" => 9,
66        "SIGUSR1" => 10,
67        "SIGUSR2" => 12,
68        "SIGTERM" => 15,
69        _ => 15,
70    }
71}
72
73/// The exit code reported for a process stopped with `signal`, following the
74/// conventional `128 + signal` mapping used by POSIX shells.
75///
76/// ```
77/// use command_stream::signal::signal_exit_code;
78///
79/// assert_eq!(signal_exit_code("SIGINT"), 130); // CTRL+C
80/// assert_eq!(signal_exit_code("SIGTERM"), 143);
81/// assert_eq!(signal_exit_code("SIGKILL"), 137);
82/// ```
83pub fn signal_exit_code(signal: &str) -> i32 {
84    128 + signal_number(signal)
85}
86
87/// Who a signal is delivered to.
88///
89/// Only the runner that spawned the child knows which of these applies, so it
90/// is stated rather than discovered: a child spawned with `process_group(0)`
91/// leads its own group, and a child left in the caller's group does not.
92// Windows has neither signals nor process groups, so the distinction only ever
93// narrows to `ProcessAndGroup` there and the other variant is genuinely unused.
94#[cfg_attr(not(unix), allow(dead_code))]
95#[derive(Clone, Copy, PartialEq, Eq, Debug)]
96pub(crate) enum Delivery {
97    /// The process alone, for a child sharing the caller's process group.
98    ProcessOnly,
99    /// The process and the group it leads, which is what reaches grandchildren.
100    ProcessAndGroup,
101}
102
103/// Send a signal to a process and, when it leads one, its process group.
104///
105/// Delivery to the group (negative pid) is what reaches grandchildren, e.g. the
106/// real command behind a `sh -c` wrapper. It must not be attempted for a child
107/// left in the caller's group, where `-pid` would name a group we do not own -
108/// at best a non-existent one, at worst an unrelated group that reused the
109/// number.
110///
111/// Group leadership is passed in rather than looked up with `getpgid` because
112/// the leader is usually dead by the time the group is signalled: the first
113/// signal kills the `sh` wrapper, and the escalation follows a grace period
114/// later. Linux answers `getpgid` for a zombie, but macOS does not - its
115/// `proc_find` skips zombies, so the lookup failed with `ESRCH` and the group,
116/// including the still-running grandchild, was never signalled at all.
117///
118/// Both deliveries are best effort: the process may already have exited, which
119/// is not an error for a caller that only wants it stopped.
120#[cfg(unix)]
121pub(crate) fn send_signal_to_process(pid: u32, signal: &str, delivery: Delivery) {
122    use nix::sys::signal::{kill, Signal};
123    use nix::unistd::Pid;
124
125    let sig = match signal {
126        "SIGHUP" => Signal::SIGHUP,
127        "SIGINT" => Signal::SIGINT,
128        "SIGQUIT" => Signal::SIGQUIT,
129        "SIGKILL" => Signal::SIGKILL,
130        "SIGUSR1" => Signal::SIGUSR1,
131        "SIGUSR2" => Signal::SIGUSR2,
132        "SIGTERM" => Signal::SIGTERM,
133        _ => Signal::SIGTERM,
134    };
135
136    // Signal the whole process group (negative pid) first, so grandchildren are
137    // reached even if the group leader dies on the signal we send it next.
138    if delivery == Delivery::ProcessAndGroup {
139        let _ = kill(Pid::from_raw(-(pid as i32)), sig);
140    }
141    // Signal the process itself.
142    let _ = kill(Pid::from_raw(pid as i32), sig);
143}
144
145/// Stop the Windows tree while the parent still identifies its descendants.
146/// The caller's `start_kill()` remains the fallback if taskkill cannot run.
147#[cfg(windows)]
148pub(crate) fn send_signal_to_process(pid: u32, _signal: &str, _delivery: Delivery) {
149    use std::os::windows::process::CommandExt;
150    use std::process::{Command, Stdio};
151
152    let result = Command::new("taskkill")
153        .args(["/PID", &pid.to_string(), "/T", "/F"])
154        .stdin(Stdio::null())
155        .stdout(Stdio::null())
156        .stderr(Stdio::null())
157        .creation_flags(0x08000000) // CREATE_NO_WINDOW
158        .status();
159    crate::trace::trace_lazy("ProcessRunner", || match result {
160        Ok(status) if status.success() => format!("taskkill stopped process tree {pid}"),
161        Ok(status) => format!("taskkill failed for process {pid}: {status}"),
162        Err(error) => format!("taskkill failed for process {pid}: {error}"),
163    });
164}
165
166/// Other platforms use the caller's direct-child termination fallback.
167#[cfg(not(any(unix, windows)))]
168pub(crate) fn send_signal_to_process(_pid: u32, _signal: &str, _delivery: Delivery) {}