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 crate::trace::trace_lazy("ProcessRunner", || {
153 format!("taskkill starting for process tree {pid}")
154 });
155 let result = Command::new("taskkill")
156 .args(["/PID", &pid.to_string(), "/T", "/F"])
157 .stdin(Stdio::null())
158 .stdout(Stdio::null())
159 .stderr(Stdio::null())
160 .creation_flags(0x08000000) // CREATE_NO_WINDOW
161 .status();
162 crate::trace::trace_lazy("ProcessRunner", || match result {
163 Ok(status) if status.success() => format!("taskkill stopped process tree {pid}"),
164 Ok(status) => format!("taskkill failed for process {pid}: {status}"),
165 Err(error) => format!("taskkill failed for process {pid}: {error}"),
166 });
167}
168
169/// Other platforms use the caller's direct-child termination fallback.
170#[cfg(not(any(unix, windows)))]
171pub(crate) fn send_signal_to_process(_pid: u32, _signal: &str, _delivery: Delivery) {}