Files
oh-my-pi/crates/pi-shell/src/process.rs
T
can1357 a8e1f636f6 refactor(minimizer): applied #[must_use] annotations and simplified minimizer parsing internals
- Applied #[must_use] annotations across minimizer and process APIs to flag ignored return values.
- Replaced direct serde_json field reads with typed Value helper calls in several filters.
- Simplified filter parsing with chained if-let guards, rfind checks, and related cleanup.
- Updated minimizer tests and fixtures to build outputs via write! and fmt::Write.
2026-06-12 12:16:18 +02:00

1811 lines
55 KiB
Rust

//! 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<OwnedFd>,
start_time: u64,
}
impl Process {
pub fn from_pid(pid: i32) -> Option<Self> {
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<Self> {
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<i32> = HashSet::new();
let mut out = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(tid_str) = name.to_str() else {
continue;
};
if tid_str.parse::<i32>().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;
};
for part in content.split_whitespace() {
let Ok(child_pid) = part.parse::<i32>() else {
continue;
};
if !seen.insert(child_pid) {
continue;
}
let Some(child) = Self::from_pid(child_pid) else {
continue;
};
if child.status() == ProcessStatus::Running
&& current_parent_pid(child.pid) == Some(self.pid)
{
out.push(child);
}
}
}
out
}
pub fn parent_pid(&self) -> Option<i32> {
if self.status() == ProcessStatus::Running {
current_parent_pid(self.pid)
} else {
None
}
}
pub fn args(&self) -> Vec<String> {
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::<libc::siginfo_t>(),
0,
)
};
ret == 0
}
pub fn group_id(&self) -> Option<i32> {
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<Self> {
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<Self>, visited: &mut HashSet<i32>) {
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<String> {
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<i32> {
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::<i32>().ok())
})
}
fn read_start_time(pid: i32) -> Option<u64> {
// `/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<Arc<OwnedFd>> {
// 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<Process> {
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::<i32>() 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<Self> {
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<Self> {
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<i32> {
let info = self.live_bsdinfo()?;
i32::try_from(info.pbi_ppid).ok().filter(|ppid| *ppid > 0)
}
pub fn args(&self) -> Vec<String> {
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<i32> {
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<Self> {
// 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<i32, Vec<i32>>) -> Vec<Self> {
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<i32, Vec<i32>>,
visited: &mut HashSet<i32>,
out: &mut Vec<Self>,
) {
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<libc::proc_bsdinfo> {
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<i32> {
// 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::<i32>();
let cap = count.saturating_mul(4).max(2048);
let mut buffer = vec![0i32; cap];
// SAFETY: `buffer` is valid for `buffer.len() * size_of::<i32>()` 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::<i32>()) as i32) };
if actual <= 0 {
return Vec::new();
}
let pid_count = ((actual as usize) / size_of::<i32>()).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<i32, Vec<i32>> {
let pids = snapshot_all_pids();
let mut tree: HashMap<i32, Vec<i32>> = 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<Process> {
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::<std::ffi::c_void>(),
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<libc::proc_bsdinfo> {
// 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::<libc::proc_bsdinfo>() };
// 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::<std::ffi::c_void>(),
size_of::<libc::proc_bsdinfo>() as i32,
)
};
if actual < size_of::<libc::proc_bsdinfo>() as i32 {
return None;
}
Some(info)
}
fn process_args(pid: i32) -> Vec<String> {
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::<libc::c_int>() {
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::<std::ffi::c_void>(),
&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<String> {
// 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::<libc::c_int>();
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<Self> {
if raw.is_null() || raw == INVALID_HANDLE_VALUE {
None
} else {
Some(Self { raw: raw as isize })
}
}
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<OwnedHandle>,
creation_time: u64,
}
impl Process {
pub fn from_pid(pid: i32) -> Option<Self> {
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<i32> {
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<String> {
process_command_line(self)
.as_deref()
.map(split_windows_command_line)
.unwrap_or_default()
}
pub fn children(&self) -> Vec<Self> {
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<Self> {
let tree = build_process_tree();
let Ok(root) = u32::try_from(self.pid) else {
return Vec::new();
};
let mut visited: HashSet<u32> = 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<u32, SmallVec<[u32; 4]>>) -> Vec<Self> {
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<u32, SmallVec<[u32; 4]>>,
visited: &mut HashSet<u32>,
out: &mut Vec<Self>,
) {
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(&self) -> Option<i32> {
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<ProcessBasicInformation> {
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::<ProcessBasicInformation>()` 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::<c_void>(),
mem::size_of::<ProcessBasicInformation>() as u32,
&raw mut returned,
)
};
(status == STATUS_SUCCESS).then_some(info)
}
fn process_command_line(process: &Process) -> Option<String> {
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<u64> {
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<T: Copy>(handle: Handle, address: usize) -> Option<T> {
if address == 0 {
return None;
}
let mut value = mem::MaybeUninit::<T>::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::<c_void>(),
mem::size_of::<T>(),
&raw mut bytes_read,
) != 0
};
if ok && bytes_read == mem::size_of::<T>() {
// SAFETY: The successful `ReadProcessMemory` call above initialized exactly
// `size_of::<T>()` bytes in `value`.
Some(unsafe { value.assume_init() })
} else {
None
}
}
fn read_remote_unicode_string(handle: Handle, value: UnicodeString) -> Option<String> {
if value.length == 0 || value.buffer == 0 || value.length % 2 != 0 {
return None;
}
let code_units = usize::from(value.length) / size_of::<u16>();
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::<c_void>(),
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<String> {
use std::os::windows::ffi::OsStringExt;
let mut wide: Vec<u16> = 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::<c_void>()) };
args
}
fn open_process(pid: u32, access: u32) -> Option<Arc<OwnedHandle>> {
// 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<OwnedHandle> {
// 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)
}
fn process_entry() -> PROCESSENTRY32W {
PROCESSENTRY32W {
dwSize: mem::size_of::<PROCESSENTRY32W>() 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<u32, SmallVec<[u32; 4]>> {
let mut tree: HashMap<u32, SmallVec<[u32; 4]>> = 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<Process> {
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<Self> {
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<Self> {
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<i32> {
self.inner.parent_pid()
}
/// Launch arguments for this process.
#[must_use]
pub fn args(&self) -> Vec<String> {
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<i32>) -> u32 {
self.signal_tree(signal.unwrap_or(KILL_SIGNAL))
}
/// Process group id for this process, when supported by the platform.
#[must_use]
pub fn group_id(&self) -> Option<i32> {
self.inner.group_id()
}
/// Direct children of this process as stable process references.
pub fn children(&self) -> Vec<Self> {
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<bool> {
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<Duration>, ct: CancelToken) -> Result<bool> {
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> {
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.inner.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<bool> {
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<Duration>,
ct: CancelToken,
) -> Result<bool> {
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<i32>,
processes: Vec<Process>,
seen_pids: HashSet<i32>,
}
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<i32> {
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<S: std::hash::BuildHasher>(
targets: &mut TerminationTargets,
baseline: &HashSet<i32, S>,
) {
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<DescendantInfo> = 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<i32>,
}
/// Classified termination targets returned by [`select_termination_targets`].
#[derive(Debug, Default)]
struct TargetSelection {
pgids: Vec<i32>,
pids: Vec<i32>,
}
/// 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<S: std::hash::BuildHasher>(
descendants: &[DescendantInfo],
baseline: &HashSet<i32, S>,
) -> TargetSelection {
let new_descendant_pids: HashSet<i32> = descendants
.iter()
.map(|info| info.pid)
.filter(|pid| !baseline.contains(pid))
.collect();
let mut selection = TargetSelection::default();
let mut seen_pgids: HashSet<i32> = 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<i32> = 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<i32> = 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<i32> = 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<i32> = 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`",
);
}
}