Skip to main content

aft/bash_background/
process.rs

1#[cfg(target_os = "linux")]
2use std::ffi::OsString;
3#[cfg(unix)]
4use std::path::Path;
5#[cfg(target_os = "linux")]
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9/// Shared process-termination helpers for both foreground bash and background
10/// bash tasks. Extracted to avoid duplication between `commands/bash.rs` and
11/// `bash_background/registry.rs`.
12///
13/// Termination is graceful-first: SIGTERM + 3-second grace period, then
14/// SIGKILL on Unix. On Windows, `taskkill /T /F` kills the entire process tree.
15use std::process::Child;
16#[cfg(windows)]
17use std::process::{Command, Stdio};
18#[cfg(unix)]
19use std::thread;
20use std::time::Duration;
21#[cfg(unix)]
22use std::time::Instant;
23
24pub const TERMINATE_GRACE: Duration = Duration::from_secs(2);
25pub const LIVE_DESCENDANT_CAP: usize = 16;
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct LiveDescendant {
29    pub pid: u32,
30    pub comm: String,
31    pub argv0: String,
32}
33
34/// Enumerate surviving members of a task's process group. `None` means the
35/// platform has no process-group enumeration support for these tasks today.
36pub fn live_process_group_members(pgid: i32) -> Option<(Vec<LiveDescendant>, usize)> {
37    #[cfg(target_os = "linux")]
38    let mut members = linux_process_group_members(pgid);
39    #[cfg(target_os = "macos")]
40    let mut members = macos_process_group_members(pgid);
41    #[cfg(windows)]
42    {
43        let _ = pgid;
44        return None;
45    }
46    #[cfg(all(unix, not(any(target_os = "linux", target_os = "macos"))))]
47    {
48        let _ = pgid;
49        return None;
50    }
51
52    #[cfg(any(target_os = "linux", target_os = "macos"))]
53    {
54        members.sort_by_key(|member| member.pid);
55        let omitted = members.len().saturating_sub(LIVE_DESCENDANT_CAP);
56        members.truncate(LIVE_DESCENDANT_CAP);
57        Some((members, omitted))
58    }
59}
60
61#[cfg(target_os = "linux")]
62pub(crate) fn systemd_scope_argv(
63    systemd_run: &Path,
64    executable: &Path,
65    args: &[OsString],
66) -> (PathBuf, Vec<OsString>) {
67    let mut wrapped = ["--user", "--scope", "--collect", "--quiet"]
68        .into_iter()
69        .map(OsString::from)
70        .collect::<Vec<_>>();
71    wrapped.push(executable.as_os_str().to_os_string());
72    wrapped.extend_from_slice(args);
73    (systemd_run.to_path_buf(), wrapped)
74}
75
76#[cfg(target_os = "linux")]
77fn select_systemd_scope_launcher_with(
78    find: impl FnOnce() -> Option<PathBuf>,
79    probe: impl FnOnce(&Path) -> bool,
80) -> Option<PathBuf> {
81    let launcher = find()?;
82    probe(&launcher).then_some(launcher)
83}
84
85#[cfg(target_os = "linux")]
86pub(crate) fn select_systemd_scope_launcher() -> Option<PathBuf> {
87    select_systemd_scope_launcher_with(
88        || which::which("systemd-run").ok(),
89        |launcher| {
90            std::process::Command::new(launcher)
91                .args(["--user", "--scope", "--collect", "--quiet", "/bin/true"])
92                .stdin(std::process::Stdio::null())
93                .stdout(std::process::Stdio::null())
94                .stderr(std::process::Stdio::null())
95                .status()
96                .is_ok_and(|status| status.success())
97        },
98    )
99}
100
101#[cfg(target_os = "linux")]
102fn linux_process_group_members(pgid: i32) -> Vec<LiveDescendant> {
103    let Ok(entries) = std::fs::read_dir("/proc") else {
104        return Vec::new();
105    };
106    entries
107        .flatten()
108        .filter_map(|entry| entry.file_name().to_str()?.parse::<u32>().ok())
109        .filter_map(|pid| linux_process_member(pid, pgid))
110        .collect()
111}
112
113#[cfg(target_os = "linux")]
114fn linux_process_member(pid: u32, expected_pgid: i32) -> Option<LiveDescendant> {
115    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
116    let open = stat.find('(')?;
117    let close = stat.rfind(") ")?;
118    let comm = stat.get(open + 1..close)?.to_string();
119    let mut fields = stat.get(close + 2..)?.split_whitespace();
120    let state = fields.next()?;
121    let _ppid = fields.next()?;
122    let pgrp = fields.next()?.parse::<i32>().ok()?;
123    if pgrp != expected_pgid || state == "Z" {
124        return None;
125    }
126    let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default();
127    let argv0 = cmdline
128        .split(|byte| *byte == 0)
129        .next()
130        .filter(|value| !value.is_empty())
131        .map(|value| String::from_utf8_lossy(value).into_owned())
132        .unwrap_or_else(|| comm.clone());
133    Some(LiveDescendant { pid, comm, argv0 })
134}
135
136#[cfg(target_os = "macos")]
137fn macos_process_group_members(pgid: i32) -> Vec<LiveDescendant> {
138    use std::ffi::{c_char, c_int, c_void};
139
140    #[link(name = "proc")]
141    extern "C" {
142        fn proc_listpgrppids(pgrpid: u32, buffer: *mut c_void, buffersize: c_int) -> c_int;
143        fn proc_name(pid: c_int, buffer: *mut c_void, buffersize: u32) -> c_int;
144    }
145
146    if pgid <= 0 {
147        return Vec::new();
148    }
149    let mut pids = vec![0_u32; 16_384];
150    let count = unsafe {
151        proc_listpgrppids(
152            pgid as u32,
153            pids.as_mut_ptr().cast(),
154            i32::try_from(pids.len() * std::mem::size_of::<u32>()).unwrap_or(i32::MAX),
155        )
156    };
157    if count <= 0 {
158        return Vec::new();
159    }
160    pids.truncate(count as usize);
161    pids.into_iter()
162        .filter(|pid| *pid != 0)
163        .filter_map(|pid| {
164            let mut name = [0 as c_char; 1024];
165            let name_len =
166                unsafe { proc_name(pid as c_int, name.as_mut_ptr().cast(), name.len() as u32) };
167            if name_len <= 0 {
168                return None;
169            }
170            let comm = unsafe { std::ffi::CStr::from_ptr(name.as_ptr()) }
171                .to_string_lossy()
172                .into_owned();
173            let argv0 = macos_argv0(pid).unwrap_or_else(|| comm.clone());
174            Some(LiveDescendant { pid, comm, argv0 })
175        })
176        .collect()
177}
178
179#[cfg(target_os = "macos")]
180fn macos_argv0(pid: u32) -> Option<String> {
181    const CTL_KERN: libc::c_int = 1;
182    const KERN_PROCARGS2: libc::c_int = 49;
183    let mut mib = [CTL_KERN, KERN_PROCARGS2, i32::try_from(pid).ok()?];
184    let mut size = 0_usize;
185    if unsafe {
186        libc::sysctl(
187            mib.as_mut_ptr(),
188            mib.len() as u32,
189            std::ptr::null_mut(),
190            &mut size,
191            std::ptr::null_mut(),
192            0,
193        )
194    } != 0
195        || size <= std::mem::size_of::<libc::c_int>()
196    {
197        return None;
198    }
199    let mut bytes = vec![0_u8; size];
200    if unsafe {
201        libc::sysctl(
202            mib.as_mut_ptr(),
203            mib.len() as u32,
204            bytes.as_mut_ptr().cast(),
205            &mut size,
206            std::ptr::null_mut(),
207            0,
208        )
209    } != 0
210    {
211        return None;
212    }
213    bytes.truncate(size);
214    let mut cursor = std::mem::size_of::<libc::c_int>();
215    cursor += bytes.get(cursor..)?.iter().position(|byte| *byte == 0)?;
216    while bytes.get(cursor).is_some_and(|byte| *byte == 0) {
217        cursor += 1;
218    }
219    let end = cursor
220        + bytes
221            .get(cursor..)?
222            .iter()
223            .position(|byte| *byte == 0)
224            .unwrap_or(bytes.len().saturating_sub(cursor));
225    (end > cursor).then(|| String::from_utf8_lossy(&bytes[cursor..end]).into_owned())
226}
227
228/// The Unix payload wrapper runs the user's command in the same shell that
229/// owns the pipeline. Bash exposes per-segment statuses as `PIPESTATUS`, while
230/// zsh exposes them as `pipestatus`; plain POSIX sh has neither and therefore
231/// receives the original command without a capture suffix. The status stream
232/// uses fd 5, which the parent opens only for a clean pipeline and places beside
233/// the ordinary exit marker. Windows shells use their existing wrapper and do
234/// not have a portable per-segment status equivalent.
235#[cfg(unix)]
236pub(crate) const PAYLOAD_WRAPPER: &[u8] = br#"#!/bin/sh
237shell=$1
238command=$2
239exit_fd=$3
240pipeline_status_fd=$4
241pipeline_shell=$5
242case "$pipeline_status_fd:$pipeline_shell" in
243  5:bash)
244    "$shell" -c "$command
245__aft_code=\$? __aft_ps=(\"\${PIPESTATUS[@]}\")
246printf '%s\\n' \"\${__aft_ps[@]}\" >&5 2>/dev/null || true
247exit \"\$__aft_code\""
248    ;;
249  5:zsh)
250    "$shell" -c "$command
251__aft_code=\$? __aft_ps=(\"\${pipestatus[@]}\")
252printf '%s\\n' \"\${__aft_ps[@]}\" >&5 2>/dev/null || true
253exit \"\$__aft_code\""
254    ;;
255  *)
256    # POSIX sh has no per-segment pipeline status array; preserve the original
257    # invocation instead of changing its semantics with a best-effort guess.
258    "$shell" -c "$command"
259    ;;
260esac
261code=$?
262printf "%s" "$code" >&"$exit_fd"
263exit "$code"
264"#;
265
266#[cfg(unix)]
267pub(crate) fn pipeline_shell_kind(shell: &Path) -> Option<&'static str> {
268    match shell.file_name().and_then(|name| name.to_str()) {
269        Some("bash") => Some("bash"),
270        Some("zsh") => Some("zsh"),
271        _ => None,
272    }
273}
274
275/// Start a non-PTY child in a new session before exec. The bridge can inherit a
276/// harness's controlling terminal even though the child uses pipe-backed stdio;
277/// an interactive shell could otherwise perform job control against that terminal.
278#[cfg(unix)]
279pub(crate) fn start_new_session(command: &mut std::process::Command) {
280    use std::os::unix::process::CommandExt;
281
282    unsafe {
283        command.pre_exec(|| {
284            if libc::setsid() == -1 {
285                Err(std::io::Error::last_os_error())
286            } else {
287                Ok(())
288            }
289        });
290    }
291}
292
293#[cfg(unix)]
294pub fn terminate_process(child: &mut Child) {
295    let pgid = child.id() as i32;
296    terminate_pgid(pgid, Some(child));
297}
298
299#[cfg(unix)]
300pub fn terminate_pgid(pgid: i32, mut child: Option<&mut Child>) {
301    unsafe {
302        libc::killpg(pgid, libc::SIGTERM);
303    }
304    let grace_started = Instant::now();
305    while grace_started.elapsed() < TERMINATE_GRACE {
306        if let Some(child) = child.as_deref_mut() {
307            if matches!(child.try_wait(), Ok(Some(_))) {
308                // The direct child (process-group leader) exited. Stop waiting,
309                // but still SIGKILL the whole group below — a descendant that
310                // ignored SIGTERM can outlive the leader (the wrapper-shell /
311                // CLI-spawns-child orphan class). killpg on an already-empty
312                // group is a harmless ESRCH.
313                break;
314            }
315        }
316        thread::sleep(Duration::from_millis(50));
317    }
318    unsafe {
319        libc::killpg(pgid, libc::SIGKILL);
320    }
321}
322
323#[cfg(windows)]
324pub fn terminate_process(child: &mut Child) {
325    terminate_pid(child.id());
326}
327
328#[cfg(windows)]
329pub fn terminate_pid(pid: u32) {
330    let pid = pid.to_string();
331    let _ = Command::new("taskkill")
332        .args(["/PID", &pid, "/T", "/F"])
333        .stdout(Stdio::null())
334        .stderr(Stdio::null())
335        .status();
336}
337
338#[cfg(unix)]
339pub fn is_process_alive(pid: u32) -> bool {
340    let Ok(pid) = i32::try_from(pid) else {
341        return false;
342    };
343    if pid <= 0 {
344        return false;
345    }
346    (unsafe { libc::kill(pid, 0) == 0 })
347        || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
348}
349
350#[cfg(windows)]
351pub fn is_process_alive(pid: u32) -> bool {
352    use std::ffi::c_void;
353
354    type Handle = *mut c_void;
355
356    extern "system" {
357        fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> Handle;
358        fn GetExitCodeProcess(hProcess: Handle, lpExitCode: *mut u32) -> i32;
359        fn CloseHandle(hObject: Handle) -> i32;
360    }
361
362    const FALSE: i32 = 0;
363    const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
364    const STILL_ACTIVE: u32 = 0x103;
365
366    if pid == 0 {
367        return false;
368    }
369
370    unsafe {
371        let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
372        if handle.is_null() {
373            return false;
374        }
375        let mut exit_code = 0;
376        let ok = GetExitCodeProcess(handle, &mut exit_code) != 0 && exit_code == STILL_ACTIVE;
377        let _ = CloseHandle(handle);
378        ok
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[cfg(target_os = "linux")]
387    #[test]
388    fn linux_scope_argv_wraps_the_tool_shell() {
389        let args = vec![OsString::from("-c"), OsString::from("printf ready")];
390        let (program, wrapped) = systemd_scope_argv(
391            Path::new("/usr/bin/systemd-run"),
392            Path::new("/bin/sh"),
393            &args,
394        );
395        assert_eq!(program, PathBuf::from("/usr/bin/systemd-run"));
396        assert_eq!(
397            wrapped,
398            vec![
399                "--user",
400                "--scope",
401                "--collect",
402                "--quiet",
403                "/bin/sh",
404                "-c",
405                "printf ready",
406            ]
407            .into_iter()
408            .map(OsString::from)
409            .collect::<Vec<_>>()
410        );
411    }
412
413    #[cfg(target_os = "linux")]
414    #[test]
415    fn linux_scope_falls_back_when_user_manager_is_unreachable() {
416        let probes = std::cell::Cell::new(0);
417        let selected = select_systemd_scope_launcher_with(
418            || Some(PathBuf::from("/usr/bin/systemd-run")),
419            |_| {
420                probes.set(probes.get() + 1);
421                false
422            },
423        );
424        assert_eq!(selected, None);
425        assert_eq!(probes.get(), 1);
426    }
427
428    #[test]
429    fn is_process_alive_returns_true_for_self() {
430        assert!(is_process_alive(std::process::id()));
431    }
432
433    #[cfg(unix)]
434    #[test]
435    fn payload_wrapper_captures_bash_and_zsh_pipeline_statuses() {
436        use std::os::fd::AsRawFd;
437        use std::os::unix::process::CommandExt;
438        use std::process::Command;
439
440        let mut shells = vec![("/bin/bash", "bash")];
441        if Path::new("/bin/zsh").is_file() {
442            shells.push(("/bin/zsh", "zsh"));
443        }
444
445        for (shell, kind) in shells {
446            let temp = tempfile::tempdir().expect("create wrapper test directory");
447            let exit_path = temp.path().join("exit");
448            let status_path = temp.path().join("pipeline-status");
449            let exit_file = std::fs::File::create(&exit_path).expect("create exit marker");
450            let status_file = std::fs::File::create(&status_path).expect("create status file");
451            let exit_fd = exit_file.as_raw_fd();
452            let status_fd = status_file.as_raw_fd();
453            let mut command = Command::new("/bin/sh");
454            command.args([
455                "-c",
456                std::str::from_utf8(PAYLOAD_WRAPPER).expect("wrapper is UTF-8"),
457                "aft-payload-wrapper",
458                shell,
459                "false | true",
460                "3",
461                "5",
462                kind,
463            ]);
464            unsafe {
465                command.pre_exec(move || {
466                    // Mirror apply_marker_fd_allowlist's two-step: a raw
467                    // dup2(fd, N) is a no-op when fd == N already, which keeps
468                    // the descriptor CLOEXEC and silently closes it at exec.
469                    // Parking above the target range first makes the final
470                    // dup2 a real copy that clears CLOEXEC.
471                    let exit_copy = libc::fcntl(exit_fd, libc::F_DUPFD_CLOEXEC, 6);
472                    let status_copy = libc::fcntl(status_fd, libc::F_DUPFD_CLOEXEC, 6);
473                    if exit_copy < 0
474                        || status_copy < 0
475                        || libc::dup2(exit_copy, 3) < 0
476                        || libc::dup2(status_copy, 5) < 0
477                    {
478                        return Err(std::io::Error::last_os_error());
479                    }
480                    libc::close(exit_copy);
481                    libc::close(status_copy);
482                    Ok(())
483                });
484            }
485            let result = command.status().expect("run payload wrapper");
486            drop(exit_file);
487            drop(status_file);
488            assert!(result.success(), "wrapper failed for {kind}");
489            assert_eq!(std::fs::read_to_string(&status_path).unwrap(), "1\n0\n");
490            assert_eq!(std::fs::read_to_string(&exit_path).unwrap(), "0");
491        }
492    }
493
494    #[cfg(unix)]
495    #[test]
496    fn non_pty_child_starts_as_its_own_session_and_process_group_leader() {
497        use std::process::Command;
498
499        let mut command = Command::new("/bin/sh");
500        command.args(["-c", "sleep 30"]);
501        start_new_session(&mut command);
502        let mut child = command.spawn().expect("spawn isolated child");
503        let pid = child.id() as libc::pid_t;
504
505        assert_eq!(unsafe { libc::getsid(pid) }, pid);
506        assert_eq!(unsafe { libc::getpgid(pid) }, pid);
507
508        terminate_process(&mut child);
509        let _ = child.wait();
510    }
511
512    #[test]
513    fn is_process_alive_returns_false_for_dead_pid() {
514        #[cfg(unix)]
515        let mut child = std::process::Command::new("/bin/sh")
516            .args(["-c", "true"])
517            .spawn()
518            .expect("spawn true");
519
520        #[cfg(windows)]
521        let mut child = std::process::Command::new("cmd.exe")
522            .args(["/D", "/C", "exit 0"])
523            .spawn()
524            .expect("spawn cmd");
525
526        let pid = child.id();
527        child.wait().expect("wait for child");
528
529        assert!(!is_process_alive(pid));
530    }
531
532    /// Regression: when the process-group LEADER exits during the SIGTERM grace
533    /// window, `terminate_pgid` must still SIGKILL the rest of the group. A
534    /// TERM-ignoring descendant (the wrapper-shell / CLI-spawns-child orphan
535    /// class) used to survive because the old code returned the instant the
536    /// leader was reaped, skipping the group SIGKILL.
537    #[cfg(unix)]
538    #[test]
539    fn terminate_pgid_kills_term_ignoring_descendant_after_leader_exits() {
540        use std::os::unix::process::CommandExt;
541
542        let dir = tempfile::tempdir().unwrap();
543        let pidfile = dir.path().join("desc.pid");
544        let ready = dir.path().join("ready");
545
546        // Leader becomes its own process-group leader (setsid → pgid == pid).
547        // It backgrounds a descendant shell that ignores SIGTERM, signals
548        // readiness (so the trap is definitely installed before we terminate),
549        // then sleeps. The leader waits for readiness and exits — so by the time
550        // we call terminate_pgid, the leader is gone and only SIGKILL can reap
551        // the descendant.
552        let script = format!(
553            "sh -c \"trap '' TERM; echo \\$$ > '{pid}'; touch '{ready}'; sleep 30\" & \
554             while [ ! -f '{ready}' ]; do sleep 0.02; done; exit 0",
555            pid = pidfile.display(),
556            ready = ready.display(),
557        );
558        let mut leader = unsafe {
559            std::process::Command::new("/bin/sh")
560                .args(["-c", &script])
561                .pre_exec(|| {
562                    libc::setsid();
563                    Ok(())
564                })
565                .spawn()
566                .expect("spawn leader")
567        };
568        let pgid = leader.id() as i32;
569
570        // Wait for the descendant to be ready (trap installed + pid written).
571        let start = Instant::now();
572        while !ready.exists() && start.elapsed() < Duration::from_secs(5) {
573            thread::sleep(Duration::from_millis(20));
574        }
575        let desc_pid: u32 = std::fs::read_to_string(&pidfile)
576            .expect("descendant pid file")
577            .trim()
578            .parse()
579            .expect("parse descendant pid");
580        assert!(is_process_alive(desc_pid), "descendant should be alive");
581
582        terminate_pgid(pgid, Some(&mut leader));
583
584        // The TERM-ignoring descendant must be gone (SIGKILL'd via the group).
585        let start = Instant::now();
586        while is_process_alive(desc_pid) && start.elapsed() < Duration::from_secs(5) {
587            thread::sleep(Duration::from_millis(20));
588        }
589        assert!(
590            !is_process_alive(desc_pid),
591            "TERM-ignoring descendant must be SIGKILLed when the group is terminated"
592        );
593    }
594}
595
596/// Check that a persisted PID still names the process instance launched for a task.
597/// On platforms without process start-time inspection, a live PID is protected;
598/// deleting a live task is worse than temporarily retaining a recycled PID.
599pub fn is_recorded_process_alive(pid: u32, task_started_at_ms: u64) -> bool {
600    const PROCESS_START_GRACE_MS: u64 = 30_000;
601
602    if !is_process_alive(pid) {
603        return false;
604    }
605    crate::root_cache::process_start_time_ms(pid).is_none_or(|started_at_ms| {
606        started_at_ms <= task_started_at_ms.saturating_add(PROCESS_START_GRACE_MS)
607    })
608}