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};
9use 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
34pub 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#[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#[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 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 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 #[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 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 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 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
596pub 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}