use std::{io, time::Duration};
use windows_sys::Win32::{
Foundation::{
CloseHandle, DuplicateHandle, FILETIME, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0,
},
System::{
Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
TH32CS_SNAPPROCESS,
},
JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject, TerminateJobObject,
},
Threading::{
GetCurrentProcess, GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
PROCESS_SET_QUOTA, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, WaitForSingleObject,
},
},
};
pub struct ExitHandle(HANDLE);
unsafe impl Send for ExitHandle {}
impl ExitHandle {
pub fn duplicate(process: HANDLE) -> io::Result<Self> {
let mut copy = 0;
let duplicated = unsafe {
DuplicateHandle(
GetCurrentProcess(),
process,
GetCurrentProcess(),
&mut copy,
PROCESS_SYNCHRONIZE,
0,
0,
)
};
if duplicated == 0 {
return Err(io::Error::last_os_error());
}
Ok(Self(copy))
}
pub fn exited(&self, timeout: Duration) -> bool {
let milliseconds = u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX);
unsafe { WaitForSingleObject(self.0, milliseconds) == WAIT_OBJECT_0 }
}
}
impl Drop for ExitHandle {
fn drop(&mut self) {
unsafe { CloseHandle(self.0) };
}
}
pub struct ProcessTree {
job: HANDLE,
}
unsafe impl Send for ProcessTree {}
unsafe impl Sync for ProcessTree {}
impl ProcessTree {
pub fn adopt(pid: u32) -> io::Result<Self> {
let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
if job == 0 {
return Err(io::Error::last_os_error());
}
let tree = Self { job };
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let configured = unsafe {
SetInformationJobObject(
tree.job,
JobObjectExtendedLimitInformation,
std::ptr::addr_of!(limits).cast(),
size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
};
if configured == 0 {
return Err(io::Error::last_os_error());
}
tree.assign(pid)?;
for descendant in descendants_of(pid) {
let _ = tree.assign(descendant);
}
Ok(tree)
}
fn assign(&self, pid: u32) -> io::Result<()> {
let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if process == 0 {
return Err(io::Error::last_os_error());
}
let assigned = unsafe { AssignProcessToJobObject(self.job, process) };
let error = io::Error::last_os_error();
unsafe { CloseHandle(process) };
if assigned == 0 {
return Err(error);
}
Ok(())
}
pub fn terminate(&self) -> io::Result<()> {
if unsafe { TerminateJobObject(self.job, 1) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
}
fn created_at(pid: u32) -> Option<u64> {
let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if process == 0 {
return None;
}
let mut created: FILETIME = unsafe { std::mem::zeroed() };
let mut ignored: FILETIME = unsafe { std::mem::zeroed() };
let ok = unsafe {
GetProcessTimes(
process,
&mut created,
&mut ignored,
&mut ignored,
&mut ignored,
)
};
unsafe { CloseHandle(process) };
if ok == 0 {
return None;
}
Some((u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime))
}
fn descendants_of(root: u32) -> Vec<u32> {
let mut entries: Vec<(u32, u32)> = Vec::new();
let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if snapshot == INVALID_HANDLE_VALUE {
return Vec::new();
}
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
entry.dwSize = size_of::<PROCESSENTRY32W>() as u32;
let mut valid = unsafe { Process32FirstW(snapshot, &mut entry) } != 0;
while valid {
entries.push((entry.th32ProcessID, entry.th32ParentProcessID));
valid = unsafe { Process32NextW(snapshot, &mut entry) } != 0;
}
unsafe { CloseHandle(snapshot) };
let mut found = Vec::new();
let mut frontier = match created_at(root) {
Some(created) => vec![(root, created)],
None => return found,
};
while let Some((parent, parent_created)) = frontier.pop() {
for (pid, parent_pid) in &entries {
if *parent_pid != parent || *pid == parent || found.contains(pid) {
continue;
}
let Some(created) = created_at(*pid).filter(|it| *it >= parent_created) else {
continue;
};
found.push(*pid);
frontier.push((*pid, created));
}
}
found
}
impl Drop for ProcessTree {
fn drop(&mut self) {
unsafe { CloseHandle(self.job) };
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{
process::{Child as StdChild, Command},
thread::sleep,
time::{Duration, Instant},
};
fn powershell(script: &str) -> String {
let output = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.output()
.expect("powershell runs");
String::from_utf8_lossy(&output.stdout).trim().to_owned()
}
fn is_running(pid: u32) -> bool {
powershell(&format!(
"if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) {{ 'yes' }} else {{ 'no' }}"
)) == "yes"
}
struct Spawned(StdChild);
impl Spawned {
fn id(&self) -> u32 {
self.0.id()
}
}
impl Drop for Spawned {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn spawn_with_grandchild() -> (Spawned, u32) {
let parent = Spawned(
Command::new("cmd.exe")
.args(["/c", "ping", "-n", "30", "127.0.0.1"])
.stdout(std::process::Stdio::null())
.spawn()
.expect("spawn parent"),
);
let deadline = Instant::now() + Duration::from_secs(15);
while Instant::now() < deadline {
let found = powershell(&format!(
"(Get-CimInstance Win32_Process -Filter 'ParentProcessId={}').ProcessId",
parent.id()
));
if let Some(pid) = found
.lines()
.next()
.and_then(|line| line.trim().parse().ok())
{
return (parent, pid);
}
sleep(Duration::from_millis(300));
}
panic!("grandchild never appeared");
}
fn assert_gone(pid: u32, what: &str) {
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if !is_running(pid) {
return;
}
sleep(Duration::from_millis(300));
}
panic!("{what} ({pid}) survived");
}
fn own_handle_count() -> u32 {
powershell(&format!(
"(Get-Process -Id {}).HandleCount",
std::process::id()
))
.parse()
.expect("handle count")
}
#[test]
fn adopting_and_dropping_jobs_returns_their_handles() {
let spawn = || {
Command::new("cmd.exe")
.args(["/d", "/c", "ping", "-n", "30", "127.0.0.1"])
.stdout(std::process::Stdio::null())
.spawn()
.expect("spawn")
};
for _ in 0..5 {
let mut child = spawn();
drop(ProcessTree::adopt(child.id()).expect("adopt"));
let _ = child.wait();
}
let before = own_handle_count();
const ROUNDS: u32 = 100;
for _ in 0..ROUNDS {
let mut child = spawn();
let tree = ProcessTree::adopt(child.id()).expect("adopt");
drop(tree);
let _ = child.wait();
}
let after = own_handle_count();
assert!(
after.saturating_sub(before) < ROUNDS / 4,
"adopting {ROUNDS} jobs retained {} handles ({before} -> {after})",
after.saturating_sub(before)
);
}
#[test]
fn terminating_a_job_reaches_a_grandchild() {
let (parent, grandchild) = spawn_with_grandchild();
let tree = ProcessTree::adopt(parent.id()).expect("adopt into a job");
assert!(
is_running(grandchild),
"grandchild was not running to begin with"
);
tree.terminate().expect("terminate the job");
assert_gone(parent.id(), "shell");
assert_gone(grandchild, "grandchild");
}
#[test]
fn dropping_a_job_kills_what_is_left() {
let (parent, grandchild) = spawn_with_grandchild();
let tree = ProcessTree::adopt(parent.id()).expect("adopt into a job");
assert!(
is_running(grandchild),
"grandchild was not running to begin with"
);
drop(tree);
assert_gone(parent.id(), "shell");
assert_gone(grandchild, "grandchild");
}
}