Skip to main content

aft/bash_background/
process.rs

1/// Shared process-termination helpers for both foreground bash and background
2/// bash tasks. Extracted to avoid duplication between `commands/bash.rs` and
3/// `bash_background/registry.rs`.
4///
5/// Termination is graceful-first: SIGTERM + 3-second grace period, then
6/// SIGKILL on Unix. On Windows, `taskkill /T /F` kills the entire process tree.
7use std::process::Child;
8#[cfg(windows)]
9use std::process::{Command, Stdio};
10#[cfg(unix)]
11use std::thread;
12use std::time::Duration;
13#[cfg(unix)]
14use std::time::Instant;
15
16pub const TERMINATE_GRACE: Duration = Duration::from_secs(2);
17
18#[cfg(unix)]
19pub fn terminate_process(child: &mut Child) {
20    let pgid = child.id() as i32;
21    terminate_pgid(pgid, Some(child));
22}
23
24#[cfg(unix)]
25pub fn terminate_pgid(pgid: i32, mut child: Option<&mut Child>) {
26    unsafe {
27        libc::killpg(pgid, libc::SIGTERM);
28    }
29    let grace_started = Instant::now();
30    while grace_started.elapsed() < TERMINATE_GRACE {
31        if let Some(child) = child.as_deref_mut() {
32            if matches!(child.try_wait(), Ok(Some(_))) {
33                // The direct child (process-group leader) exited. Stop waiting,
34                // but still SIGKILL the whole group below — a descendant that
35                // ignored SIGTERM can outlive the leader (the wrapper-shell /
36                // CLI-spawns-child orphan class). killpg on an already-empty
37                // group is a harmless ESRCH.
38                break;
39            }
40        }
41        thread::sleep(Duration::from_millis(50));
42    }
43    unsafe {
44        libc::killpg(pgid, libc::SIGKILL);
45    }
46}
47
48#[cfg(windows)]
49pub fn terminate_process(child: &mut Child) {
50    terminate_pid(child.id());
51}
52
53#[cfg(windows)]
54pub fn terminate_pid(pid: u32) {
55    let pid = pid.to_string();
56    let _ = Command::new("taskkill")
57        .args(["/PID", &pid, "/T", "/F"])
58        .stdout(Stdio::null())
59        .stderr(Stdio::null())
60        .status();
61}
62
63#[cfg(unix)]
64pub fn is_process_alive(pid: u32) -> bool {
65    let Ok(pid) = i32::try_from(pid) else {
66        return false;
67    };
68    if pid <= 0 {
69        return false;
70    }
71    (unsafe { libc::kill(pid, 0) == 0 })
72        || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
73}
74
75#[cfg(windows)]
76pub fn is_process_alive(pid: u32) -> bool {
77    use std::ffi::c_void;
78
79    type Handle = *mut c_void;
80
81    extern "system" {
82        fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> Handle;
83        fn GetExitCodeProcess(hProcess: Handle, lpExitCode: *mut u32) -> i32;
84        fn CloseHandle(hObject: Handle) -> i32;
85    }
86
87    const FALSE: i32 = 0;
88    const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
89    const STILL_ACTIVE: u32 = 0x103;
90
91    if pid == 0 {
92        return false;
93    }
94
95    unsafe {
96        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
97        if handle.is_null() {
98            return false;
99        }
100        let mut exit_code = 0;
101        let ok = GetExitCodeProcess(handle, &mut exit_code) != 0 && exit_code == STILL_ACTIVE;
102        let _ = CloseHandle(handle);
103        ok
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn is_process_alive_returns_true_for_self() {
113        assert!(is_process_alive(std::process::id()));
114    }
115
116    #[test]
117    fn is_process_alive_returns_false_for_dead_pid() {
118        #[cfg(unix)]
119        let mut child = std::process::Command::new("/bin/sh")
120            .args(["-c", "true"])
121            .spawn()
122            .expect("spawn true");
123
124        #[cfg(windows)]
125        let mut child = std::process::Command::new("cmd.exe")
126            .args(["/D", "/C", "exit 0"])
127            .spawn()
128            .expect("spawn cmd");
129
130        let pid = child.id();
131        child.wait().expect("wait for child");
132
133        assert!(!is_process_alive(pid));
134    }
135
136    /// Regression: when the process-group LEADER exits during the SIGTERM grace
137    /// window, `terminate_pgid` must still SIGKILL the rest of the group. A
138    /// TERM-ignoring descendant (the wrapper-shell / CLI-spawns-child orphan
139    /// class) used to survive because the old code returned the instant the
140    /// leader was reaped, skipping the group SIGKILL.
141    #[cfg(unix)]
142    #[test]
143    fn terminate_pgid_kills_term_ignoring_descendant_after_leader_exits() {
144        use std::os::unix::process::CommandExt;
145
146        let dir = tempfile::tempdir().unwrap();
147        let pidfile = dir.path().join("desc.pid");
148        let ready = dir.path().join("ready");
149
150        // Leader becomes its own process-group leader (setsid → pgid == pid).
151        // It backgrounds a descendant shell that ignores SIGTERM, signals
152        // readiness (so the trap is definitely installed before we terminate),
153        // then sleeps. The leader waits for readiness and exits — so by the time
154        // we call terminate_pgid, the leader is gone and only SIGKILL can reap
155        // the descendant.
156        let script = format!(
157            "sh -c \"trap '' TERM; echo \\$$ > '{pid}'; touch '{ready}'; sleep 30\" & \
158             while [ ! -f '{ready}' ]; do sleep 0.02; done; exit 0",
159            pid = pidfile.display(),
160            ready = ready.display(),
161        );
162        let mut leader = unsafe {
163            std::process::Command::new("/bin/sh")
164                .args(["-c", &script])
165                .pre_exec(|| {
166                    libc::setsid();
167                    Ok(())
168                })
169                .spawn()
170                .expect("spawn leader")
171        };
172        let pgid = leader.id() as i32;
173
174        // Wait for the descendant to be ready (trap installed + pid written).
175        let start = Instant::now();
176        while !ready.exists() && start.elapsed() < Duration::from_secs(5) {
177            thread::sleep(Duration::from_millis(20));
178        }
179        let desc_pid: u32 = std::fs::read_to_string(&pidfile)
180            .expect("descendant pid file")
181            .trim()
182            .parse()
183            .expect("parse descendant pid");
184        assert!(is_process_alive(desc_pid), "descendant should be alive");
185
186        terminate_pgid(pgid, Some(&mut leader));
187
188        // The TERM-ignoring descendant must be gone (SIGKILL'd via the group).
189        let start = Instant::now();
190        while is_process_alive(desc_pid) && start.elapsed() < Duration::from_secs(5) {
191            thread::sleep(Duration::from_millis(20));
192        }
193        assert!(
194            !is_process_alive(desc_pid),
195            "TERM-ignoring descendant must be SIGKILLed when the group is terminated"
196        );
197    }
198}