use std::collections::{HashMap, VecDeque};
use std::io;
use windows_sys::Win32::Foundation::{CloseHandle, HANDLE, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
};
use windows_sys::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject, TerminateJobObject,
};
#[cfg(test)]
use windows_sys::Win32::System::JobObjects::{
JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JobObjectBasicAccountingInformation,
QueryInformationJobObject,
};
use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
struct Owned(HANDLE);
impl Drop for Owned {
fn drop(&mut self) {
unsafe { CloseHandle(self.0) };
}
}
pub(crate) struct Job {
handle: Owned,
}
unsafe impl Send for Job {}
unsafe impl Sync for Job {}
impl Job {
pub(crate) fn new() -> 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 = Self {
handle: Owned(handle),
};
let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
let size = u32::try_from(std::mem::size_of_val(&limits)).map_err(io::Error::other)?;
if unsafe {
SetInformationJobObject(
job.handle.0,
JobObjectExtendedLimitInformation,
std::ptr::from_ref(&limits).cast(),
size,
)
} == 0
{
return Err(io::Error::last_os_error());
}
Ok(job)
}
pub(crate) fn assign(&self, pid: u32) -> io::Result<()> {
let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) };
if process.is_null() {
return Err(io::Error::last_os_error());
}
let process = Owned(process);
if unsafe { AssignProcessToJobObject(self.handle.0, process.0) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
pub(crate) fn terminate(&self) {
unsafe { TerminateJobObject(self.handle.0, 1) };
}
#[cfg(test)]
pub(crate) fn active(&self) -> u32 {
let mut info: JOBOBJECT_BASIC_ACCOUNTING_INFORMATION = unsafe { std::mem::zeroed() };
let Ok(size) = u32::try_from(std::mem::size_of_val(&info)) else {
return 0;
};
let ok = unsafe {
QueryInformationJobObject(
self.handle.0,
JobObjectBasicAccountingInformation,
std::ptr::from_mut(&mut info).cast(),
size,
std::ptr::null_mut(),
)
};
if ok == 0 { 0 } else { info.ActiveProcesses }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Entry {
pub(crate) pid: u32,
pub(crate) parent: u32,
pub(crate) name: String,
}
fn snapshot() -> Vec<Entry> {
let handle = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) };
if handle == INVALID_HANDLE_VALUE || handle.is_null() {
return Vec::new();
}
let handle = Owned(handle);
let mut entry = PROCESSENTRY32W {
dwSize: u32::try_from(std::mem::size_of::<PROCESSENTRY32W>()).unwrap_or(0),
..Default::default()
};
let mut out = Vec::new();
let mut ok = unsafe { Process32FirstW(handle.0, &raw mut entry) } != 0;
while ok {
let len = entry
.szExeFile
.iter()
.position(|unit| *unit == 0)
.unwrap_or(entry.szExeFile.len());
out.push(Entry {
pid: entry.th32ProcessID,
parent: entry.th32ParentProcessID,
name: String::from_utf16_lossy(&entry.szExeFile[..len]),
});
ok = unsafe { Process32NextW(handle.0, &raw mut entry) } != 0;
}
out
}
pub(crate) fn foreground(root: u32) -> Option<Entry> {
newest_descendant(root, &snapshot())
}
pub(crate) fn newest_descendant(root: u32, entries: &[Entry]) -> Option<Entry> {
let mut children: HashMap<u32, Vec<&Entry>> = HashMap::new();
let mut by_pid = HashMap::new();
for entry in entries {
by_pid.insert(entry.pid, entry);
if entry.parent != entry.pid {
children.entry(entry.parent).or_default().push(entry);
}
}
let mut last = *by_pid.get(&root)?;
let mut queue = VecDeque::from([root]);
let mut seen = std::collections::HashSet::from([root]);
while let Some(pid) = queue.pop_front() {
for child in children.get(&pid).into_iter().flatten() {
if seen.insert(child.pid) {
last = child;
queue.push_back(child.pid);
}
}
}
Some(last.clone())
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(pid: u32, parent: u32, name: &str) -> Entry {
Entry {
pid,
parent,
name: name.to_owned(),
}
}
#[test]
fn the_foreground_is_the_newest_deepest_descendant() {
let table = [
entry(1, 0, "explorer.exe"),
entry(10, 1, "pwsh.exe"),
entry(11, 10, "cargo.exe"),
entry(12, 11, "rustc.exe"),
entry(13, 10, "git.exe"),
entry(20, 1, "other.exe"),
];
assert_eq!(newest_descendant(10, &table).unwrap().name, "rustc.exe");
assert_eq!(newest_descendant(13, &table).unwrap().name, "git.exe");
assert_eq!(newest_descendant(99, &table), None);
let cycle = [entry(5, 6, "a.exe"), entry(6, 5, "b.exe")];
assert_eq!(newest_descendant(5, &cycle).unwrap().name, "b.exe");
}
#[cfg(windows)]
#[test]
fn closing_the_job_ends_the_process_and_what_it_started() {
use std::time::{Duration, Instant};
let mut child = std::process::Command::new("cmd.exe")
.args([
"/d",
"/c",
"ping -n 2 127.0.0.1 >nul & ping -n 1000 127.0.0.1 >nul",
])
.spawn()
.unwrap();
let job = Job::new().unwrap();
job.assign(child.id()).unwrap();
let wait = |done: &dyn Fn() -> bool| {
let deadline = Instant::now() + Duration::from_secs(15);
while !done() {
assert!(Instant::now() < deadline, "timed out");
std::thread::sleep(Duration::from_millis(50));
}
};
wait(&|| job.active() >= 2);
let foreground = foreground(child.id()).unwrap();
assert!(
foreground.name.eq_ignore_ascii_case("ping.exe"),
"{foreground:?}"
);
job.terminate();
wait(&|| job.active() == 0);
assert!(child.wait().is_ok());
}
}