agentmux_common/process.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Best-effort child-process termination, by PID.
5//!
6//! Before this module, the same two operations were implemented five
7//! times across four crates — `agentmux-srv` (twice), `agentmux-cef`,
8//! `agentmux-bashwrap` — one of which described itself as a *"Mirror of
9//! the cef-side helper"* (`docs/reports/REPORT_DRY_AND_MODULARITY_AUDIT_2026_09_06.md`
10//! §2.2). Only one of the five suppressed the console flash that `taskkill`
11//! otherwise causes from a GUI-subsystem parent; here every Windows path
12//! does.
13//!
14//! **This is not `process_tracker`.** That module owns *tracked* agent
15//! process trees (Job Object / cgroup / pgid) and is the right tool when
16//! the child was assigned to a tracker at spawn. These helpers are for the
17//! untracked cases — a one-shot login CLI, a shell node, a pager left
18//! behind by a hook — where all the caller has is a PID.
19//!
20//! Every function is scoped strictly to the given PID (and, on Windows,
21//! its tree via `/T`) — **never** by image name. Killing by image name is
22//! the cross-instance hazard CLAUDE.md bans outright.
23//!
24//! ## The `kill(2)` argument ranges, and why the guards are what they are
25//!
26//! Callers hand us a `u32`; POSIX `kill(2)` takes a signed `pid_t`, and
27//! its argument is **not** simply "a process id". Every range means
28//! something different:
29//!
30//! | `pid` argument | `kill(2)` signals… |
31//! |---|---|
32//! | `> 0` | exactly that one process |
33//! | `== 0` | every process in the **caller's own** process group |
34//! | `== -1` | **every process the caller may signal — a system-wide broadcast** |
35//! | `< -1` | every process in process group `-pid` |
36//!
37//! Three successive ReAgent P1s on PR #3033 each caught this module
38//! hitting one of the non-literal rows through an innocent-looking `u32`:
39//!
40//! 1. `u32::MAX - 1` cast with `as` wraps to `-2` → row four, group 2.
41//! 2. `0` → row two, the caller's own group (the test runner).
42//! 3. `1`, negated for the group call → `-1` → row three, the broadcast.
43//!
44//! So the guards are derived from the table, not added one hole at a time:
45//!
46//! - [`checked_pid`] refuses `0` and anything that does not fit `pid_t`,
47//! which is exactly the set of inputs that could reach rows two and
48//! four from the single-PID path. Every `pid_t` that reaches
49//! [`kill_pid`] is strictly positive → row one only.
50//! - [`signal_process_group`] (and [`kill_process_group`] and
51//! [`force_kill_tree`], which go through it on Unix) additionally
52//! requires `pgid >= 2`, because it negates: `-1` would be row three, and `-0` is `0`, row two. Every
53//! value it passes to `kill(2)` is `<= -2` → row four only, and never
54//! the broadcast.
55//!
56//! Real PIDs are never 0 or 1 for anything this module should touch
57//! (1 is `init`/`systemd`), and never approach `i32::MAX` on any supported
58//! platform (Linux `pid_max` caps at 4194304; Windows PIDs are small
59//! multiples of 4) — so the guards cost nothing in practice and close the
60//! whole class, not just the three instances that were reported.
61
62#[cfg(windows)]
63use crate::win32::NoWindow;
64use std::io;
65
66/// Convert a caller-supplied PID to a signed, **strictly positive** `pid_t`.
67/// Refuses `0` (the caller's own process group to `kill(2)`) and anything
68/// that would wrap negative (a process group, or the broadcast). Shared by
69/// both public functions so the guard can't drift.
70fn checked_pid(pid: u32) -> io::Result<i32> {
71 if pid == 0 {
72 return Err(io::Error::new(
73 io::ErrorKind::InvalidInput,
74 "pid 0 would target the caller's own process group — refusing",
75 ));
76 }
77 i32::try_from(pid).map_err(|_| {
78 io::Error::new(
79 io::ErrorKind::InvalidInput,
80 format!("pid {pid} does not fit in pid_t — refusing (would wrap negative)"),
81 )
82 })
83}
84
85/// Terminate one process by PID — `SIGTERM` on Unix; `taskkill /F /T /PID`
86/// on Windows (which also takes the tree, since Windows has no
87/// process-group signal).
88///
89/// Returns `Err` if the PID is `0` or out of range for `pid_t` (see the
90/// module doc), or if the signal/command could not be delivered. A process
91/// that has already exited surfaces as an error too (Unix `ESRCH`, Windows
92/// non-zero `taskkill` exit) — callers treating "already gone" as success
93/// should ignore the result, which is what every prior copy did.
94///
95/// `1` is accepted here: to `kill(2)` a positive `1` is exactly one process
96/// (row one of the table), not a special value. It only becomes the
97/// broadcast when *negated*, which this function never does.
98pub fn kill_pid(pid: u32) -> io::Result<()> {
99 let pid = checked_pid(pid)?;
100 #[cfg(windows)]
101 {
102 let status = taskkill_tree(pid).status()?;
103 if status.success() {
104 Ok(())
105 } else {
106 Err(io::Error::other(format!("taskkill exit {:?}", status.code())))
107 }
108 }
109 #[cfg(unix)]
110 {
111 // SAFETY: kill(2) is a well-defined POSIX syscall; `pid` is a
112 // checked, strictly-positive pid_t (never 0, never negative), so
113 // this is row one of the module-doc table — exactly one process.
114 let ret = unsafe { libc::kill(pid, libc::SIGTERM) };
115 if ret != 0 {
116 return Err(io::Error::last_os_error());
117 }
118 Ok(())
119 }
120}
121
122/// Terminate a process **group** led by `pid`, with escalation: `SIGTERM`
123/// to the group, a short grace, then `SIGKILL` to the group. On Windows
124/// the same `taskkill /F /T /PID` as [`kill_pid`] — there is no group
125/// signal, and `/T` already takes the tree.
126///
127/// Unix callers must have spawned the child as a group leader
128/// (`process_group(0)` / `setsid`) for the negative-pid signal to reach
129/// its descendants; that is the contract every prior copy relied on.
130/// Best-effort: nothing is returned because there is nothing useful a
131/// caller can do about a failed kill of an already-dying group.
132///
133/// A PID of `0`, `1`, or anything out of range for `pid_t` is a **no-op**.
134/// `0` and out-of-range are refused by [`checked_pid`]; `1` is refused
135/// here specifically because this function negates its argument and
136/// `kill(-1, …)` is the system-wide broadcast (row three of the module-doc
137/// table), not "process group 1".
138pub fn kill_process_group(pid: u32) {
139 let Ok(pgid) = checked_pid(pid) else { return };
140 if pgid < 2 {
141 // pgid == 1 → -pgid == -1 → broadcast to every signalable process.
142 // (pgid == 0 is already impossible here; checked_pid refused it.)
143 return;
144 }
145 #[cfg(windows)]
146 {
147 let _ = taskkill_tree(pgid).status();
148 }
149 #[cfg(unix)]
150 {
151 let _ = signal_process_group(pid, libc::SIGTERM);
152 std::thread::sleep(std::time::Duration::from_millis(300));
153 let _ = signal_process_group(pid, libc::SIGKILL);
154 }
155}
156
157/// Send one `signal` to the process group led by `pid`, with no grace and
158/// no escalation: the building block of [`kill_process_group`], for callers
159/// that pick their own signal and timing (the launcher's teardown backstop).
160/// Same guards: `0`, `1` and anything out of range for `pid_t` are refused
161/// with `InvalidInput` and nothing is sent. An already-empty group is an
162/// `ESRCH` error, which callers usually ignore.
163#[cfg(unix)]
164pub fn signal_process_group(pid: u32, signal: libc::c_int) -> io::Result<()> {
165 let pgid = checked_pid(pid)?;
166 if pgid < 2 {
167 return Err(io::Error::new(
168 io::ErrorKind::InvalidInput,
169 "pgid 1 would be negated to -1, the broadcast to every process — refusing",
170 ));
171 }
172 // SAFETY: kill(2) is a well-defined POSIX syscall; `pgid >= 2` by the
173 // guards above, so `-pgid <= -2`: row four of the module-doc table,
174 // exactly that one process group. It can never be 0 (the caller's own
175 // group) or -1 (the broadcast).
176 if unsafe { libc::kill(-pgid, signal) } != 0 {
177 return Err(io::Error::last_os_error());
178 }
179 Ok(())
180}
181
182/// Kill a process and everything it spawned at once, with no grace:
183/// `SIGKILL` to the process group led by `pid` on Unix (the child must be a
184/// group or session leader), `taskkill /F /T /PID` on Windows. For a caller
185/// that has already asked nicely, or never can (bashwrap's idle kill of a
186/// wrapped command). The guards are [`signal_process_group`]'s on Unix and
187/// [`kill_pid`]'s on Windows; the result is for logging, since "already
188/// gone" is an error too.
189pub fn force_kill_tree(pid: u32) -> io::Result<()> {
190 #[cfg(windows)]
191 {
192 kill_pid(pid)
193 }
194 #[cfg(unix)]
195 {
196 signal_process_group(pid, libc::SIGKILL)
197 }
198}
199
200/// `taskkill /F /T /PID <pid>` with stdio detached and the console flash
201/// suppressed — the one Windows kill command, built once.
202#[cfg(windows)]
203fn taskkill_tree(pid: i32) -> std::process::Command {
204 let mut cmd = std::process::Command::new("taskkill");
205 cmd.args(["/F", "/T", "/PID", &pid.to_string()])
206 .stdin(std::process::Stdio::null())
207 .stdout(std::process::Stdio::null())
208 .stderr(std::process::Stdio::null())
209 .no_window();
210 cmd
211}
212
213/// The cap on srv's app-exit teardown (`agent_teardown::app_exit`: every
214/// agent closed, then leftover shells swept). Shared so the launcher's wait
215/// for srv ([`SRV_EXIT_WAIT`]) stays above it.
216pub const SRV_APP_EXIT_CAP: std::time::Duration = std::time::Duration::from_secs(8);
217
218/// How long the launcher waits for srv to exit on its own after telling it
219/// to (stdin closed; on Unix also SIGTERM) before force-killing it: on every
220/// quit and on an upgrade (`upgrade::quiesce_srv`). Above
221/// [`SRV_APP_EXIT_CAP`] with room for srv's own exit, so a force-kill never
222/// lands mid-teardown. srv usually exits well before it.
223pub const SRV_EXIT_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
224const _: () = assert!(SRV_EXIT_WAIT.as_millis() > SRV_APP_EXIT_CAP.as_millis() + 1000);
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 // ── Row two: pid 0 is the caller's own group ────────────────────────
231
232 #[test]
233 fn kill_pid_refuses_zero() {
234 let err = kill_pid(0).unwrap_err();
235 assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "{err}");
236 assert!(err.to_string().contains("own process group"), "{err}");
237 }
238
239 #[test]
240 fn kill_process_group_refuses_zero() {
241 kill_process_group(0);
242 }
243
244 // ── Row three: pgid 1 negated is -1, the broadcast ──────────────────
245
246 /// The third P1: `kill_process_group(1)` would have called
247 /// `kill(-1, SIGTERM)` then `kill(-1, SIGKILL)` — every process the
248 /// runner may signal. Must be a silent no-op, reached without any
249 /// syscall.
250 #[test]
251 fn kill_process_group_refuses_one() {
252 kill_process_group(1);
253 }
254
255 // ── Row four via overflow: u32::MAX - 1 wraps to -2 ─────────────────
256
257 #[test]
258 fn kill_pid_refuses_a_pid_that_would_wrap_negative() {
259 let err = kill_pid(u32::MAX - 1).unwrap_err();
260 assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "{err}");
261 assert!(err.to_string().contains("does not fit"), "{err}");
262 }
263
264 #[test]
265 fn kill_process_group_refuses_a_pid_that_would_wrap_negative() {
266 kill_process_group(u32::MAX - 1);
267 }
268
269 // ── Row one: a real, positive, nonexistent pid fails cleanly ────────
270
271 /// `i32::MAX` fits `pid_t`, so the guard must pass it through — and it
272 /// cannot be a live process on any supported platform, so the OS
273 /// rejects it (`ESRCH` / non-zero `taskkill`). Asserting the error is
274 /// NOT the guard's proves the syscall was actually made.
275 #[test]
276 fn kill_pid_on_a_nonexistent_in_range_pid_is_an_error_not_a_panic() {
277 let err = kill_pid(i32::MAX as u32).unwrap_err();
278 assert_ne!(err.kind(), io::ErrorKind::InvalidInput, "guard should NOT have fired: {err}");
279 }
280
281 /// The guard's exact contract: strictly positive and fits `pid_t`.
282 /// `1` passes `checked_pid` — it is a legitimate single-process target
283 /// for `kill_pid`; only `kill_process_group` layers the `>= 2` rule on
284 /// top, because only it negates.
285 #[test]
286 fn checked_pid_boundaries() {
287 assert!(checked_pid(0).is_err(), "0 is the caller's own group");
288 assert_eq!(checked_pid(1).unwrap(), 1);
289 assert_eq!(checked_pid(i32::MAX as u32).unwrap(), i32::MAX);
290 assert!(checked_pid(i32::MAX as u32 + 1).is_err());
291 assert!(checked_pid(u32::MAX).is_err());
292 }
293
294 // ── signal_process_group / force_kill_tree: the same guards ──────────
295
296 #[test]
297 fn force_kill_tree_refuses_zero_and_a_pid_that_would_wrap_negative() {
298 for pid in [0, u32::MAX - 1] {
299 let err = force_kill_tree(pid).unwrap_err();
300 assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "{pid}: {err}");
301 }
302 }
303
304 #[cfg(unix)]
305 #[test]
306 fn signal_process_group_refuses_zero_one_and_overflow() {
307 for pid in [0, 1, u32::MAX - 1] {
308 let err = signal_process_group(pid, 0).unwrap_err();
309 assert_eq!(err.kind(), io::ErrorKind::InvalidInput, "{pid}: {err}");
310 }
311 }
312
313 /// What bashwrap's idle kill relies on: one SIGKILL to the group of a
314 /// child spawned as its own group leader, with no grace period.
315 #[cfg(unix)]
316 #[test]
317 fn force_kill_tree_sigkills_the_group() {
318 use std::os::unix::process::{CommandExt as _, ExitStatusExt as _};
319 let mut child = std::process::Command::new("sleep")
320 .arg("30")
321 .process_group(0)
322 .spawn()
323 .expect("spawn sleep");
324 force_kill_tree(child.id()).expect("signal the group");
325 let status = child.wait().expect("reap sleep");
326 assert_eq!(status.signal(), Some(libc::SIGKILL));
327 }
328
329 #[cfg(windows)]
330 #[test]
331 fn taskkill_command_targets_pid_never_image_name() {
332 let cmd = taskkill_tree(4242);
333 let args: Vec<String> = cmd.get_args().map(|a| a.to_string_lossy().into_owned()).collect();
334 assert_eq!(args, ["/F", "/T", "/PID", "4242"]);
335 assert!(!args.iter().any(|a| a.eq_ignore_ascii_case("/IM")));
336 }
337}