use std::io; use std::path::Path; #[cfg(unix)] use std::process::Stdio; #[cfg(unix)] use std::process::{Child, Command}; #[cfg(any(unix, windows))] use std::sync::Mutex; pub const ANCHOR_ARG: &str = "__avril_task_anchor"; /// Hidden re-exec target: a single process that leads the process group /// a watched run's commands join. It ignores INT/TERM so the group keeps /// a live, run-owned identity until the watcher escalates to KILL. pub fn anchor() -> ! { #[cfg(unix)] unsafe { libc::signal(libc::SIGINT, libc::SIG_IGN); libc::signal(libc::SIGTERM, libc::SIG_IGN); } #[cfg(unix)] { // Standard input is a pipe owned by the watcher. Its EOF means // the watcher died without cleaning up (SIGKILL, SIGHUP, ...): // take the whole group down rather than park forever, orphaned. use std::io::Read as _; let mut stdin = std::io::stdin(); let mut buffer = [0; 1024]; loop { match stdin.read(&mut buffer) { Ok(0) | Err(_) => { unsafe { libc::kill(-libc::getpid(), libc::SIGKILL); } std::process::exit(1); } Ok(_) => {} } } } #[cfg(not(unix))] loop { std::thread::park(); } } #[cfg(unix)] struct AnchorProcess { child: Child, /// Held so its closing (watcher death or cleanup) tells the anchor to /// take the group down. _owner: std::io::PipeWriter, } /// A process group whose lifetime is independent of any one command. /// On Unix the host executable must dispatch ANCHOR_ARG to anchor(). pub struct Group { #[cfg(unix)] pid: u32, #[cfg(unix)] anchor: Mutex>, #[cfg(windows)] job: Mutex>>, } impl Group { pub fn new(host: &Path) -> io::Result { #[cfg(unix)] { use std::os::unix::process::CommandExt as _; let (reader, writer) = std::io::pipe()?; let child = Command::new(host) .arg(ANCHOR_ARG) .stdin(Stdio::from(reader)) .stdout(Stdio::null()) .stderr(Stdio::null()) .process_group(0) .spawn()?; Ok(Self { pid: child.id(), anchor: Mutex::new(Some(AnchorProcess { child, _owner: writer })), }) } #[cfg(windows)] { let _ = host; Ok(Self { job: Mutex::new(Some(std::sync::Arc::new(crate::job::Job::new(0)?))), }) } #[cfg(not(any(unix, windows)))] { let _ = host; Err(io::Error::new( io::ErrorKind::Unsupported, "Process groups are not supported", )) } } #[cfg(unix)] pub(crate) fn pgid(&self) -> io::Result { if self.anchor.lock().unwrap().is_none() { return Err(io::Error::new( io::ErrorKind::NotConnected, "Process group is closed", )); } Ok(self.pid) } #[cfg(windows)] pub(crate) fn job(&self) -> io::Result> { self.job .lock() .unwrap() .clone() .ok_or_else(|| io::Error::new(io::ErrorKind::NotConnected, "Process group is closed")) } pub fn signal(&self, name: &str) -> io::Result { #[cfg(unix)] { let anchor = self.anchor.lock().unwrap(); if anchor.is_none() { return Ok(false); } crate::signals::send(self.pid, true, crate::signals::signal_number(name)?) } #[cfg(windows)] { crate::validate_signal(name)?; self.job .lock() .unwrap() .as_ref() .map_or(Ok(false), |job| job.terminate()) } #[cfg(not(any(unix, windows)))] { let _ = name; Ok(false) } } /// Uncertainty counts as alive; the Unix anchor and zombies do not. pub fn alive(&self) -> bool { #[cfg(unix)] { let anchor = self.anchor.lock().unwrap(); if anchor.is_none() { return false; } // The anchor itself is not a task process; only other members // count, and zombies do not: they are already dead. #[cfg(any(target_os = "linux", target_os = "android"))] if let Some(alive) = linux_group_alive(self.pid) { return alive; } #[cfg(target_os = "macos")] if let Some(alive) = macos_group_alive(self.pid) { return alive; } // No anchor-excluding membership check on this platform: report // uncertainty rather than reap the anchor (which would release // the group identity) or count the anchor itself. true } #[cfg(windows)] { self.job .lock() .unwrap() .as_ref() .is_some_and(|job| job.alive().unwrap_or(true)) } #[cfg(not(any(unix, windows)))] false } /// Kills the scope and prevents further launches. Does not wait for members. pub fn close(&self) { #[cfg(unix)] if let Some(mut anchor) = self.anchor.lock().unwrap().take() { let _ = crate::signals::send(self.pid, true, libc::SIGKILL); let _ = anchor.child.kill(); let _ = anchor.child.wait(); } #[cfg(windows)] if let Some(job) = self.job.lock().unwrap().take() { let _ = job.terminate(); } } } impl Drop for Group { fn drop(&mut self) { self.close(); } } /// Whether any non-zombie process other than the anchor leads a life in /// the group. Zombies are dead; waiting for someone else to reap them /// would hang termination. /// Whether process enumeration can be trusted to see this group: a /// hidepid mount without CAP_SYS_PTRACE can hide even same-uid members /// (e.g. a task that cleared its dumpable flag), and an empty listing /// from such a mount is not proof that the group is gone. #[cfg(any(target_os = "linux", target_os = "android"))] fn enumeration_complete() -> bool { let hidepid = std::fs::read_to_string("/proc/mounts").is_ok_and(|mounts| { mounts.lines().any(|line| { let mut fields = line.split(' '); let _device = fields.next(); let mountpoint = fields.next(); let _kind = fields.next(); let options = fields.next().unwrap_or(""); mountpoint == Some("/proc") && options.split(',').any(|option| { matches!( option, "hidepid=1" | "hidepid=2" | "hidepid=4" | "hidepid=invisible" ) }) }) }); if !hidepid { return true; } let cap_sys_ptrace = 1u64 << 19; std::fs::read_to_string("/proc/self/status").is_ok_and(|status| { status.lines().any(|line| { line.strip_prefix("CapEff:") .and_then(|value| u64::from_str_radix(value.trim(), 16).ok()) .is_some_and(|caps| caps & cap_sys_ptrace != 0) }) }) } #[cfg(any(target_os = "linux", target_os = "android"))] fn linux_group_alive(pgid: u32) -> Option { if !enumeration_complete() { return None; } // Parsed as bytes: a process name may hold arbitrary bytes, and an // entry that cannot be understood counts as uncertainty (alive), // never as proof that the group is gone. let mut found = false; for entry in std::fs::read_dir("/proc").ok()? { let Ok(entry) = entry else { continue }; let Ok(pid) = entry.file_name().to_string_lossy().parse::() else { continue; }; if pid == pgid { continue; } // Membership first, via syscall: an unrelated process that // cannot be read must not pin this group. let member = unsafe { libc::getpgid(pid as i32) }; if member < 0 { if std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) { return Some(true); } continue; } if member as u32 != pgid { continue; } let Ok(stat) = std::fs::read(entry.path().join("stat")) else { return Some(true); }; let Some(close) = stat.iter().rposition(|byte| *byte == b')') else { return Some(true); }; let mut fields = stat[close + 1..] .split(|byte| byte.is_ascii_whitespace()) .filter(|field| !field.is_empty()); let state = fields.next(); let _ppid = fields.next(); let group = fields .next() .and_then(|value| std::str::from_utf8(value).ok()) .and_then(|value| value.parse::().ok()); let Some(group) = group else { return Some(true); }; if group == pgid && state != Some(b"Z".as_slice()) && state != Some(b"X".as_slice()) { found = true; } } Some(found) } #[cfg(target_os = "macos")] fn macos_group_alive(pgid: u32) -> Option { let mut capacity = 1024usize; let listed = loop { let mut pids_buffer = vec![0i32; capacity]; let listed = unsafe { libc::proc_listallpids( pids_buffer.as_mut_ptr().cast(), (pids_buffer.len() * std::mem::size_of::()) as i32, ) }; if listed <= 0 { return None; } if (listed as usize) < capacity { pids_buffer.truncate(listed as usize); break pids_buffer; } capacity *= 4; if capacity > 1 << 20 { return None; } pids_buffer.clear(); }; let mut found = false; for pid in listed { if pid <= 0 || pid as u32 == pgid { continue; } let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() }; let size = std::mem::size_of::() as i32; let read = unsafe { libc::proc_pidinfo(pid, libc::PROC_PIDTBSDINFO, 0, (&raw mut info).cast(), size) }; if read != size { if std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH) { return Some(true); } continue; } // Status 5 is SZOMB: a zombie is already dead. if info.pbi_pgid == pgid && info.pbi_status != 5 { found = true; } } Some(found) }