Skip to main content

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}