Skip to main content

aft/bash_background/
process.rs

1#[cfg(unix)]
2use std::path::Path;
3/// Shared process-termination helpers for both foreground bash and background
4/// bash tasks. Extracted to avoid duplication between `commands/bash.rs` and
5/// `bash_background/registry.rs`.
6///
7/// Termination is graceful-first: SIGTERM + 3-second grace period, then
8/// SIGKILL on Unix. On Windows, `taskkill /T /F` kills the entire process tree.
9use std::process::Child;
10#[cfg(windows)]
11use std::process::{Command, Stdio};
12#[cfg(unix)]
13use std::thread;
14use std::time::Duration;
15#[cfg(unix)]
16use std::time::Instant;
17
18pub const TERMINATE_GRACE: Duration = Duration::from_secs(2);
19
20/// The Unix payload wrapper runs the user's command in the same shell that
21/// owns the pipeline. Bash exposes per-segment statuses as `PIPESTATUS`, while
22/// zsh exposes them as `pipestatus`; plain POSIX sh has neither and therefore
23/// receives the original command without a capture suffix. The status stream
24/// uses fd 5, which the parent opens only for a clean pipeline and places beside
25/// the ordinary exit marker. Windows shells use their existing wrapper and do
26/// not have a portable per-segment status equivalent.
27#[cfg(unix)]
28pub(crate) const PAYLOAD_WRAPPER: &[u8] = br#"#!/bin/sh
29shell=$1
30command=$2
31exit_fd=$3
32pipeline_status_fd=$4
33pipeline_shell=$5
34case "$pipeline_status_fd:$pipeline_shell" in
35  5:bash)
36    "$shell" -c "$command
37__aft_code=\$? __aft_ps=(\"\${PIPESTATUS[@]}\")
38printf '%s\\n' \"\${__aft_ps[@]}\" >&5 2>/dev/null || true
39exit \"\$__aft_code\""
40    ;;
41  5:zsh)
42    "$shell" -c "$command
43__aft_code=\$? __aft_ps=(\"\${pipestatus[@]}\")
44printf '%s\\n' \"\${__aft_ps[@]}\" >&5 2>/dev/null || true
45exit \"\$__aft_code\""
46    ;;
47  *)
48    # POSIX sh has no per-segment pipeline status array; preserve the original
49    # invocation instead of changing its semantics with a best-effort guess.
50    "$shell" -c "$command"
51    ;;
52esac
53code=$?
54printf "%s" "$code" >&"$exit_fd"
55exit "$code"
56"#;
57
58#[cfg(unix)]
59pub(crate) fn pipeline_shell_kind(shell: &Path) -> Option<&'static str> {
60    match shell.file_name().and_then(|name| name.to_str()) {
61        Some("bash") => Some("bash"),
62        Some("zsh") => Some("zsh"),
63        _ => None,
64    }
65}
66
67/// Start a non-PTY child in a new session before exec. The bridge can inherit a
68/// harness's controlling terminal even though the child uses pipe-backed stdio;
69/// an interactive shell could otherwise perform job control against that terminal.
70#[cfg(unix)]
71pub(crate) fn start_new_session(command: &mut std::process::Command) {
72    use std::os::unix::process::CommandExt;
73
74    unsafe {
75        command.pre_exec(|| {
76            if libc::setsid() == -1 {
77                Err(std::io::Error::last_os_error())
78            } else {
79                Ok(())
80            }
81        });
82    }
83}
84
85#[cfg(unix)]
86pub fn terminate_process(child: &mut Child) {
87    let pgid = child.id() as i32;
88    terminate_pgid(pgid, Some(child));
89}
90
91#[cfg(unix)]
92pub fn terminate_pgid(pgid: i32, mut child: Option<&mut Child>) {
93    unsafe {
94        libc::killpg(pgid, libc::SIGTERM);
95    }
96    let grace_started = Instant::now();
97    while grace_started.elapsed() < TERMINATE_GRACE {
98        if let Some(child) = child.as_deref_mut() {
99            if matches!(child.try_wait(), Ok(Some(_))) {
100                // The direct child (process-group leader) exited. Stop waiting,
101                // but still SIGKILL the whole group below — a descendant that
102                // ignored SIGTERM can outlive the leader (the wrapper-shell /
103                // CLI-spawns-child orphan class). killpg on an already-empty
104                // group is a harmless ESRCH.
105                break;
106            }
107        }
108        thread::sleep(Duration::from_millis(50));
109    }
110    unsafe {
111        libc::killpg(pgid, libc::SIGKILL);
112    }
113}
114
115#[cfg(windows)]
116pub fn terminate_process(child: &mut Child) {
117    terminate_pid(child.id());
118}
119
120#[cfg(windows)]
121pub fn terminate_pid(pid: u32) {
122    let pid = pid.to_string();
123    let _ = Command::new("taskkill")
124        .args(["/PID", &pid, "/T", "/F"])
125        .stdout(Stdio::null())
126        .stderr(Stdio::null())
127        .status();
128}
129
130#[cfg(unix)]
131pub fn is_process_alive(pid: u32) -> bool {
132    let Ok(pid) = i32::try_from(pid) else {
133        return false;
134    };
135    if pid <= 0 {
136        return false;
137    }
138    (unsafe { libc::kill(pid, 0) == 0 })
139        || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
140}
141
142#[cfg(windows)]
143pub fn is_process_alive(pid: u32) -> bool {
144    use std::ffi::c_void;
145
146    type Handle = *mut c_void;
147
148    extern "system" {
149        fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> Handle;
150        fn GetExitCodeProcess(hProcess: Handle, lpExitCode: *mut u32) -> i32;
151        fn CloseHandle(hObject: Handle) -> i32;
152    }
153
154    const FALSE: i32 = 0;
155    const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
156    const STILL_ACTIVE: u32 = 0x103;
157
158    if pid == 0 {
159        return false;
160    }
161
162    unsafe {
163        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
164        if handle.is_null() {
165            return false;
166        }
167        let mut exit_code = 0;
168        let ok = GetExitCodeProcess(handle, &mut exit_code) != 0 && exit_code == STILL_ACTIVE;
169        let _ = CloseHandle(handle);
170        ok
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn is_process_alive_returns_true_for_self() {
180        assert!(is_process_alive(std::process::id()));
181    }
182
183    #[cfg(unix)]
184    #[test]
185    fn payload_wrapper_captures_bash_and_zsh_pipeline_statuses() {
186        use std::os::fd::AsRawFd;
187        use std::os::unix::process::CommandExt;
188        use std::process::Command;
189
190        let mut shells = vec![("/bin/bash", "bash")];
191        if Path::new("/bin/zsh").is_file() {
192            shells.push(("/bin/zsh", "zsh"));
193        }
194
195        for (shell, kind) in shells {
196            let temp = tempfile::tempdir().expect("create wrapper test directory");
197            let exit_path = temp.path().join("exit");
198            let status_path = temp.path().join("pipeline-status");
199            let exit_file = std::fs::File::create(&exit_path).expect("create exit marker");
200            let status_file = std::fs::File::create(&status_path).expect("create status file");
201            let exit_fd = exit_file.as_raw_fd();
202            let status_fd = status_file.as_raw_fd();
203            let mut command = Command::new("/bin/sh");
204            command.args([
205                "-c",
206                std::str::from_utf8(PAYLOAD_WRAPPER).expect("wrapper is UTF-8"),
207                "aft-payload-wrapper",
208                shell,
209                "false | true",
210                "3",
211                "5",
212                kind,
213            ]);
214            unsafe {
215                command.pre_exec(move || {
216                    // Mirror apply_marker_fd_allowlist's two-step: a raw
217                    // dup2(fd, N) is a no-op when fd == N already, which keeps
218                    // the descriptor CLOEXEC and silently closes it at exec.
219                    // Parking above the target range first makes the final
220                    // dup2 a real copy that clears CLOEXEC.
221                    let exit_copy = libc::fcntl(exit_fd, libc::F_DUPFD_CLOEXEC, 6);
222                    let status_copy = libc::fcntl(status_fd, libc::F_DUPFD_CLOEXEC, 6);
223                    if exit_copy < 0
224                        || status_copy < 0
225                        || libc::dup2(exit_copy, 3) < 0
226                        || libc::dup2(status_copy, 5) < 0
227                    {
228                        return Err(std::io::Error::last_os_error());
229                    }
230                    libc::close(exit_copy);
231                    libc::close(status_copy);
232                    Ok(())
233                });
234            }
235            let result = command.status().expect("run payload wrapper");
236            drop(exit_file);
237            drop(status_file);
238            assert!(result.success(), "wrapper failed for {kind}");
239            assert_eq!(std::fs::read_to_string(&status_path).unwrap(), "1\n0\n");
240            assert_eq!(std::fs::read_to_string(&exit_path).unwrap(), "0");
241        }
242    }
243
244    #[cfg(unix)]
245    #[test]
246    fn non_pty_child_starts_as_its_own_session_and_process_group_leader() {
247        use std::process::Command;
248
249        let mut command = Command::new("/bin/sh");
250        command.args(["-c", "sleep 30"]);
251        start_new_session(&mut command);
252        let mut child = command.spawn().expect("spawn isolated child");
253        let pid = child.id() as libc::pid_t;
254
255        assert_eq!(unsafe { libc::getsid(pid) }, pid);
256        assert_eq!(unsafe { libc::getpgid(pid) }, pid);
257
258        terminate_process(&mut child);
259        let _ = child.wait();
260    }
261
262    #[test]
263    fn is_process_alive_returns_false_for_dead_pid() {
264        #[cfg(unix)]
265        let mut child = std::process::Command::new("/bin/sh")
266            .args(["-c", "true"])
267            .spawn()
268            .expect("spawn true");
269
270        #[cfg(windows)]
271        let mut child = std::process::Command::new("cmd.exe")
272            .args(["/D", "/C", "exit 0"])
273            .spawn()
274            .expect("spawn cmd");
275
276        let pid = child.id();
277        child.wait().expect("wait for child");
278
279        assert!(!is_process_alive(pid));
280    }
281
282    /// Regression: when the process-group LEADER exits during the SIGTERM grace
283    /// window, `terminate_pgid` must still SIGKILL the rest of the group. A
284    /// TERM-ignoring descendant (the wrapper-shell / CLI-spawns-child orphan
285    /// class) used to survive because the old code returned the instant the
286    /// leader was reaped, skipping the group SIGKILL.
287    #[cfg(unix)]
288    #[test]
289    fn terminate_pgid_kills_term_ignoring_descendant_after_leader_exits() {
290        use std::os::unix::process::CommandExt;
291
292        let dir = tempfile::tempdir().unwrap();
293        let pidfile = dir.path().join("desc.pid");
294        let ready = dir.path().join("ready");
295
296        // Leader becomes its own process-group leader (setsid → pgid == pid).
297        // It backgrounds a descendant shell that ignores SIGTERM, signals
298        // readiness (so the trap is definitely installed before we terminate),
299        // then sleeps. The leader waits for readiness and exits — so by the time
300        // we call terminate_pgid, the leader is gone and only SIGKILL can reap
301        // the descendant.
302        let script = format!(
303            "sh -c \"trap '' TERM; echo \\$$ > '{pid}'; touch '{ready}'; sleep 30\" & \
304             while [ ! -f '{ready}' ]; do sleep 0.02; done; exit 0",
305            pid = pidfile.display(),
306            ready = ready.display(),
307        );
308        let mut leader = unsafe {
309            std::process::Command::new("/bin/sh")
310                .args(["-c", &script])
311                .pre_exec(|| {
312                    libc::setsid();
313                    Ok(())
314                })
315                .spawn()
316                .expect("spawn leader")
317        };
318        let pgid = leader.id() as i32;
319
320        // Wait for the descendant to be ready (trap installed + pid written).
321        let start = Instant::now();
322        while !ready.exists() && start.elapsed() < Duration::from_secs(5) {
323            thread::sleep(Duration::from_millis(20));
324        }
325        let desc_pid: u32 = std::fs::read_to_string(&pidfile)
326            .expect("descendant pid file")
327            .trim()
328            .parse()
329            .expect("parse descendant pid");
330        assert!(is_process_alive(desc_pid), "descendant should be alive");
331
332        terminate_pgid(pgid, Some(&mut leader));
333
334        // The TERM-ignoring descendant must be gone (SIGKILL'd via the group).
335        let start = Instant::now();
336        while is_process_alive(desc_pid) && start.elapsed() < Duration::from_secs(5) {
337            thread::sleep(Duration::from_millis(20));
338        }
339        assert!(
340            !is_process_alive(desc_pid),
341            "TERM-ignoring descendant must be SIGKILLed when the group is terminated"
342        );
343    }
344}
345
346/// Check that a persisted PID still names the process instance launched for a task.
347/// On platforms without process start-time inspection, a live PID is protected;
348/// deleting a live task is worse than temporarily retaining a recycled PID.
349pub fn is_recorded_process_alive(pid: u32, task_started_at_ms: u64) -> bool {
350    const PROCESS_START_GRACE_MS: u64 = 30_000;
351
352    if !is_process_alive(pid) {
353        return false;
354    }
355    crate::root_cache::process_start_time_ms(pid).is_none_or(|started_at_ms| {
356        started_at_ms <= task_started_at_ms.saturating_add(PROCESS_START_GRACE_MS)
357    })
358}