//! Cross-platform process tree management. use std::{collections::HashSet, time::Duration}; use anyhow::Result; use crate::cancel::CancelToken; /// Current state of a process reference. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ProcessStatus { /// The referenced process is still running. Running, /// The referenced process has exited or is no longer observable. Exited, } #[cfg(target_os = "linux")] mod platform { use std::{ collections::HashSet, ffi::OsStr, fs, os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}, ptr, sync::Arc, }; use super::ProcessStatus; /// Stable Linux process reference backed by a pidfd. #[derive(Clone)] pub struct Process { pid: i32, pidfd: Arc, start_time: u64, } impl Process { pub fn from_pid(pid: i32) -> Option { if pid <= 0 { return None; } let pidfd = open_pidfd(pid)?; let start_time = read_start_time(pid)?; Some(Self { pid, pidfd, start_time }) } pub const fn pid(&self) -> i32 { self.pid } pub fn children(&self) -> Vec { if !self.live_identity() { return Vec::new(); } // `/proc/{pid}/task/{tid}/children` is per-task: a child fork()ed from a // worker thread appears under that thread's `tid`, not the tgid. Walk // every task subdir and union the lists, then re-validate parentage. let task_dir = format!("/proc/{}/task", self.pid); let Ok(entries) = fs::read_dir(&task_dir) else { return Vec::new(); }; let mut seen: HashSet = HashSet::new(); let mut out = Vec::new(); let mut children_file_available = false; for entry in entries.flatten() { let name = entry.file_name(); let Some(tid_str) = name.to_str() else { continue; }; if tid_str.parse::().is_err() { continue; } let children_path = format!("/proc/{}/task/{}/children", self.pid, tid_str); let Ok(content) = fs::read_to_string(&children_path) else { continue; }; // The file is readable -> this kernel has CONFIG_PROC_CHILDREN. children_file_available = true; for part in content.split_whitespace() { let Ok(child_pid) = part.parse::() else { continue; }; self.push_validated_child(child_pid, &mut seen, &mut out); } } // Some Kata / microVM guest kernels are built without CONFIG_PROC_CHILDREN, // so no `.../children` file exists and the walk above finds nothing — which // would silently turn descendant signaling (cancellation cleanup) into a // no-op inside such containers. Fall back to scanning `/proc` and grouping // by parent pid, the same primitive the macOS path uses. Only taken when no // `children` file was readable, so kernels that support it keep the cheap // per-task fast path. if !children_file_available && let Ok(proc_entries) = fs::read_dir("/proc") { for entry in proc_entries.flatten() { let name = entry.file_name(); let Some(pid_str) = name.to_str() else { continue; }; let Ok(child_pid) = pid_str.parse::() else { continue; }; self.push_validated_child(child_pid, &mut seen, &mut out); } } out } /// Validate a candidate child pid — dedup, still running, and currently /// parented to `self` — then push it onto `out`. Shared by the /// `/proc//task//children` fast path and the `/proc`-scan /// fallback for kernels without `CONFIG_PROC_CHILDREN`. fn push_validated_child(&self, child_pid: i32, seen: &mut HashSet, out: &mut Vec) { if child_pid == self.pid || !seen.insert(child_pid) { return; } let Some(child) = Self::from_pid(child_pid) else { return; }; if child.status() == ProcessStatus::Running && current_parent_pid(child.pid) == Some(self.pid) { out.push(child); } } pub fn parent_pid(&self) -> Option { if self.status() == ProcessStatus::Running { current_parent_pid(self.pid) } else { None } } pub fn args(&self) -> Vec { if !self.live_identity() { return Vec::new(); } let cmdline_path = format!("/proc/{}/cmdline", self.pid); let Ok(content) = fs::read(cmdline_path) else { return Vec::new(); }; // Re-validate after the read: PID reuse between identity check and read // would otherwise leak an impostor's command line to callers. if !self.live_identity() { return Vec::new(); } split_nul_arguments(&content) } pub fn kill(&self, signal: i32) -> bool { // SAFETY: `self.pidfd` is an owned file descriptor returned by a successful // `pidfd_open` call and remains open for the duration of this syscall. A null // `siginfo_t` pointer is explicitly accepted by `pidfd_send_signal` and makes // the kernel synthesize the same signal metadata as `kill(2)`. Flags are zero, // which is the documented default behavior. let ret = unsafe { libc::syscall( libc::SYS_pidfd_send_signal, self.pidfd.as_raw_fd(), signal, ptr::null::(), 0, ) }; ret == 0 } pub fn group_id(&self) -> Option { if self.status() != ProcessStatus::Running { return None; } // SAFETY: `self.pid` names the process currently referenced by `self.pidfd` // unless it exits concurrently. If it exits, `getpgid` reports failure rather // than dereferencing caller-owned memory. let pgid = unsafe { libc::getpgid(self.pid) }; if pgid > 0 { Some(pgid) } else { None } } pub fn status(&self) -> ProcessStatus { loop { let mut pollfd = libc::pollfd { fd: self.pidfd.as_raw_fd(), events: libc::POLLIN, revents: 0 }; // SAFETY: `pollfd` points to one initialized `pollfd` element, and the pidfd // remains open for the duration of the call. Timeout zero makes this a // non-blocking readiness probe. let ready = unsafe { libc::poll(&raw mut pollfd, 1, 0) }; if ready < 0 { // Retry on EINTR; for any other transient poll error treat the pidfd as // still running. The pidfd is still owned and the kernel has not reported // the process gone — a spurious `Exited` here makes every downstream // signal/kill fall through silently. if std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) { continue; } return ProcessStatus::Running; } if ready == 0 { return ProcessStatus::Running; } if (pollfd.revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR | libc::POLLNVAL)) != 0 { return ProcessStatus::Exited; } return ProcessStatus::Running; } } /// Walk the descendant tree in post-order (leaves first), de-duplicating /// by PID so concurrent reparenting cannot trap us in a cycle. pub fn descendants(&self) -> Vec { let mut out = Vec::new(); let mut visited = HashSet::new(); visited.insert(self.pid); self.descendants_into(&mut out, &mut visited); out } fn descendants_into(&self, out: &mut Vec, visited: &mut HashSet) { for child in self.children() { if visited.insert(child.pid) { child.descendants_into(out, visited); out.push(child); } } } fn live_identity(&self) -> bool { self.status() == ProcessStatus::Running && read_start_time(self.pid) == Some(self.start_time) } } fn split_nul_arguments(content: &[u8]) -> Vec { content .split(|byte| *byte == 0) .filter(|part| !part.is_empty()) .map(|part| String::from_utf8_lossy(part).into_owned()) .collect() } fn current_parent_pid(pid: i32) -> Option { let status_path = format!("/proc/{pid}/status"); let content = fs::read_to_string(status_path).ok()?; content.lines().find_map(|line| { line .strip_prefix("PPid:") .and_then(|ppid| ppid.trim().parse::().ok()) }) } fn read_start_time(pid: i32) -> Option { // `/proc/[pid]/stat` field 22 is the process start time in clock ticks since // boot. The comm field (between parens) may itself contain spaces and parens, // so locate the *last* `)` and split the trailing whitespace-separated fields. let stat_path = format!("/proc/{pid}/stat"); let content = fs::read_to_string(stat_path).ok()?; let last_paren = content.rfind(')')?; let rest = &content[last_paren + 1..]; rest.split_whitespace().nth(19)?.parse().ok() } fn open_pidfd(pid: i32) -> Option> { // SAFETY: `pidfd_open` takes the PID by value and does not read caller-owned // memory. Flags are zero, which is valid. On success the returned descriptor is // newly owned by this process and is immediately wrapped in `OwnedFd` below. let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) }; if fd < 0 { return None; } // SAFETY: `fd` is non-negative and was just returned by `pidfd_open`, so it is // an open descriptor owned by this process. `OwnedFd` takes sole ownership and // will close it exactly once. Some(Arc::new(unsafe { OwnedFd::from_raw_fd(fd as RawFd) })) } /// Send `signal` to the process group `pgid`. /// Returns true when the signal is delivered successfully. pub fn kill_process_group(pgid: i32, signal: i32) -> bool { // SAFETY: `kill` takes integer identifiers by value and does not access // caller-owned memory. A negative PID is the POSIX process-group form. unsafe { libc::kill(-pgid, signal) == 0 } } /// Find processes whose `/proc/{pid}/exe` symlink resolves to exactly /// `target`. pub fn find_by_path(target: &str) -> Vec { let mut matches = Vec::new(); let Ok(entries) = fs::read_dir("/proc") else { return matches; }; let target_os = OsStr::new(target); for entry in entries.flatten() { let name = entry.file_name(); let Some(name_str) = name.to_str() else { continue; }; let Ok(pid) = name_str.parse::() else { continue; }; let exe_path = format!("/proc/{pid}/exe"); let Ok(resolved) = fs::read_link(&exe_path) else { continue; }; if resolved.as_os_str() == target_os && let Some(process) = Process::from_pid(pid) { matches.push(process); } } matches } } #[cfg(target_os = "macos")] mod platform { use std::{ collections::{HashMap, HashSet}, ptr, }; use super::ProcessStatus; #[link(name = "proc", kind = "dylib")] unsafe extern "C" { fn proc_listallpids(buffer: *mut i32, buffersize: i32) -> i32; fn proc_pidpath(pid: i32, buffer: *mut std::ffi::c_void, buffersize: u32) -> i32; } /// macOS does not expose pidfds; identity is pinned via the kernel-reported /// process start time so a recycled PID does not silently impersonate the /// original target. #[derive(Clone)] pub struct Process { pid: i32, start_tvsec: u64, start_tvusec: u64, } impl Process { pub fn from_pid(pid: i32) -> Option { if pid <= 0 { return None; } let info = read_bsdinfo(pid)?; if i32::try_from(info.pbi_pid).ok()? != pid { return None; } Some(Self { pid, start_tvsec: info.pbi_start_tvsec, start_tvusec: info.pbi_start_tvusec }) } pub const fn pid(&self) -> i32 { self.pid } pub fn children(&self) -> Vec { if self.live_bsdinfo().is_none() { return Vec::new(); } // `proc_listchildpids` (the obvious choice) is broken on recent macOS // kernels when queried for the *calling* process — it returns one byte of // padding regardless of how many children the process actually has, so a // process can never list its own descendants. Confirmed on darwin 25.4 // from C, Rust, and Bun callers via `proc_listchildpids(getpid(), …)`, // while `ps -P` and `pgrep -P` still see the same children. Walk the // whole pid table via `proc_listallpids` and filter on `pbi_ppid` // instead; this is the same approach we already use for `find_by_path` // and that the Windows implementation uses via Toolhelp snapshots. let tree = build_process_tree(); Self::children_from_tree(self.pid, &tree) } pub fn parent_pid(&self) -> Option { let info = self.live_bsdinfo()?; i32::try_from(info.pbi_ppid).ok().filter(|ppid| *ppid > 0) } pub fn args(&self) -> Vec { if self.live_bsdinfo().is_none() { return Vec::new(); } process_args(self.pid) } pub fn kill(&self, signal: i32) -> bool { // Re-validate identity right before signaling. There is no atomic // "kill iff start_time matches" primitive on macOS, so a vanishingly small // window remains between this check and the syscall — but matching against // the recorded `(pid, start_tvsec, start_tvusec)` triple eliminates the // PID-reuse race in every practical case. if self.live_bsdinfo().is_none() { return false; } // SAFETY: `kill` takes integer identifiers by value and does not access // caller-owned memory. unsafe { libc::kill(self.pid, signal) == 0 } } pub fn group_id(&self) -> Option { let info = self.live_bsdinfo()?; i32::try_from(info.pbi_pgid).ok().filter(|pgid| *pgid > 0) } /// Walk the descendant tree in post-order (leaves first), de-duplicating /// by PID so concurrent reparenting cannot trap us in a cycle. pub fn descendants(&self) -> Vec { // One process-table snapshot per walk — building it inside the recursion // would re-scan every pid for every visited node, producing an `O(N · D)` // kernel call pattern. Mirrors the Windows implementation. let tree = build_process_tree(); let mut out = Vec::new(); let mut visited = HashSet::new(); visited.insert(self.pid); Self::collect_descendants_from_tree(self.pid, &tree, &mut visited, &mut out); out } fn children_from_tree(parent: i32, tree: &HashMap>) -> Vec { let Some(child_pids) = tree.get(&parent) else { return Vec::new(); }; child_pids .iter() .copied() .filter_map(Self::from_pid) .collect() } fn collect_descendants_from_tree( parent: i32, tree: &HashMap>, visited: &mut HashSet, out: &mut Vec, ) { let Some(child_pids) = tree.get(&parent) else { return; }; for &child_pid in child_pids { if !visited.insert(child_pid) { continue; } let Some(child) = Self::from_pid(child_pid) else { continue; }; // Post-order: grandchildren first, so leaf processes get signalled // before their parents during tree termination. Self::collect_descendants_from_tree(child_pid, tree, visited, out); out.push(child); } } pub fn status(&self) -> ProcessStatus { if self.live_bsdinfo().is_some() { ProcessStatus::Running } else { ProcessStatus::Exited } } /// Returns the current `proc_bsdinfo` only if it still describes the same /// process this reference was opened on — i.e. the start time has not /// changed. fn live_bsdinfo(&self) -> Option { let info = read_bsdinfo(self.pid)?; if info.pbi_start_tvsec == self.start_tvsec && info.pbi_start_tvusec == self.start_tvusec { Some(info) } else { None } } } /// Send `signal` to the process group `pgid`. /// Returns true when the signal is delivered successfully. pub fn kill_process_group(pgid: i32, signal: i32) -> bool { // SAFETY: `kill` takes integer identifiers by value and does not access // caller-owned memory. A negative PID is the POSIX process-group form. unsafe { libc::kill(-pgid, signal) == 0 } } const KERN_PROCARGS2: libc::c_int = 49; const PROC_PIDPATHINFO_MAXSIZE: usize = 4096; /// Snapshot every pid currently visible to `proc_listallpids`. macOS /// silently truncates the second call to the supplied buffer size even /// when the sizing query reports more bytes available, so the buffer is /// padded well beyond the reported count. fn snapshot_all_pids() -> Vec { // SAFETY: Passing a null buffer with size 0 is the documented libproc query // form for obtaining the byte count needed for all PIDs; libproc does not // dereference the null pointer in this mode. let bytes = unsafe { proc_listallpids(ptr::null_mut(), 0) }; if bytes <= 0 { return Vec::new(); } let count = (bytes as usize) / size_of::(); let cap = count.saturating_mul(4).max(2048); let mut buffer = vec![0i32; cap]; // SAFETY: `buffer` is valid for `buffer.len() * size_of::()` bytes and // is properly aligned for `i32`; libproc writes at most the supplied size. let actual = unsafe { proc_listallpids(buffer.as_mut_ptr(), (buffer.len() * size_of::()) as i32) }; if actual <= 0 { return Vec::new(); } let pid_count = ((actual as usize) / size_of::()).min(buffer.len()); buffer.truncate(pid_count); buffer } /// Build a `ppid -> [pids]` map from a one-shot scan of `proc_listallpids`. /// /// Used as the foundation of `Process::children` and `Process::descendants` /// on macOS where `proc_listchildpids` returns no children for self-queries. pub(super) fn build_process_tree() -> HashMap> { let pids = snapshot_all_pids(); let mut tree: HashMap> = HashMap::with_capacity(pids.len() / 2); for pid in pids { if pid <= 0 { continue; } let Some(info) = read_bsdinfo(pid) else { continue; }; let Ok(ppid) = i32::try_from(info.pbi_ppid) else { continue; }; if ppid <= 0 { continue; } tree.entry(ppid).or_default().push(pid); } tree } /// Find processes whose libproc-reported executable path equals `target`. pub fn find_by_path(target: &str) -> Vec { let pids = snapshot_all_pids(); let mut path_buf = vec![0u8; PROC_PIDPATHINFO_MAXSIZE]; let mut matches = Vec::new(); for pid in pids { if pid <= 0 { continue; } // SAFETY: `path_buf` is valid for `path_buf.len()` bytes; libproc writes a // NUL-terminated path no longer than the supplied capacity and returns the // number of bytes written. let len = unsafe { proc_pidpath( pid, path_buf.as_mut_ptr().cast::(), path_buf.len() as u32, ) }; if len <= 0 { continue; } let path_bytes = &path_buf[..len as usize]; let path_bytes = match path_bytes.iter().position(|byte| *byte == 0) { Some(end) => &path_bytes[..end], None => path_bytes, }; let Ok(path) = std::str::from_utf8(path_bytes) else { continue; }; if path == target && let Some(process) = Process::from_pid(pid) { matches.push(process); } } matches } fn read_bsdinfo(pid: i32) -> Option { // SAFETY: `proc_bsdinfo` is a plain C data struct. Zero initialization is // valid because every field is an integer or fixed-size integer array, and // libproc fully overwrites the fields it reports on a successful call. let mut info = unsafe { std::mem::zeroed::() }; // SAFETY: `info` is a writable `proc_bsdinfo` buffer whose exact byte size is // supplied to libproc. The PID, flavor, and arg are scalar values passed by // value; libproc writes at most the supplied buffer size. let actual = unsafe { libc::proc_pidinfo( pid, libc::PROC_PIDTBSDINFO, 0, (&raw mut info).cast::(), size_of::() as i32, ) }; if actual < size_of::() as i32 { return None; } Some(info) } fn process_args(pid: i32) -> Vec { let mut mib = [libc::CTL_KERN, KERN_PROCARGS2, pid]; let mut size = 0usize; // SAFETY: `mib` points to three initialized integers and the old-value buffer // is null with a zero-length query, which is the documented `sysctl` sizing // pattern. `size` is a valid out-parameter for the required byte count. let sizing_ok = unsafe { libc::sysctl( mib.as_mut_ptr(), mib.len() as u32, ptr::null_mut(), &raw mut size, ptr::null_mut(), 0, ) } == 0; if !sizing_ok || size <= size_of::() { return Vec::new(); } let mut buffer = vec![0u8; size]; // SAFETY: `mib` still points to three initialized integers. `buffer` is // writable for `size` bytes, and `size` is provided as the in/out byte count. let read_ok = unsafe { libc::sysctl( mib.as_mut_ptr(), mib.len() as u32, buffer.as_mut_ptr().cast::(), &raw mut size, ptr::null_mut(), 0, ) } == 0; if !read_ok { return Vec::new(); } buffer.truncate(size); parse_macos_procargs(&buffer) } fn parse_macos_procargs(buffer: &[u8]) -> Vec { // KERN_PROCARGS2 layout: `argc: i32 | exec_path: NUL-padded | argv[0..argc] | // env[..]`. argc covers only argv, so we must skip the exec_path NUL padding // and stop after exactly argc entries — otherwise environment variables leak // into the arg list (each NUL-terminated env=value is indistinguishable from // an arg). let argc_size = size_of::(); if buffer.len() <= argc_size { return Vec::new(); } let argc_bytes: [u8; 4] = match buffer[..argc_size].try_into() { Ok(bytes) => bytes, Err(_) => return Vec::new(), }; let argc = libc::c_int::from_ne_bytes(argc_bytes); if argc <= 0 { return Vec::new(); } let mut offset = argc_size; while offset < buffer.len() && buffer[offset] != 0 { offset += 1; } while offset < buffer.len() && buffer[offset] == 0 { offset += 1; } let mut args = Vec::with_capacity(argc as usize); while offset < buffer.len() && args.len() < argc as usize { let end = buffer[offset..] .iter() .position(|byte| *byte == 0) .map_or(buffer.len(), |position| offset + position); if end == offset { break; } args.push(String::from_utf8_lossy(&buffer[offset..end]).into_owned()); offset = end + 1; } args } } #[cfg(target_os = "windows")] mod platform { use std::{ collections::{HashMap, HashSet}, ffi::c_void, mem, sync::Arc, }; use smallvec::SmallVec; use super::ProcessStatus; #[repr(C)] #[allow(non_snake_case, reason = "Windows PROCESSENTRY32W field names must match Win32 ABI")] struct PROCESSENTRY32W { dwSize: u32, cntUsage: u32, th32ProcessID: u32, th32DefaultHeapID: usize, th32ModuleID: u32, cntThreads: u32, th32ParentProcessID: u32, pcPriClassBase: i32, dwFlags: u32, szExeFile: [u16; 260], } #[repr(C)] struct ProcessBasicInformation { exit_status: i32, peb_base_address: usize, affinity_mask: usize, base_priority: i32, unique_process_id: usize, inherited_from_unique_process_id: usize, } #[repr(C)] #[derive(Clone, Copy)] struct UnicodeString { length: u16, maximum_length: u16, buffer: usize, } #[repr(C)] #[derive(Clone, Copy)] struct PebPartial { reserved1: [u8; 2], being_debugged: u8, reserved2: [u8; 1], reserved3: [usize; 2], loader: usize, process_parameters: usize, } #[repr(C)] #[derive(Clone, Copy)] struct UserProcessParametersPartial { reserved1: [u8; 16], reserved2: [usize; 10], image_path_name: UnicodeString, command_line: UnicodeString, } #[repr(C)] #[derive(Clone, Copy, Default)] struct Filetime { dw_low_date_time: u32, dw_high_date_time: u32, } type Handle = *mut c_void; type NtStatus = i32; const INVALID_HANDLE_VALUE: Handle = -1isize as Handle; const PROCESS_QUERY_INFORMATION: u32 = 0x0400; const PROCESS_VM_READ: u32 = 0x0010; const PROCESS_BASIC_INFORMATION_CLASS: u32 = 0; const STATUS_SUCCESS: NtStatus = 0; const TH32CS_SNAPPROCESS: u32 = 0x00000002; const PROCESS_TERMINATE: u32 = 0x0001; const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000; const SYNCHRONIZE: u32 = 0x00100000; const PROCESS_REFERENCE_ACCESS: u32 = PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE; const WAIT_OBJECT_0: u32 = 0; #[link(name = "kernel32")] unsafe extern "system" { fn CreateToolhelp32Snapshot(dwFlags: u32, th32ProcessID: u32) -> Handle; fn Process32FirstW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32; fn Process32NextW(hSnapshot: Handle, lppe: *mut PROCESSENTRY32W) -> i32; fn CloseHandle(hObject: Handle) -> i32; fn OpenProcess(dwDesiredAccess: u32, bInheritHandle: i32, dwProcessId: u32) -> Handle; fn TerminateProcess(hProcess: Handle, uExitCode: u32) -> i32; fn QueryFullProcessImageNameW( hProcess: Handle, dwFlags: u32, lpExeName: *mut u16, lpdwSize: *mut u32, ) -> i32; fn WaitForSingleObject(hHandle: Handle, dwMilliseconds: u32) -> u32; fn GetProcessTimes( hProcess: Handle, lpCreationTime: *mut Filetime, lpExitTime: *mut Filetime, lpKernelTime: *mut Filetime, lpUserTime: *mut Filetime, ) -> i32; fn ReadProcessMemory( hProcess: Handle, lpBaseAddress: *const c_void, lpBuffer: *mut c_void, nSize: usize, lpNumberOfBytesRead: *mut usize, ) -> i32; fn LocalFree(hMem: Handle) -> Handle; } #[link(name = "shell32")] unsafe extern "system" { fn CommandLineToArgvW(lpCmdLine: *const u16, pNumArgs: *mut i32) -> *mut *mut u16; } #[link(name = "ntdll")] unsafe extern "system" { fn NtQueryInformationProcess( ProcessHandle: Handle, ProcessInformationClass: u32, ProcessInformation: *mut c_void, ProcessInformationLength: u32, ReturnLength: *mut u32, ) -> NtStatus; } struct OwnedHandle { raw: isize, } impl OwnedHandle { fn from_raw(raw: Handle) -> Option { if raw.is_null() || raw == INVALID_HANDLE_VALUE { None } else { Some(Self { raw: raw as isize }) } } const fn as_raw(&self) -> Handle { self.raw as Handle } } impl Drop for OwnedHandle { fn drop(&mut self) { // SAFETY: `self.raw` was returned by a successful Win32 handle-producing // function and stored only in this `OwnedHandle`. `Drop` runs once, so this // closes the owned handle exactly once and no code uses it afterward. let _ = unsafe { CloseHandle(self.as_raw()) }; } } #[derive(Clone)] /// Stable Windows process reference backed by an owned process handle plus /// the kernel-reported creation time, which pins identity even if the PID is /// recycled while we hold the handle. pub struct Process { pid: i32, handle: Arc, creation_time: u64, } impl Process { pub fn from_pid(pid: i32) -> Option { if pid <= 0 { return None; } let pid_u32 = u32::try_from(pid).ok()?; let handle = open_process(pid_u32, PROCESS_REFERENCE_ACCESS)?; let creation_time = process_creation_time(handle.as_raw())?; Some(Self { pid, handle, creation_time }) } pub const fn pid(&self) -> i32 { self.pid } pub fn parent_pid(&self) -> Option { process_basic_information(self.handle.as_raw()) .and_then(|info| i32::try_from(info.inherited_from_unique_process_id).ok()) .filter(|pid| *pid > 0) } pub fn args(&self) -> Vec { process_command_line(self) .as_deref() .map(split_windows_command_line) .unwrap_or_default() } pub fn children(&self) -> Vec { let tree = build_process_tree(); Self::children_from_tree(self.pid, &tree) } /// Walk the entire descendant tree using a single Toolhelp snapshot. /// /// `children()` recursing per-node would re-snapshot the whole process /// table for every visited descendant, making tree termination /// `O(N · D)` snapshots. One snapshot per termination wave is enough. pub fn descendants(&self) -> Vec { let tree = build_process_tree(); let Ok(root) = u32::try_from(self.pid) else { return Vec::new(); }; let mut visited: HashSet = HashSet::new(); visited.insert(root); let mut out = Vec::new(); Self::collect_descendants_from_tree(root, &tree, &mut visited, &mut out); out } fn children_from_tree(pid: i32, tree: &HashMap>) -> Vec { let Ok(pid_u32) = u32::try_from(pid) else { return Vec::new(); }; tree .get(&pid_u32) .into_iter() .flatten() .filter_map(|&child_pid| { let child = Self::from_pid(i32::try_from(child_pid).ok()?)?; (child.status() == ProcessStatus::Running).then_some(child) }) .collect() } fn collect_descendants_from_tree( parent: u32, tree: &HashMap>, visited: &mut HashSet, out: &mut Vec, ) { let Some(children) = tree.get(&parent) else { return; }; for &child_pid in children { if !visited.insert(child_pid) { continue; } let Ok(child_pid_i) = i32::try_from(child_pid) else { continue; }; let Some(child) = Self::from_pid(child_pid_i) else { continue; }; if child.status() != ProcessStatus::Running { continue; } // Post-order: collect grandchildren first so leaves are signalled before // their parents during tree termination. Self::collect_descendants_from_tree(child_pid, tree, visited, out); out.push(child); } } pub fn kill(&self, _signal: i32) -> bool { // The handle pins the original kernel process object even after the PID is // recycled, so `TerminateProcess` cannot accidentally hit a different // process. SAFETY: `self.handle` is an owned process handle opened with // `PROCESS_TERMINATE` access and remains valid for the duration of this // call. The exit code is passed by value. unsafe { TerminateProcess(self.handle.as_raw(), 1) != 0 } } pub const fn group_id() -> Option { None } pub fn status(&self) -> ProcessStatus { // `WaitForSingleObject` on a process handle opened with `SYNCHRONIZE` is // the definitive liveness probe: the handle becomes signalled iff the // process has exited. This avoids the `STILL_ACTIVE == 259` pitfall in // `GetExitCodeProcess`, where a process that legitimately exits with code // 259 is indistinguishable from a still-running one. // // SAFETY: `self.handle` is an owned process handle opened with // `SYNCHRONIZE` access. A zero timeout makes this a non-blocking probe. let result = unsafe { WaitForSingleObject(self.handle.as_raw(), 0) }; if result == WAIT_OBJECT_0 { ProcessStatus::Exited } else { ProcessStatus::Running } } } fn process_basic_information(handle: Handle) -> Option { let mut info = ProcessBasicInformation { exit_status: 0, peb_base_address: 0, affinity_mask: 0, base_priority: 0, unique_process_id: 0, inherited_from_unique_process_id: 0, }; let mut returned = 0u32; // SAFETY: `handle` is a valid process handle. `info` is writable for exactly // `size_of::()` bytes, and `returned` is a valid // optional out-parameter for the byte count. let status = unsafe { NtQueryInformationProcess( handle, PROCESS_BASIC_INFORMATION_CLASS, (&raw mut info).cast::(), mem::size_of::() as u32, &raw mut returned, ) }; (status == STATUS_SUCCESS).then_some(info) } fn process_command_line(process: &Process) -> Option { let pid_u32 = u32::try_from(process.pid).ok()?; let read_handle = open_process(pid_u32, PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)?; // PID-reuse defense: `OpenProcess` resolves a PID to *whichever* process owns // it right now, which need not be the one our original handle pinned. Compare // the freshly opened handle's creation time against the recorded value to // reject reads from an unrelated process that happens to share the PID. if process_creation_time(read_handle.as_raw())? != process.creation_time { return None; } let info = process_basic_information(read_handle.as_raw())?; let peb: PebPartial = read_remote(read_handle.as_raw(), info.peb_base_address)?; if peb.process_parameters == 0 { return None; } let params: UserProcessParametersPartial = read_remote(read_handle.as_raw(), peb.process_parameters)?; read_remote_unicode_string(read_handle.as_raw(), params.command_line) } fn process_creation_time(handle: Handle) -> Option { let mut creation = Filetime::default(); let mut exit = Filetime::default(); let mut kernel = Filetime::default(); let mut user = Filetime::default(); // SAFETY: `handle` is a valid process handle opened with at least // `PROCESS_QUERY_LIMITED_INFORMATION`. All four out-parameters point to // initialized, writable `Filetime` values that live until the call returns. let ok = unsafe { GetProcessTimes(handle, &raw mut creation, &raw mut exit, &raw mut kernel, &raw mut user) != 0 }; if !ok { return None; } Some((u64::from(creation.dw_high_date_time) << 32) | u64::from(creation.dw_low_date_time)) } fn read_remote(handle: Handle, address: usize) -> Option { if address == 0 { return None; } let mut value = mem::MaybeUninit::::uninit(); let mut bytes_read = 0usize; // SAFETY: `handle` is opened with `PROCESS_VM_READ`. `address` comes from // kernel-reported process structures for that same process. `value` points to // uninitialized local storage large enough for `T`, and `bytes_read` is a valid // out-parameter. The value is only assumed initialized after the OS reports a // full-size successful read. let ok = unsafe { ReadProcessMemory( handle, address as *const c_void, value.as_mut_ptr().cast::(), mem::size_of::(), &raw mut bytes_read, ) != 0 }; if ok && bytes_read == mem::size_of::() { // SAFETY: The successful `ReadProcessMemory` call above initialized exactly // `size_of::()` bytes in `value`. Some(unsafe { value.assume_init() }) } else { None } } fn read_remote_unicode_string(handle: Handle, value: UnicodeString) -> Option { if value.length == 0 || value.buffer == 0 || !value.length.is_multiple_of(2) { return None; } let code_units = usize::from(value.length) / size_of::(); let mut buffer = vec![0u16; code_units]; let mut bytes_read = 0usize; // SAFETY: `handle` is opened with `PROCESS_VM_READ`. `value.buffer` and // `value.length` come from the remote process' own `UNICODE_STRING`. `buffer` // is writable for exactly `value.length` bytes, and `bytes_read` is a valid // out-parameter. The string is decoded only after a full successful read. let ok = unsafe { ReadProcessMemory( handle, value.buffer as *const c_void, buffer.as_mut_ptr().cast::(), usize::from(value.length), &raw mut bytes_read, ) != 0 }; if ok && bytes_read == usize::from(value.length) { Some(String::from_utf16_lossy(&buffer)) } else { None } } fn split_windows_command_line(command_line: &str) -> Vec { use std::os::windows::ffi::OsStringExt; let mut wide: Vec = command_line.encode_utf16().chain([0]).collect(); let mut argc = 0i32; // SAFETY: `wide` is a local, NUL-terminated UTF-16 buffer that remains alive // for the duration of the call. `argc` is a valid out-parameter. The returned // argv block is released with `LocalFree` below as required by // `CommandLineToArgvW`. let argv = unsafe { CommandLineToArgvW(wide.as_mut_ptr(), &raw mut argc) }; if argv.is_null() || argc <= 0 { return Vec::new(); } let argc = argc as usize; // SAFETY: `CommandLineToArgvW` returned a non-null pointer to `argc` argument // pointers, valid until freed with `LocalFree`. let pointers = unsafe { std::slice::from_raw_parts(argv, argc) }; let args = pointers .iter() .filter_map(|&arg| { if arg.is_null() { return None; } let mut len = 0usize; // SAFETY: Each pointer in the argv block is a NUL-terminated UTF-16 // string owned by the argv block and valid until `LocalFree` below. while unsafe { *arg.add(len) } != 0 { len += 1; } // SAFETY: The loop above found the terminating NUL, so the preceding // `len` code units form a valid readable slice. let slice = unsafe { std::slice::from_raw_parts(arg, len) }; Some( std::ffi::OsString::from_wide(slice) .to_string_lossy() .into_owned(), ) }) .collect(); // SAFETY: `argv` is the allocation returned by `CommandLineToArgvW` and has // not been freed yet. No pointers into it are used after this call. let _ = unsafe { LocalFree(argv.cast::()) }; args } fn open_process(pid: u32, access: u32) -> Option> { // SAFETY: `OpenProcess` takes the PID and access mask by value and does not // dereference caller-owned memory. Handle inheritance is disabled. Identity // is established by the caller (typically `Process::from_pid`) capturing the // creation time immediately after a successful open and re-checking it on // every subsequent operation that re-resolves the PID. let handle = unsafe { OpenProcess(access, 0, pid) }; OwnedHandle::from_raw(handle).map(Arc::new) } fn create_process_snapshot() -> Option { // SAFETY: The process snapshot API takes flags and a process ID by value and // does not dereference caller-owned memory. PID zero requests all processes. let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; OwnedHandle::from_raw(snapshot) } const fn process_entry() -> PROCESSENTRY32W { PROCESSENTRY32W { dwSize: mem::size_of::() as u32, cntUsage: 0, th32ProcessID: 0, th32DefaultHeapID: 0, th32ModuleID: 0, cntThreads: 0, th32ParentProcessID: 0, pcPriClassBase: 0, dwFlags: 0, szExeFile: [0; 260], } } /// Build a map of `parent_pid` -> [`child_pids`] for all processes. fn build_process_tree() -> HashMap> { let mut tree: HashMap> = HashMap::new(); let Some(snapshot) = create_process_snapshot() else { return tree; }; let mut entry = process_entry(); // SAFETY: `snapshot` is a valid Toolhelp snapshot handle. `entry` points to a // writable `PROCESSENTRY32W` whose `dwSize` field was initialized to the exact // ABI size before the call. if unsafe { Process32FirstW(snapshot.as_raw(), &raw mut entry) } == 0 { return tree; } loop { tree .entry(entry.th32ParentProcessID) .or_default() .push(entry.th32ProcessID); // SAFETY: `snapshot` remains a valid Toolhelp snapshot handle, and `entry` // remains a writable `PROCESSENTRY32W` with its ABI size preserved. if unsafe { Process32NextW(snapshot.as_raw(), &raw mut entry) } == 0 { break; } } tree } /// Process groups are not exposed on Windows. /// Always returns `false`. pub const fn kill_process_group(_pgid: i32, _signal: i32) -> bool { false } /// Find processes whose `QueryFullProcessImageNameW` result equals `target`. pub fn find_by_path(target: &str) -> Vec { use std::{ffi::OsString, os::windows::ffi::OsStringExt}; let mut matches = Vec::new(); let Some(snapshot) = create_process_snapshot() else { return matches; }; let mut entry = process_entry(); let mut buf = vec![0u16; 32_768]; let target = OsString::from(target); // SAFETY: `snapshot` is a valid Toolhelp snapshot handle. `entry` points to a // writable `PROCESSENTRY32W` whose `dwSize` field was initialized to the exact // ABI size before the call. if unsafe { Process32FirstW(snapshot.as_raw(), &raw mut entry) } == 0 { return matches; } loop { let pid = entry.th32ProcessID; if let Some(handle) = open_process(pid, PROCESS_QUERY_LIMITED_INFORMATION) { let mut size = buf.len() as u32; // SAFETY: `handle` was opened with query access and remains valid for the // call. `buf` is writable for `size` UTF-16 code units, and `size` is a valid // in/out parameter initialized to that capacity. let ok = unsafe { QueryFullProcessImageNameW(handle.as_raw(), 0, buf.as_mut_ptr(), &raw mut size) != 0 }; if ok { let path = OsString::from_wide(&buf[..size as usize]); if path == target && let Some(process) = Process::from_pid(i32::try_from(pid).unwrap_or_default()) { matches.push(process); } } } // SAFETY: `snapshot` remains a valid Toolhelp snapshot handle, and `entry` // remains a writable `PROCESSENTRY32W` with its ABI size preserved. if unsafe { Process32NextW(snapshot.as_raw(), &raw mut entry) } == 0 { break; } } matches } } /// Stable process reference. #[derive(Clone)] pub struct Process { inner: platform::Process, } impl Process { /// Open a stable process reference from a PID. pub fn from_pid(pid: i32) -> Option { platform::Process::from_pid(pid).map(Self::from_inner) } /// Open stable process references whose executable path matches exactly. pub fn from_path(path: String) -> Vec { platform::find_by_path(&path) .into_iter() .map(Self::from_inner) .collect() } /// Operating-system process identifier for this process reference. #[must_use] pub const fn pid(&self) -> i32 { self.inner.pid() } /// Parent process id for this process, when available. #[must_use] pub fn ppid(&self) -> Option { self.inner.parent_pid() } /// Launch arguments for this process. #[must_use] pub fn args(&self) -> Vec { self.inner.args() } /// Send `signal` to this process and its descendants, children first. /// /// On Linux and macOS the signal is forwarded as-is. On Windows there is no /// signal abstraction, so the `signal` argument is ignored and the entire /// tree is hard-killed via `TerminateProcess`. Defaults to the POSIX /// hard-kill signal. #[must_use] pub fn kill_tree(&self, signal: Option) -> u32 { self.signal_tree(signal.unwrap_or(KILL_SIGNAL)) } /// Process group id for this process, when supported by the platform. #[cfg(target_os = "windows")] #[must_use] pub const fn group_id(&self) -> Option { platform::Process::group_id() } #[cfg(not(target_os = "windows"))] #[must_use] pub fn group_id(&self) -> Option { self.inner.group_id() } /// Direct children of this process as stable process references. pub fn children(&self) -> Vec { self .inner .children() .into_iter() .map(Self::from_inner) .collect() } /// Current status of this process reference. #[must_use] pub fn status(&self) -> ProcessStatus { self.inner.status() } /// Gracefully terminate this process and its descendants. /// /// Sends `TERM_SIGNAL` to the optional process group, every live descendant, /// and the root, then optionally waits up to `graceful_ms` for the tree to /// exit before escalating to `KILL_SIGNAL`. Pass `graceful_ms < 0` to skip /// the wait entirely (the polite signal is still emitted). Returns `true` /// when the tree has exited by the end of the hard wave's wait window. pub async fn terminate_tree( &self, group: bool, graceful_ms: i32, timeout_ms: u32, ct: CancelToken, ) -> Result { self .terminate_tree_impl(group, graceful_ms, timeout_ms, ct) .await } /// Wait until this process exits, optionally bounded by `timeout`. pub async fn wait_for_exit(&self, timeout: Option, ct: CancelToken) -> Result { wait_for_exit(self, &[], timeout, ct).await } } impl Process { const fn from_inner(inner: platform::Process) -> Self { Self { inner } } /// Walk the live descendant tree from scratch. Cheap and idempotent — call /// it again before each signal wave so grandchildren spawned during a grace /// period are not missed. fn live_descendants(&self) -> Vec { self .inner .descendants() .into_iter() .map(Self::from_inner) .collect() } fn signal_tree(&self, signal: i32) -> u32 { let descendants = self.live_descendants(); let mut signaled = 0u32; // If self leads its own process group, also signal the group — this catches // grandchildren reparented to init when their immediate parent died inside // the descendant walk. if let Some(pgid) = self.group_id() && pgid == self.inner.pid() { let _ = kill_process_group(pgid, signal); } for child in &descendants { if child.inner.kill(signal) { signaled += 1; } } if self.inner.kill(signal) { signaled += 1; } signaled } async fn terminate_tree_impl( &self, group: bool, graceful_ms: i32, timeout_ms: u32, ct: CancelToken, ) -> Result { if self.status() != ProcessStatus::Running { return Ok(true); } let process_group = if group { self.group_id() } else { None }; // Polite wave: SIGTERM the group, every live descendant, then the root. if let Some(pgid) = process_group { let _ = kill_process_group(pgid, TERM_SIGNAL); } let mut descendants = self.live_descendants(); for child in &descendants { let _ = child.inner.kill(TERM_SIGNAL); } let _ = self.inner.kill(TERM_SIGNAL); // Optional grace wait. A negative `graceful_ms` skips the wait entirely // (we still emit the polite signal so cleanup handlers can run before KILL). if graceful_ms >= 0 { let exited = wait_for_exit( self, &descendants, Some(Duration::from_millis(graceful_ms as u64)), ct.clone(), ) .await?; if exited { return Ok(true); } } // Hard wave. Re-walk the tree so any grandchild spawned during the grace // period — or any process re-parented to the root — is signalled too. if let Some(pgid) = process_group { let _ = kill_process_group(pgid, KILL_SIGNAL); } descendants = self.live_descendants(); for child in &descendants { let _ = child.inner.kill(KILL_SIGNAL); } let _ = self.inner.kill(KILL_SIGNAL); wait_for_exit(self, &descendants, Some(Duration::from_millis(u64::from(timeout_ms))), ct) .await } } async fn wait_for_exit( root: &Process, descendants: &[Process], timeout: Option, ct: CancelToken, ) -> Result { ct.heartbeat()?; if root.status() != ProcessStatus::Running && descendants .iter() .all(|process| process.status() != ProcessStatus::Running) { return Ok(true); } let poll_interval = Duration::from_millis(50); let mut elapsed = Duration::ZERO; while timeout.is_none_or(|limit| elapsed < limit) { let sleep_for = timeout.map_or(poll_interval, |limit| limit.saturating_sub(elapsed).min(poll_interval)); if sleep_for.is_zero() { break; } ct.heartbeat()?; tokio::time::sleep(sleep_for).await; elapsed += sleep_for; if root.status() != ProcessStatus::Running && descendants .iter() .all(|process| process.status() != ProcessStatus::Running) { return Ok(true); } } Ok(false) } /// Send `signal` to the process group `pgid`. /// Returns false when process groups are unsupported on the platform. #[allow(clippy::missing_const_for_fn, reason = "Dispatches to platform-specific implementation")] #[must_use] pub fn kill_process_group(pgid: i32, signal: i32) -> bool { // Defense in depth: refuse to deliver a signal to the harness's own // process group. Doing so terminates the harness along with the targets. // Higher layers (`add_new_descendants`) already filter pgids by descendant // ownership; this catches any future caller that bypasses that filter. if pgid <= 0 || is_self_process_group(pgid) { return false; } platform::kill_process_group(pgid, signal) } #[cfg(unix)] fn is_self_process_group(pgid: i32) -> bool { // SAFETY: `getpgid(0)` queries the calling process's pgid and does not access // caller-owned memory. A return value <= 0 is treated as "unknown", which // fails open so the actual signal call decides. let self_pgid = unsafe { libc::getpgid(0) }; self_pgid > 0 && self_pgid == pgid } #[cfg(not(unix))] const fn is_self_process_group(_pgid: i32) -> bool { false } /// POSIX `SIGTERM` / Windows polite termination sentinel. pub const TERM_SIGNAL: i32 = 15; /// POSIX `SIGKILL` / Windows hard-termination sentinel. pub const KILL_SIGNAL: i32 = 9; /// A collection of process groups and process trees scheduled for /// termination together. /// /// Built incrementally from job records or PTY metadata, then signalled /// in escalating waves (typically `TERM_SIGNAL` followed by /// `KILL_SIGNAL` after a grace period). Process-group calls are no-ops /// on platforms that do not expose process groups. #[derive(Default)] pub struct TerminationTargets { pgids: Vec, processes: Vec, seen_pids: HashSet, } impl TerminationTargets { /// Create an empty target set. #[must_use] pub fn new() -> Self { Self::default() } /// Record a process group id. Duplicates are ignored. pub fn add_pgid(&mut self, pgid: i32) { if pgid > 0 && !self.pgids.contains(&pgid) { self.pgids.push(pgid); } } /// Record a pid. Duplicates are ignored. If the pid is alive, opens /// a stable [`Process`] reference so the descendant tree can be /// killed even if the original pid is reused later. pub fn add_pid(&mut self, pid: i32) { if self.seen_pids.insert(pid) && let Some(process) = Process::from_pid(pid) { self.processes.push(process); } } /// True when no targets have been recorded. #[must_use] pub const fn is_empty(&self) -> bool { self.pgids.is_empty() && self.processes.is_empty() } /// Send `signal` to every recorded target. Failures are swallowed: /// targets routinely exit between collection and signalling, and /// the caller's policy is "best effort". pub fn signal(&self, signal: i32) { for &pgid in &self.pgids { let _ = kill_process_group(pgid, signal); } for process in &self.processes { let _ = process.signal_tree(signal); } } } #[must_use] pub fn current_descendant_pids() -> HashSet { Process::from_pid(i32::try_from(std::process::id()).unwrap_or_default()).map_or_else( HashSet::new, |process| { process .live_descendants() .into_iter() .map(|child| child.pid()) .collect() }, ) } pub fn add_new_descendants( targets: &mut TerminationTargets, baseline: &HashSet, ) { let self_pid = i32::try_from(std::process::id()).unwrap_or_default(); let Some(process) = Process::from_pid(self_pid) else { return; }; let descendants = process.live_descendants(); let descendants_info: Vec = descendants .iter() .map(|child| DescendantInfo { pid: child.pid(), pgid: child.group_id() }) .collect(); let selection = select_termination_targets(&descendants_info, baseline); for pgid in selection.pgids { targets.add_pgid(pgid); } for pid in selection.pids { targets.add_pid(pid); } } /// Light view of a descendant for target classification — just enough to /// decide which pgids/pids belong in the kill set without holding any /// platform-specific process handles. #[derive(Debug, Clone, Copy)] struct DescendantInfo { pid: i32, pgid: Option, } /// Classified termination targets returned by [`select_termination_targets`]. #[derive(Debug, Default)] struct TargetSelection { pgids: Vec, pids: Vec, } /// Pure target-classifier separated from process discovery so it is testable /// without depending on the platform's process-listing primitives (libproc on /// macOS, `/proc` on Linux). /// /// **Critical**: a `pgid` is only adopted when its leader is itself one of the /// new descendants. Without that check, a descendant that inherited the /// harness's pgid — any subprocess started via APIs that do not call `setpgid`, /// such as a sibling LSP/MCP helper spawned outside of brush — would drag /// `harness.pgid` into the kill set, and the subsequent /// `kill(-harness.pgid, SIGTERM)` would terminate the harness alongside the /// intended targets. Pids of new descendants are still tracked individually so /// the descendant tree can be reaped via `signal_tree`. fn select_termination_targets( descendants: &[DescendantInfo], baseline: &HashSet, ) -> TargetSelection { let new_descendant_pids: HashSet = descendants .iter() .map(|info| info.pid) .filter(|pid| !baseline.contains(pid)) .collect(); let mut selection = TargetSelection::default(); let mut seen_pgids: HashSet = HashSet::new(); for info in descendants { if !new_descendant_pids.contains(&info.pid) { continue; } if let Some(pgid) = info.pgid && pgid > 0 && new_descendant_pids.contains(&pgid) && seen_pgids.insert(pgid) { selection.pgids.push(pgid); } selection.pids.push(info.pid); } selection } #[cfg(test)] mod tests { use super::*; /// Regression test for the cancellation-kills-harness bug. /// /// When the descendant walk harvested each descendant's `pgid` and pushed /// it onto the kill list, a descendant that inherited the harness's pgid /// — any subprocess started via APIs that do not call `setpgid`, such as a /// sibling LSP/MCP helper — dragged `harness.pgid` into the kill set, and /// the subsequent `kill(-harness.pgid, SIGTERM)` killed the harness. /// /// Encode the dangerous shape directly: a new descendant whose `pgid` /// resolves to something the harness owns (not in the new descendant set) /// must contribute its pid for individual cleanup but **must not** drag its /// pgid into the group-signal list. #[test] fn select_targets_drops_inherited_harness_pgid() { const HARNESS_PGID: i32 = 1000; const BASELINE_HELPER_PID: i32 = 1500; // Harness pgid is *not* a new descendant; a baseline helper happens to // lead a group that a new descendant inherited. Neither pgid is safe to // signal as a group. let descendants = [DescendantInfo { pid: 2000, pgid: Some(HARNESS_PGID) }, DescendantInfo { pid: 2001, pgid: Some(BASELINE_HELPER_PID), }]; let baseline: HashSet = std::iter::once(BASELINE_HELPER_PID).collect(); let selection = select_termination_targets(&descendants, &baseline); assert!( selection.pgids.is_empty(), "no pgid should be added when leaders live outside the new descendant set; got {:?}", selection.pgids, ); assert_eq!( selection.pids, vec![2000, 2001], "new descendant pids must still be tracked individually for tree cleanup", ); } #[test] fn select_targets_adopts_owned_process_group() { // A new descendant that *is* the group leader — brush's `NewProcessGroup` // path — contributes both its pid and its pgid, so grandchildren in the // same group get reaped in one signal wave. let leader = DescendantInfo { pid: 3000, pgid: Some(3000) }; let grandchild = DescendantInfo { pid: 3001, pgid: Some(3000) }; let baseline: HashSet = HashSet::new(); let selection = select_termination_targets(&[leader, grandchild], &baseline); assert_eq!(selection.pgids, vec![3000]); assert_eq!(selection.pids, vec![3000, 3001]); } #[test] fn select_targets_skips_baseline_descendants() { let old = DescendantInfo { pid: 4000, pgid: Some(4000) }; let fresh = DescendantInfo { pid: 4100, pgid: Some(4100) }; let baseline: HashSet = std::iter::once(4000).collect(); let selection = select_termination_targets(&[old, fresh], &baseline); assert_eq!(selection.pgids, vec![4100]); assert_eq!(selection.pids, vec![4100]); } #[test] fn select_targets_dedupes_shared_process_group() { let a = DescendantInfo { pid: 5000, pgid: Some(5000) }; let b = DescendantInfo { pid: 5001, pgid: Some(5000) }; let c = DescendantInfo { pid: 5002, pgid: Some(5000) }; let baseline: HashSet = HashSet::new(); let selection = select_termination_targets(&[a, b, c], &baseline); assert_eq!( selection.pgids, vec![5000], "each pgid should be recorded exactly once even when many descendants share it", ); assert_eq!(selection.pids, vec![5000, 5001, 5002]); } /// `kill_process_group` is the last line of defense: even if a future /// caller manages to feed the harness's own pgid into the signal path, /// this wrapper must refuse to deliver the signal. #[cfg(unix)] #[test] fn kill_process_group_refuses_self_pgroup() { // SAFETY: `getpgid(0)` queries the calling process and does not touch // caller-owned memory. let self_pgid = unsafe { libc::getpgid(0) }; assert!(self_pgid > 0, "getpgid(0) failed"); assert!( !kill_process_group(self_pgid, TERM_SIGNAL), "kill_process_group must refuse the harness pgid; otherwise the test process would have \ been SIGTERMed", ); assert!( !kill_process_group(0, TERM_SIGNAL), "kill_process_group must reject non-positive pgids", ); } /// Regression test for the macOS `proc_listchildpids` brokenness: on /// darwin 25.4+ the kernel returns no entries when a process queries its /// own children via that API, so `Process::descendants` produced an empty /// list and termination cleanup silently became a no-op. The replacement /// path scans `proc_listallpids` and groups by `pbi_ppid`, which actually /// works. Linux has always worked via `/proc`. #[cfg(unix)] #[test] fn descendants_includes_freshly_spawned_child() { use std::{process::Command, thread, time::Duration}; let mut child = Command::new("sleep") .arg("10") .spawn() .expect("spawn sleep"); let child_pid = i32::try_from(child.id()).expect("child pid fits in i32"); let self_pid = i32::try_from(std::process::id()).expect("self pid fits in i32"); let harness = Process::from_pid(self_pid).expect("harness Process ref"); // Allow a few polling iterations so the kernel's process-table query // settles on a loaded host. proc_listallpids reflects newly forked pids // within milliseconds in practice; 1s is a comfortable upper bound. let mut found = false; for _ in 0..40 { if harness .live_descendants() .iter() .any(|descendant| descendant.pid() == child_pid) { found = true; break; } thread::sleep(Duration::from_millis(25)); } let _ = child.kill(); let _ = child.wait(); assert!( found, "freshly spawned child pid {child_pid} must appear in `live_descendants` so the \ cancellation cleanup can reach it; this regressed on macOS when the walk relied on the \ broken `proc_listchildpids`", ); } }