#![allow(unsafe_code)]
use std::io;
use std::process::{Child, Command, ExitStatus, Output};
pub struct ProcessTree {
child: Option<Child>,
guard: platform::Guard,
}
impl ProcessTree {
pub(crate) fn spawn(command: &mut Command) -> io::Result<Self> {
let (child, guard) = platform::spawn(command)?;
Ok(Self {
child: Some(child),
guard,
})
}
pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
self.child
.as_mut()
.ok_or_else(|| io::Error::other("process output was already collected"))?
.try_wait()
}
pub fn wait(&mut self) -> io::Result<ExitStatus> {
self.child
.as_mut()
.ok_or_else(|| io::Error::other("process output was already collected"))?
.wait()
}
pub fn wait_with_output(&mut self) -> io::Result<Output> {
self.child
.take()
.ok_or_else(|| io::Error::other("process output was already collected"))?
.wait_with_output()
}
pub fn terminate(&mut self) -> io::Result<()> {
self.terminate_descendants()?;
if let Some(child) = self.child.as_mut() {
child.wait()?;
self.child.take();
}
Ok(())
}
pub fn terminate_descendants(&mut self) -> io::Result<()> {
self.guard.terminate()
}
}
impl Drop for ProcessTree {
fn drop(&mut self) {
let _ = self.terminate();
}
}
#[cfg(unix)]
mod platform {
use std::io;
use std::os::unix::process::CommandExt as _;
use std::process::{Child, Command};
pub(super) struct Guard {
process_group: libc::pid_t,
active: bool,
}
pub(super) fn spawn(command: &mut Command) -> io::Result<(Child, Guard)> {
command.process_group(0);
let child = command.spawn()?;
let process_group = libc::pid_t::try_from(child.id())
.map_err(|_| io::Error::other("child process id does not fit pid_t"))?;
Ok((
child,
Guard {
process_group,
active: true,
},
))
}
impl Guard {
pub(super) fn terminate(&mut self) -> io::Result<()> {
if !self.active {
return Ok(());
}
if unsafe { libc::kill(-self.process_group, libc::SIGKILL) } == 0 {
self.active = false;
return Ok(());
}
let error = io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ESRCH) {
self.active = false;
Ok(())
} else {
Err(error)
}
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::ProcessTree;
use std::fs;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
const MODE: &str = "ACYCLIC_PROCESS_TREE_TEST_MODE";
const ROOT: &str = "ACYCLIC_PROCESS_TREE_TEST_ROOT";
#[test]
fn process_tree_helper() {
let Ok(mode) = std::env::var(MODE) else {
return;
};
let root = std::path::PathBuf::from(std::env::var_os(ROOT).expect("helper root"));
if mode == "grandchild" {
fs::write(root.join("grandchild-ready"), b"ready").expect("grandchild ready");
thread::sleep(Duration::from_millis(750));
fs::write(root.join("escaped"), b"descendant survived").expect("escaped marker");
return;
}
assert_eq!(mode, "child");
let mut grandchild = Command::new(std::env::current_exe().expect("test executable"));
grandchild
.args(["--exact", "process_tree::tests::process_tree_helper"])
.env(MODE, "grandchild")
.env(ROOT, &root)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut grandchild = grandchild.spawn().expect("spawn grandchild");
let deadline = Instant::now() + Duration::from_secs(5);
while !root.join("grandchild-ready").exists() && Instant::now() < deadline {
thread::sleep(Duration::from_millis(10));
}
assert!(root.join("grandchild-ready").exists());
fs::write(root.join("tree-ready"), b"ready").expect("tree ready");
let _ = grandchild.wait();
}
#[test]
fn termination_contains_descendants() {
let temporary = tempfile::tempdir().expect("temporary process-tree directory");
let mut command = Command::new(std::env::current_exe().expect("test executable"));
command
.args(["--exact", "process_tree::tests::process_tree_helper"])
.env(MODE, "child")
.env(ROOT, temporary.path())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut tree = ProcessTree::spawn(&mut command).expect("spawn process tree");
let deadline = Instant::now() + Duration::from_secs(5);
while !temporary.path().join("tree-ready").exists() && Instant::now() < deadline {
thread::sleep(Duration::from_millis(10));
}
assert!(temporary.path().join("tree-ready").exists());
tree.terminate().expect("terminate process tree");
thread::sleep(Duration::from_secs(1));
assert!(!temporary.path().join("escaped").exists());
}
}
#[cfg(windows)]
mod platform {
use std::io;
use std::mem::{size_of, zeroed};
use std::os::windows::io::AsRawHandle as _;
use std::os::windows::process::CommandExt as _;
use std::process::{Child, Command};
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject, TerminateJobObject,
};
use windows_sys::Win32::System::Threading::{
CREATE_NEW_PROCESS_GROUP, CREATE_SUSPENDED, OpenThread, ResumeThread, THREAD_SUSPEND_RESUME,
};
pub(super) struct Guard {
job: HANDLE,
active: bool,
}
pub(super) fn spawn(command: &mut Command) -> io::Result<(Child, Guard)> {
let mut guard = Guard::new()?;
command.creation_flags(CREATE_SUSPENDED | CREATE_NEW_PROCESS_GROUP);
let mut child = command.spawn()?;
if unsafe { AssignProcessToJobObject(guard.job, child.as_raw_handle().cast()) } == 0 {
let error = io::Error::last_os_error();
let _ = guard.terminate();
let _ = child.kill();
let _ = child.wait();
return Err(error);
}
if let Err(error) = resume_process(child.id()) {
let _ = guard.terminate();
let _ = child.wait();
return Err(error);
}
Ok((child, guard))
}
impl Guard {
fn new() -> io::Result<Self> {
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if job.is_null() {
return Err(io::Error::last_os_error());
}
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { zeroed() };
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let configured = unsafe {
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
(&raw const limits).cast(),
u32::try_from(size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
.map_err(|_| io::Error::other("Job limit structure is too large"))?,
)
};
if configured == 0 {
let error = io::Error::last_os_error();
unsafe { CloseHandle(job) };
return Err(error);
}
Ok(Self { job, active: true })
}
pub(super) fn terminate(&mut self) -> io::Result<()> {
if !self.active {
return Ok(());
}
if unsafe { TerminateJobObject(self.job, 1) } == 0 {
Err(io::Error::last_os_error())
} else {
self.active = false;
Ok(())
}
}
}
impl Drop for Guard {
fn drop(&mut self) {
let _ = self.terminate();
unsafe { CloseHandle(self.job) };
}
}
fn resume_process(process_id: u32) -> io::Result<()> {
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return Err(io::Error::last_os_error());
}
let mut entry: THREADENTRY32 = unsafe { zeroed() };
entry.dwSize = u32::try_from(size_of::<THREADENTRY32>())
.map_err(|_| io::Error::other("thread entry structure is too large"))?;
let mut found = unsafe { Thread32First(snapshot, &raw mut entry) } != 0;
let mut result = Err(io::Error::new(
io::ErrorKind::NotFound,
"suspended child thread was not found",
));
while found {
if entry.th32OwnerProcessID == process_id {
let thread = unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
if thread.is_null() {
result = Err(io::Error::last_os_error());
} else {
result = if unsafe { ResumeThread(thread) } == u32::MAX {
Err(io::Error::last_os_error())
} else {
Ok(())
};
unsafe { CloseHandle(thread) };
}
break;
}
found = unsafe { Thread32Next(snapshot, &raw mut entry) } != 0;
}
unsafe { CloseHandle(snapshot) };
result
}
}