use std::io;
use std::process::ExitStatus;
use std::time::Duration;
use tokio::process::{Child, ChildStderr, ChildStdout, Command};
pub const DEFAULT_GRACE: Duration = Duration::from_secs(2);
static LIVE_GROUPS: std::sync::Mutex<Vec<GroupHandle>> = std::sync::Mutex::new(Vec::new());
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct GroupHandle(#[cfg(unix)] i32, #[cfg(windows)] usize);
#[cfg(windows)]
unsafe impl Send for GroupHandle {}
fn register_group(handle: GroupHandle) {
if let Ok(mut live) = LIVE_GROUPS.lock() {
live.push(handle);
}
}
fn forget_group(handle: GroupHandle) {
if let Ok(mut live) = LIVE_GROUPS.lock() {
live.retain(|h| *h != handle);
}
}
pub fn kill_all_groups() {
let Ok(mut live) = LIVE_GROUPS.lock() else {
return;
};
for handle in live.drain(..) {
#[cfg(unix)]
unix_impl::signal_pgid(handle.0, unix_impl::SIGKILL);
#[cfg(windows)]
windows_impl::terminate_job(handle.0);
}
}
#[derive(Debug)]
pub struct ManagedChild {
child: Child,
group: Option<GroupHandle>,
#[cfg(windows)]
job: windows_impl::JobObject,
}
impl ManagedChild {
pub fn spawn(cmd: &mut Command) -> io::Result<Self> {
#[cfg(unix)]
{
unix_impl::prepare(cmd);
let child = cmd.spawn()?;
let group = child
.id()
.and_then(|p| i32::try_from(p).ok())
.map(GroupHandle);
if let Some(g) = group {
register_group(g);
}
Ok(Self { child, group })
}
#[cfg(windows)]
{
windows_impl::prepare(cmd);
let job = windows_impl::JobObject::create()?;
let child = cmd.spawn()?;
job.assign(&child)?;
let group = GroupHandle(job.raw());
register_group(group);
Ok(Self {
child,
group: Some(group),
job,
})
}
#[cfg(not(any(unix, windows)))]
{
let child = cmd.spawn()?;
Ok(Self { child, group: None })
}
}
pub fn take_stdout(&mut self) -> Option<ChildStdout> {
self.child.stdout.take()
}
pub fn take_stderr(&mut self) -> Option<ChildStderr> {
self.child.stderr.take()
}
pub async fn wait(&mut self) -> io::Result<ExitStatus> {
let status = self.child.wait().await;
if status.is_ok() {
self.release_group();
}
status
}
pub async fn terminate(&mut self, grace: Duration) -> io::Result<Option<ExitStatus>> {
if self.child.id().is_none() {
self.release_group();
return Ok(None);
}
#[cfg(unix)]
unix_impl::signal_group(&self.child, unix_impl::SIGTERM);
#[cfg(windows)]
self.job.terminate();
match tokio::time::timeout(grace, self.child.wait()).await {
Ok(status) => return status.map(Some),
Err(_) => {
#[cfg(unix)]
unix_impl::signal_group(&self.child, unix_impl::SIGKILL);
}
}
let status = self.child.wait().await.map(Some);
self.release_group();
status
}
fn release_group(&mut self) {
if let Some(g) = self.group.take() {
forget_group(g);
}
}
}
impl Drop for ManagedChild {
fn drop(&mut self) {
self.release_group();
}
}
#[cfg(unix)]
mod unix_impl {
use std::os::unix::process::CommandExt;
use tokio::process::{Child, Command};
pub const SIGTERM: i32 = 15;
pub const SIGKILL: i32 = 9;
unsafe extern "C" {
fn kill(pid: i32, sig: i32) -> i32;
}
pub fn prepare(cmd: &mut Command) {
cmd.as_std_mut().process_group(0);
}
pub fn signal_group(child: &Child, sig: i32) {
let Some(pid) = child.id() else { return };
let Ok(pid) = i32::try_from(pid) else { return };
signal_pgid(pid, sig);
}
pub fn signal_pgid(pgid: i32, sig: i32) {
unsafe {
kill(-pgid, sig);
}
}
}
#[cfg(windows)]
mod windows_impl {
use std::io;
use tokio::process::{Child, Command};
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE};
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_NO_WINDOW};
#[derive(Debug)]
pub struct JobObject(HANDLE);
unsafe impl Send for JobObject {}
unsafe impl Sync for JobObject {}
impl JobObject {
pub fn create() -> io::Result<Self> {
let handle = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if handle.is_null() {
return Err(io::Error::last_os_error());
}
let job = JobObject(handle);
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let ok = unsafe {
SetInformationJobObject(
job.0,
JobObjectExtendedLimitInformation,
(&raw const info).cast(),
u32::try_from(size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>())
.unwrap_or(u32::MAX),
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(job)
}
pub fn assign(&self, child: &Child) -> io::Result<()> {
let Some(handle) = child.raw_handle() else {
return Ok(());
};
let ok = unsafe { AssignProcessToJobObject(self.0, handle as HANDLE) };
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
pub fn raw(&self) -> usize {
self.0 as usize
}
pub fn terminate(&self) {
unsafe {
TerminateJobObject(self.0, 1);
}
}
}
impl Drop for JobObject {
fn drop(&mut self) {
unsafe {
CloseHandle(self.0);
}
}
}
pub fn terminate_job(handle: usize) {
unsafe {
TerminateJobObject(handle as HANDLE, 1);
}
}
pub fn prepare(cmd: &mut Command) {
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW);
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[cfg(unix)]
#[tokio::test]
async fn terminate_kills_grandchildren() {
let marker = tempfile::NamedTempFile::new().unwrap();
let marker_path = marker.path().to_path_buf();
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg(format!(
"( while true; do echo tick >> {p}; sleep 0.05; done ) & echo $! ; wait",
p = marker_path.display()
));
cmd.stdout(std::process::Stdio::piped());
let mut managed = ManagedChild::spawn(&mut cmd).expect("spawn");
tokio::time::sleep(Duration::from_millis(200)).await;
let before = std::fs::metadata(&marker_path).unwrap().len();
assert!(before > 0, "grandchild should have written something");
managed
.terminate(Duration::from_millis(500))
.await
.expect("terminate");
let after_terminate = std::fs::metadata(&marker_path).unwrap().len();
tokio::time::sleep(Duration::from_millis(300)).await;
let later = std::fs::metadata(&marker_path).unwrap().len();
assert_eq!(
after_terminate, later,
"grandchild kept writing after terminate; the process group was not signalled"
);
}
#[cfg(unix)]
#[tokio::test]
async fn wait_reports_exit_status() {
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg("exit 3");
let mut managed = ManagedChild::spawn(&mut cmd).expect("spawn");
let status = managed.wait().await.expect("wait");
assert_eq!(status.code(), Some(3));
}
#[cfg(unix)]
#[tokio::test]
async fn killing_all_groups_reaches_grandchildren() {
let marker = tempfile::NamedTempFile::new().unwrap();
let path = marker.path().to_path_buf();
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg(format!(
"( while true; do echo tick >> {p}; sleep 0.05; done ) & wait",
p = path.display()
));
let managed = ManagedChild::spawn(&mut cmd).expect("spawn");
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(std::fs::metadata(&path).unwrap().len() > 0);
kill_all_groups();
let after = std::fs::metadata(&path).unwrap().len();
tokio::time::sleep(Duration::from_millis(300)).await;
assert_eq!(
after,
std::fs::metadata(&path).unwrap().len(),
"a grandchild survived the hard-exit path"
);
drop(managed);
}
#[cfg(unix)]
#[tokio::test]
async fn a_finished_child_leaves_no_group_behind() {
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg("exit 0");
let mut managed = ManagedChild::spawn(&mut cmd).expect("spawn");
let group = managed.group.expect("a spawned child has a group");
assert!(LIVE_GROUPS.lock().unwrap().contains(&group));
managed.wait().await.expect("wait");
assert!(
!LIVE_GROUPS.lock().unwrap().contains(&group),
"a reaped child must not stay registered"
);
}
#[cfg(unix)]
#[tokio::test]
async fn terminate_on_already_exited_child_is_ok() {
let mut cmd = Command::new("/bin/sh");
cmd.arg("-c").arg("exit 0");
let mut managed = ManagedChild::spawn(&mut cmd).expect("spawn");
managed.wait().await.expect("wait");
let status = managed.terminate(Duration::from_millis(200)).await.unwrap();
assert!(status.is_none());
}
}