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)
}