use tokio::process::{Child, Command};
pub fn prepare_group(cmd: &mut Command) {
#[cfg(unix)]
{
cmd.process_group(0);
}
#[cfg(not(unix))]
{
let _ = cmd;
}
}
pub struct TreeGuard {
#[cfg(windows)]
job: Option<JobHandle>,
#[cfg(unix)]
pgid: Option<i32>,
}
impl Drop for TreeGuard {
fn drop(&mut self) {
#[cfg(unix)]
if let Some(pgid) = self.pgid {
unsafe {
libc::killpg(pgid, libc::SIGKILL);
}
}
}
}
#[cfg(windows)]
struct JobHandle(windows_sys::Win32::Foundation::HANDLE);
#[cfg(windows)]
unsafe impl Send for JobHandle {}
#[cfg(windows)]
impl Drop for JobHandle {
fn drop(&mut self) {
unsafe { windows_sys::Win32::Foundation::CloseHandle(self.0) };
}
}
impl TreeGuard {
pub fn assign(child: &Child) -> Self {
Self {
#[cfg(windows)]
job: create_job(child),
#[cfg(unix)]
pgid: {
let _ = child;
None
},
}
}
pub fn assign_group(child: &Child) -> Self {
Self {
#[cfg(windows)]
job: create_job(child),
#[cfg(unix)]
pgid: child.id().map(|id| id as i32),
}
}
pub fn disarm(&mut self) {
#[cfg(unix)]
{
self.pgid = None;
}
}
pub fn kill(&self) {
#[cfg(windows)]
if let Some(job) = &self.job {
unsafe {
windows_sys::Win32::System::JobObjects::TerminateJobObject(job.0, 1);
}
}
#[cfg(unix)]
if let Some(pgid) = self.pgid {
unsafe {
libc::killpg(pgid, libc::SIGKILL);
}
}
}
}
#[cfg(windows)]
fn create_job(child: &Child) -> Option<JobHandle> {
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject,
};
let Some(raw) = child.raw_handle() else {
tracing::warn!("no process handle — Job Object not assigned");
return None;
};
unsafe {
let job = CreateJobObjectW(std::ptr::null(), std::ptr::null());
if job.is_null() {
tracing::warn!("CreateJobObjectW failed");
return None;
}
let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let ok = SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&info as *const _ as *const core::ffi::c_void,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
);
if ok == 0 || AssignProcessToJobObject(job, raw as _) == 0 {
tracing::warn!("failed to assign the kill-on-close Job Object");
windows_sys::Win32::Foundation::CloseHandle(job);
return None;
}
Some(JobHandle(job))
}
}
#[cfg(test)]
pub fn test_python() -> Option<String> {
["python", "python3"].into_iter().find_map(|c| {
std::process::Command::new(c)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.ok()
.filter(|st| st.success())
.map(|_| c.to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Stdio;
#[tokio::test]
async fn a_grandchild_dies_with_the_tree() {
let Some(py) = test_python() else {
println!("SKIP: no python interpreter — the grandchild fixture needs one");
return;
};
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("alive.txt");
let marker_arg = marker.to_string_lossy().into_owned();
let script = "import subprocess,sys,time\n\
child=\"import sys,time\\nwhile True:\\n open(sys.argv[1],'a').write('x')\\n time.sleep(0.05)\"\n\
subprocess.Popen([sys.executable,'-c',child,sys.argv[1]])\n\
time.sleep(120)\n";
let mut cmd = Command::new(&py);
cmd.arg("-c")
.arg(script)
.arg(&marker_arg)
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true);
prepare_group(&mut cmd);
let mut child = cmd.spawn().expect("spawn the fixture");
let guard = TreeGuard::assign_group(&child);
let mut grew = false;
for _ in 0..100 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
if marker.metadata().map(|m| m.len()).unwrap_or(0) > 0 {
grew = true;
break;
}
}
assert!(grew, "the grandchild never started writing");
guard.kill();
let _ = child.start_kill();
let _ = child.wait().await;
let mut guard = guard;
guard.disarm();
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let settled = marker.metadata().map(|m| m.len()).unwrap_or(0);
tokio::time::sleep(std::time::Duration::from_millis(700)).await;
let after = marker.metadata().map(|m| m.len()).unwrap_or(0);
assert_eq!(
settled, after,
"the grandchild survived the tree kill: {settled} → {after} bytes"
);
}
}