dc22ea5dad
Protect only the harness pid during cancellation sweeps. The host recorded parent pid can be stale on Windows and recycled onto the hung command; adding that raw pid to the protected set spared the cancellation target and pruned its whole subtree from cleanup. Keep protected-subtree pruning rooted at the harness itself, which still spares its real workers while allowing the timed-out target to be reaped. Fixes #7452
2253 lines
75 KiB
Rust
2253 lines
75 KiB
Rust
//! Cross-platform process tree management.
|
|
|
|
use std::{
|
|
collections::{HashMap, HashSet},
|
|
time::Duration,
|
|
};
|
|
|
|
use anyhow::Result;
|
|
use parking_lot::Mutex;
|
|
|
|
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();
|
|
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::<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;
|
|
};
|
|
// 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::<i32>() 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::<i32>() 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/<pid>/task/<tid>/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<i32>, out: &mut Vec<Self>) {
|
|
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<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 })
|
|
}
|
|
}
|
|
|
|
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<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() -> 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.is_multiple_of(2) {
|
|
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)
|
|
}
|
|
|
|
const 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.
|
|
#[cfg(target_os = "windows")]
|
|
#[must_use]
|
|
pub const fn group_id(&self) -> Option<i32> {
|
|
platform::Process::group_id()
|
|
}
|
|
|
|
#[cfg(not(target_os = "windows"))]
|
|
#[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 {
|
|
self.signal_tree_excluding(signal, &host_protected_pids())
|
|
}
|
|
|
|
/// Signal this process and its live descendants (children first), skipping
|
|
/// any pid in `protected`.
|
|
///
|
|
/// `protected` shields the harness itself: a run-cancellation sweep must
|
|
/// never hard-kill the host. On Windows the
|
|
/// descendant tree is derived from raw `th32ParentProcessID` values that
|
|
/// outlive their recorded parent, so a freshly spawned child whose recycled
|
|
/// pid matches the harness's stale parent pid makes the harness enumerate
|
|
/// as a false descendant; `TerminateProcess`-ing it drops the whole session
|
|
/// with no cleanup and no `session_exit` record (#7452, related #4605).
|
|
fn signal_tree_excluding(&self, signal: i32, protected: &HashSet<i32>) -> u32 {
|
|
let descendants = self.signalable_descendants(protected);
|
|
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 !protected.contains(&self.pid()) && self.inner.kill(signal) {
|
|
signaled += 1;
|
|
}
|
|
signaled
|
|
}
|
|
|
|
/// Live descendants with every protected subtree pruned, not just the exact
|
|
/// protected pids.
|
|
///
|
|
/// The flattened descendant list can contain a protected node (the harness,
|
|
/// on a Windows PID-reuse false-descendant) *together with* that node's real
|
|
/// children, which were collected by recursing through it. Skipping only the
|
|
/// exact protected pid would still terminate those unrelated worker/tool
|
|
/// subprocesses, so drop every node whose recorded parent chain — within the
|
|
/// enumerated set — passes through a protected pid (#7452 review).
|
|
fn signalable_descendants(&self, protected: &HashSet<i32>) -> Vec<Self> {
|
|
let descendants = self.live_descendants();
|
|
let parents: HashMap<i32, i32> = descendants
|
|
.iter()
|
|
.filter_map(|descendant| descendant.ppid().map(|parent| (descendant.pid(), parent)))
|
|
.collect();
|
|
descendants
|
|
.into_iter()
|
|
.filter(|descendant| !pid_in_protected_subtree(descendant.pid(), protected, &parents))
|
|
.collect()
|
|
}
|
|
|
|
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 };
|
|
let protected = host_protected_pids();
|
|
|
|
// 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.signalable_descendants(&protected);
|
|
for child in &descendants {
|
|
let _ = child.inner.kill(TERM_SIGNAL);
|
|
}
|
|
if !protected.contains(&self.pid()) {
|
|
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.signalable_descendants(&protected);
|
|
for child in &descendants {
|
|
let _ = child.inner.kill(KILL_SIGNAL);
|
|
}
|
|
if !protected.contains(&self.pid()) {
|
|
let _ = self.inner.kill(KILL_SIGNAL);
|
|
}
|
|
|
|
wait_for_exit(self, &descendants, Some(Duration::from_millis(u64::from(timeout_ms))), ct)
|
|
.await
|
|
}
|
|
}
|
|
|
|
/// The harness pid — the one process a run-cancellation sweep must never
|
|
/// signal.
|
|
///
|
|
/// On Unix the descendant walk is identity-pinned (pidfd / start-time), so the
|
|
/// host can never appear as a false descendant and this set is a harmless
|
|
/// no-op safety net. On Windows the descendant tree is derived from raw
|
|
/// `th32ParentProcessID` values that survive their recorded parent's death: a
|
|
/// freshly spawned child whose recycled pid matches the harness's stale parent
|
|
/// pid makes the harness enumerate as a false descendant, so cancelling a
|
|
/// timed-out bash run would `TerminateProcess` the host with no cleanup and no
|
|
/// `session_exit` record (#7452, related #4605).
|
|
///
|
|
/// Do not walk the host's numeric parent chain here. On Windows the host's
|
|
/// recorded parent pid can itself have been recycled onto the cancellation
|
|
/// target; treating that raw pid as protected would spare the hung command and
|
|
/// prune all of its descendants from cleanup.
|
|
fn host_protected_pids() -> HashSet<i32> {
|
|
i32::try_from(std::process::id()).into_iter().collect()
|
|
}
|
|
|
|
/// True when `pid` is itself protected or descends — within the enumerated
|
|
/// `parents` map (pid -> recorded parent pid) — from a protected pid. Used to
|
|
/// prune a whole protected subtree from a cancellation sweep so a false
|
|
/// descendant of the harness cannot drag the harness's real children into the
|
|
/// kill set (#7452).
|
|
fn pid_in_protected_subtree(
|
|
pid: i32,
|
|
protected: &HashSet<i32>,
|
|
parents: &HashMap<i32, i32>,
|
|
) -> bool {
|
|
let mut current = pid;
|
|
// Bound the walk against a corrupted or cyclic parent chain.
|
|
for _ in 0..256 {
|
|
if protected.contains(¤t) {
|
|
return true;
|
|
}
|
|
match parents.get(¤t) {
|
|
Some(&parent) if parent != current => current = parent,
|
|
_ => return false,
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
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.
|
|
// `SpawnRegistry` only ever records pgids brush created for this run (never
|
|
// the harness pgid); this catches any future caller that bypasses it.
|
|
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.
|
|
///
|
|
/// Prefer [`add_process`](Self::add_process) when the caller already holds a
|
|
/// [`Process`] captured at spawn time: opening by pid here loses the
|
|
/// original identity if the pid was recycled between the child exiting
|
|
/// and this call.
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// Record a pre-pinned [`Process`] handle. Duplicates (by pid) are ignored.
|
|
///
|
|
/// This is the correct entry point when the caller captured the handle at
|
|
/// spawn time — the handle already pins OS-level identity, so no `from_pid`
|
|
/// re-open (and its PID-reuse race) is needed at cancellation time.
|
|
pub fn add_process(&mut self, process: Process) {
|
|
if self.seen_pids.insert(process.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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A single external child reported by the shell's spawn-observer hook.
|
|
///
|
|
/// `process` is captured *at spawn time* so its OS-level identity is pinned
|
|
/// before the pid can be recycled. On Windows an open process handle keeps
|
|
/// the pid reserved for the lifetime of the reference; on Linux the pidfd
|
|
/// pins identity; on macOS the recorded `(pid, start_time)` triple detects
|
|
/// impersonation. Storing only the raw pid and re-opening at cancellation
|
|
/// time — as previous versions did — leaked kills onto unrelated processes
|
|
/// that happened to acquire the recycled pid between the child exiting and
|
|
/// the run being cancelled (issue #4605).
|
|
#[derive(Clone)]
|
|
struct SpawnedProcess {
|
|
process: Option<Process>,
|
|
pgid: Option<i32>,
|
|
}
|
|
|
|
/// Per-run record of the OS processes a single shell command launched,
|
|
/// captured at spawn time via brush's `SpawnObserver` hook.
|
|
///
|
|
/// Replaces the old process-global "new descendants since a baseline" diff,
|
|
/// which could not distinguish the children of concurrent runs sharing one
|
|
/// host process: a run that cancelled would signal *any* descendant spawned
|
|
/// after its baseline, including another run's children. Ownership is now
|
|
/// explicit — only processes this run actually spawned are ever signalled.
|
|
#[derive(Default)]
|
|
struct RegistryState {
|
|
spawned: Vec<SpawnedProcess>,
|
|
/// The next `spawned.len()` at which `record` runs a sweep. Bounds sweep
|
|
/// frequency when the live set stabilizes above the initial threshold:
|
|
/// without this watermark, every subsequent `record` would find
|
|
/// `len >= PRUNE_THRESHOLD` true and sweep on every spawn (O(n²) in a
|
|
/// large-fan-out run like `for i in {1..1000}; do sleep 60 & done`). With
|
|
/// it, the next sweep only fires once the vec has grown by another
|
|
/// `PRUNE_THRESHOLD` entries since the previous sweep — restoring true
|
|
/// amortized O(1) per spawn regardless of how many entries survive each
|
|
/// sweep.
|
|
next_sweep_at: usize,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
pub struct SpawnRegistry {
|
|
state: Mutex<RegistryState>,
|
|
}
|
|
|
|
impl SpawnRegistry {
|
|
/// Amortized-cost threshold for opportunistic pruning of exited entries.
|
|
///
|
|
/// A shell run that spawns many short-lived external commands (e.g. a bash
|
|
/// loop invoking a binary per iteration) would otherwise retain one owned
|
|
/// process handle per spawn — a pidfd on Linux, a `HANDLE` on Windows — for
|
|
/// the lifetime of the run, exhausting per-process FD/handle limits.
|
|
///
|
|
/// Each sweep costs `O(N)` (one non-blocking status probe per entry, plus
|
|
/// a Toolhelp descendant walk on Windows for exited roots). The next sweep
|
|
/// is scheduled `PRUNE_THRESHOLD` further records away — via the
|
|
/// `next_sweep_at` watermark — so a run that keeps many concurrent
|
|
/// long-lived children (`for i in {1..1000}; do sleep 60 & done`) does not
|
|
/// sweep on every spawn just because the vec is already above threshold.
|
|
/// Amortized cost per spawn stays `O(1)` regardless of the live-set size.
|
|
const PRUNE_THRESHOLD: usize = 64;
|
|
|
|
/// Create an empty registry.
|
|
#[must_use]
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Record a freshly spawned child. Called from the spawn-observer hook.
|
|
///
|
|
/// The `Process` handle MUST be opened by the caller *immediately* after
|
|
/// the child's pid becomes visible, so identity is pinned before any race
|
|
/// with pid recycling can start. When the pin fails (child already exited
|
|
/// before we could `Process::from_pid`) the entry becomes a no-op at
|
|
/// termination time — there is nothing left to signal.
|
|
///
|
|
/// Exited entries are swept opportunistically once the recorded vec
|
|
/// crosses the next-sweep watermark, so long-running loops of short
|
|
/// external commands cannot exhaust the process' FD/handle limit by
|
|
/// retaining one owned handle per historical spawn.
|
|
pub fn record(&self, pgid: Option<i32>, process: Option<Process>) {
|
|
let mut state = self.state.lock();
|
|
state.spawned.push(SpawnedProcess { process, pgid });
|
|
if state.spawned.len() >= state.next_sweep_at.max(Self::PRUNE_THRESHOLD) {
|
|
prune_exited(&mut state.spawned);
|
|
// Schedule the next sweep `PRUNE_THRESHOLD` further records away.
|
|
// Comparing against the post-sweep live-set size (not the pre-sweep
|
|
// length) bounds the sweep frequency when many entries survive:
|
|
// each sweep costs O(N) but now runs at most once per
|
|
// `PRUNE_THRESHOLD` records, so amortized per-record cost is O(1)
|
|
// even if the live set stays large.
|
|
state.next_sweep_at = state.spawned.len() + Self::PRUNE_THRESHOLD;
|
|
}
|
|
}
|
|
|
|
/// Build the kill set from the processes recorded so far. Re-read on every
|
|
/// signal wave so a child spawned during a grace window — between the
|
|
/// cancel firing and the next wave — is still reaped.
|
|
///
|
|
/// A recorded process contributes only while alive; a recorded pgid
|
|
/// contributes only while the group still has members, so once the run's
|
|
/// whole tree exits the targets are empty and the wave loop can stop early.
|
|
///
|
|
/// Pruning also runs here so a cancellation cycle sees a compact target
|
|
/// set even when the record-time threshold hasn't fired yet.
|
|
#[must_use]
|
|
pub fn build_targets(&self) -> TerminationTargets {
|
|
let mut targets = TerminationTargets::new();
|
|
let spawned = {
|
|
let mut state = self.state.lock();
|
|
prune_exited(&mut state.spawned);
|
|
// Reset the watermark to the current live-set size + threshold;
|
|
// leaving a stale pre-sweep value would misgate the next
|
|
// record-time sweep.
|
|
state.next_sweep_at = state.spawned.len() + Self::PRUNE_THRESHOLD;
|
|
state.spawned.clone()
|
|
};
|
|
for entry in spawned {
|
|
if let Some(process) = entry.process {
|
|
targets.add_process(process);
|
|
}
|
|
// If the observer failed to pin a handle at spawn time (the child
|
|
// exited before `Process::from_pid` could open it), the child is
|
|
// already gone — signalling anything for that pid would either
|
|
// no-op or, worse, race a recycled pid onto an unrelated process.
|
|
// Drop the entry entirely rather than reintroduce the pid-reuse
|
|
// window this whole change exists to close (#4605).
|
|
if let Some(pgid) = entry.pgid
|
|
&& pgid > 0
|
|
&& process_group_alive(pgid)
|
|
{
|
|
targets.add_pgid(pgid);
|
|
}
|
|
}
|
|
targets
|
|
}
|
|
}
|
|
|
|
/// Drop registry entries whose pinned process, process group, and — on
|
|
/// Windows — descendant tree are all gone. With nothing still-live the entry
|
|
/// contributes nothing to the next termination wave and only pins an owned OS
|
|
/// handle for no reason.
|
|
///
|
|
/// The platform split matters because Windows has no process groups. On Unix
|
|
/// a child reparented onto init keeps its pgid, so a live pgid still catches
|
|
/// grandchildren whose immediate parent exited. On Windows there is no
|
|
/// reparenting and no pgid, so we probe the descendant tree directly through
|
|
/// the still-open pinned handle — dropping that handle would release the pid
|
|
/// slot, letting a recycled pid make future Toolhelp walks unsafe (issue
|
|
/// #4605) and orphaning any leftover child from the next cancellation wave.
|
|
fn prune_exited(spawned: &mut Vec<SpawnedProcess>) {
|
|
spawned.retain(|entry| {
|
|
if let Some(process) = &entry.process {
|
|
if process.status() == ProcessStatus::Running {
|
|
return true;
|
|
}
|
|
// Windows-only: root exited but the pinned handle still keeps its
|
|
// pid reserved, so `live_descendants` walks the *original* subtree
|
|
// via Toolhelp. If any child is still running we must keep the
|
|
// entry — closing the handle would both release the pid (racing
|
|
// pid reuse) and strand the surviving child.
|
|
#[cfg(target_os = "windows")]
|
|
if !process.live_descendants().is_empty() {
|
|
return true;
|
|
}
|
|
}
|
|
entry
|
|
.pgid
|
|
.is_some_and(|pgid| pgid > 0 && process_group_alive(pgid))
|
|
});
|
|
}
|
|
|
|
/// True when process group `pgid` still has at least one member. `kill(2)`
|
|
/// with signal 0 performs permission/existence checks without delivering a
|
|
/// signal; `EPERM` means the group exists but is not ours to signal, which
|
|
/// still counts as alive.
|
|
#[must_use]
|
|
fn process_group_alive(pgid: i32) -> bool {
|
|
if pgid <= 0 {
|
|
return false;
|
|
}
|
|
platform_process_group_alive(pgid)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn platform_process_group_alive(pgid: i32) -> bool {
|
|
// SAFETY: `kill` takes integer identifiers by value and does not access
|
|
// caller-owned memory. A negative pid targets the process group; signal 0
|
|
// only runs the existence/permission checks.
|
|
let ret = unsafe { libc::kill(-pgid, 0) };
|
|
ret == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
const fn platform_process_group_alive(_pgid: i32) -> bool {
|
|
false
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The harness pid must be the only protected pid. Including its recorded
|
|
/// parent would be unsafe on Windows: that stale numeric pid can have been
|
|
/// recycled onto the timed-out command, causing cancellation to spare the
|
|
/// hung target and its whole subtree.
|
|
#[test]
|
|
fn host_protected_pids_includes_self() {
|
|
let self_pid = i32::try_from(std::process::id()).expect("self pid fits in i32");
|
|
assert_eq!(
|
|
host_protected_pids(),
|
|
HashSet::from([self_pid]),
|
|
"only the harness pid may be protected from cancellation sweeps",
|
|
);
|
|
}
|
|
|
|
/// Regression test for #7452: a cancellation sweep must never signal the
|
|
/// protected host pid, even when it is enumerated as the sweep root. On
|
|
/// Windows a recycled pid can make the harness surface as
|
|
/// a false descendant of a just-spawned child; `TerminateProcess`-ing it
|
|
/// killed the whole session with no `session_exit` record. The observable
|
|
/// defense — provable cross-platform — is that `signal_tree_excluding`
|
|
/// leaves a protected pid untouched.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn signal_tree_spares_protected_pids() {
|
|
use std::{process::Command, thread, time::Duration};
|
|
|
|
let mut child = Command::new("sleep")
|
|
.arg("30")
|
|
.spawn()
|
|
.expect("spawn sleep");
|
|
let child_pid = i32::try_from(child.id()).expect("child pid fits in i32");
|
|
let root = Process::from_pid(child_pid).expect("pin child");
|
|
|
|
// Treat the child's pid as protected (standing in for the harness/an
|
|
// ancestor). The sweep must refuse to signal it.
|
|
let protected: HashSet<i32> = [child_pid].into_iter().collect();
|
|
let signaled = root.signal_tree_excluding(KILL_SIGNAL, &protected);
|
|
assert_eq!(signaled, 0, "a protected root must never be signalled");
|
|
|
|
// The protected process is still alive after the sweep.
|
|
thread::sleep(Duration::from_millis(50));
|
|
assert_eq!(
|
|
root.status(),
|
|
ProcessStatus::Running,
|
|
"a protected pid must survive a cancellation sweep",
|
|
);
|
|
|
|
// With no protection the same sweep reaps it — proves the skip is what
|
|
// spared it, not a dead target.
|
|
let reaped = root.signal_tree_excluding(KILL_SIGNAL, &HashSet::new());
|
|
assert!(reaped >= 1, "an unprotected root must be signalled");
|
|
let _ = child.wait();
|
|
}
|
|
|
|
/// Regression test for the #7453 review: pruning a protected node must drop
|
|
/// its whole subtree, not just the exact protected pid. A Windows PID-reuse
|
|
/// false-descendant collects the harness together with the harness's real
|
|
/// children (LSP servers, worker/tool subprocesses); skipping only the host
|
|
/// pid would still terminate those. `pid_in_protected_subtree` walks the
|
|
/// enumerated parent map so any node under a protected pid is excluded.
|
|
#[test]
|
|
fn protected_subtree_is_pruned_not_just_the_pid() {
|
|
// root(1) -> host(2, protected) -> worker(3); root(1) -> real_child(4).
|
|
let parents: HashMap<i32, i32> = [(2, 1), (3, 2), (4, 1)].into_iter().collect();
|
|
let protected: HashSet<i32> = [2].into_iter().collect();
|
|
|
|
assert!(
|
|
pid_in_protected_subtree(2, &protected, &parents),
|
|
"the protected node itself must be excluded",
|
|
);
|
|
assert!(
|
|
pid_in_protected_subtree(3, &protected, &parents),
|
|
"a child collected through the protected node must be excluded too",
|
|
);
|
|
assert!(
|
|
!pid_in_protected_subtree(4, &protected, &parents),
|
|
"a real child of the sweep root must still be signalled",
|
|
);
|
|
assert!(
|
|
!pid_in_protected_subtree(1, &protected, &parents),
|
|
"the sweep root must not be pruned",
|
|
);
|
|
}
|
|
|
|
/// `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`",
|
|
);
|
|
}
|
|
|
|
/// Regression test for issue #4605: `SpawnRegistry` MUST pin a stable
|
|
/// [`Process`] reference at spawn time rather than defer re-opening the
|
|
/// pid until termination.
|
|
///
|
|
/// Before the fix, `SpawnRegistry` stored only the raw pid; `build_targets`
|
|
/// called `Process::from_pid` at cancellation time. On Windows pids recycle
|
|
/// aggressively, so a bash-spawned `pwsh.exe` that had already exited could
|
|
/// see its pid reassigned to an unrelated PowerShell session (e.g. the
|
|
/// user's other Cursor terminal). `Process::from_pid` at cancel time would
|
|
/// happily open that unrelated process, and `signal_tree` would then
|
|
/// enumerate — and `TerminateProcess` — the entire foreign subtree.
|
|
///
|
|
/// This test cannot literally trigger Windows pid recycling from a
|
|
/// cross-platform Rust test, but it can prove the observable defense: a
|
|
/// recorded process reference survives the original pid's death (so no
|
|
/// "look it up again" step exists to be raced), and the registry never
|
|
/// consults `Process::from_pid` when a handle was pinned at record time.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn spawn_registry_pins_identity_at_record_time() {
|
|
use std::{process::Command, thread, time::Duration};
|
|
|
|
// Phase 1: while the child is alive, the pinned handle carries identity
|
|
// forward into `build_targets` without any `Process::from_pid` re-open
|
|
// step existing to be raced against pid reuse.
|
|
let mut long = Command::new("sleep")
|
|
.arg("30")
|
|
.spawn()
|
|
.expect("spawn sleep");
|
|
let long_pid = i32::try_from(long.id()).expect("child pid fits in i32");
|
|
|
|
let registry = SpawnRegistry::new();
|
|
let pinned = Process::from_pid(long_pid).expect("pin child at record time");
|
|
registry.record(None, Some(pinned));
|
|
|
|
let live_targets = registry.build_targets();
|
|
assert!(
|
|
!live_targets.is_empty(),
|
|
"a still-live pinned child must appear in the target set — otherwise the cancellation \
|
|
cleanup would silently miss it"
|
|
);
|
|
let live_pids: Vec<i32> = live_targets.processes.iter().map(Process::pid).collect();
|
|
assert_eq!(
|
|
live_pids,
|
|
vec![long_pid],
|
|
"target set must come from the pinned handle recorded at spawn time, not a re-lookup by \
|
|
pid (which would race pid reuse — issue #4605)"
|
|
);
|
|
|
|
let _ = long.kill();
|
|
let _ = long.wait();
|
|
|
|
// Phase 2: once the child exits, the registry MUST drop the entry
|
|
// rather than reintroduce a `Process::from_pid` re-open at kill time.
|
|
// Poll until pruning sees the pidfd as Exited (kernel-visible within
|
|
// milliseconds in practice).
|
|
let mut empty_after_exit = false;
|
|
for _ in 0..40 {
|
|
if registry.build_targets().is_empty() {
|
|
empty_after_exit = true;
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(25));
|
|
}
|
|
assert!(
|
|
empty_after_exit,
|
|
"once the pinned child exits the registry must drop it — re-opening by pid at \
|
|
termination time is exactly the pid-reuse race #4605 closes"
|
|
);
|
|
}
|
|
|
|
/// `TerminationTargets::add_process` must accept a pre-pinned handle
|
|
/// without going through `Process::from_pid`. This is the API contract
|
|
/// `SpawnRegistry` relies on to avoid the PID-reuse race.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn add_process_bypasses_from_pid_lookup() {
|
|
let self_pid = i32::try_from(std::process::id()).expect("self pid fits in i32");
|
|
let pinned = Process::from_pid(self_pid).expect("pin self");
|
|
|
|
let mut targets = TerminationTargets::new();
|
|
targets.add_process(pinned.clone());
|
|
assert!(!targets.is_empty(), "add_process must record the pinned handle");
|
|
|
|
// Adding the same pid again through either entry point must dedupe:
|
|
// otherwise every wave in `terminate_run` would re-signal the same
|
|
// tree N times.
|
|
targets.add_process(pinned);
|
|
targets.add_pid(self_pid);
|
|
assert_eq!(targets.processes.len(), 1, "duplicate pids must be deduped");
|
|
}
|
|
|
|
/// Regression test for the review on PR #4606: a long-running shell
|
|
/// command that spawns many short-lived external processes must not
|
|
/// retain one owned handle per historical spawn — that would exhaust
|
|
/// per-process FD/handle limits (pidfd on Linux, `HANDLE` on Windows).
|
|
/// The registry MUST prune dead entries once the recorded vec crosses
|
|
/// the sweep threshold.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn spawn_registry_prunes_exited_entries() {
|
|
use std::{thread, time::Duration};
|
|
|
|
let registry = SpawnRegistry::new();
|
|
|
|
// Fabricate many recorded-then-exited children by pinning ourselves,
|
|
// pushing the entry, then immediately treating it as "dead" from the
|
|
// registry's perspective. To simulate the exit without actually
|
|
// killing the harness, use `Process::from_pid(1)` for a pid that
|
|
// (on Linux) is init and never exits — but wrap the recording in a
|
|
// pattern that guarantees `status()` returns Exited for the pruner:
|
|
// spawn a tiny child, pin it, wait for exit, then record.
|
|
for _ in 0..(SpawnRegistry::PRUNE_THRESHOLD * 2) {
|
|
let mut child = std::process::Command::new("true")
|
|
.spawn()
|
|
.expect("spawn true");
|
|
let pid = i32::try_from(child.id()).expect("child pid fits in i32");
|
|
let pinned = Process::from_pid(pid);
|
|
let _ = child.wait();
|
|
// Give the kernel a moment to mark the pidfd readable so `status()`
|
|
// reports Exited when the pruner probes.
|
|
for _ in 0..20 {
|
|
if pinned
|
|
.as_ref()
|
|
.is_some_and(|process| process.status() == ProcessStatus::Exited)
|
|
{
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(5));
|
|
}
|
|
registry.record(None, pinned);
|
|
}
|
|
|
|
let retained = registry.state.lock().spawned.len();
|
|
assert!(
|
|
retained < SpawnRegistry::PRUNE_THRESHOLD,
|
|
"pruning must bound retained entries below the sweep threshold once the pinned processes \
|
|
have exited; got {retained} retained (threshold {})",
|
|
SpawnRegistry::PRUNE_THRESHOLD
|
|
);
|
|
|
|
// build_targets sees no live handles → empty target set, matching the
|
|
// contract that fully-exited registries stop the wave loop early.
|
|
let targets = registry.build_targets();
|
|
assert!(targets.is_empty(), "registry of only-dead entries must produce an empty target set");
|
|
}
|
|
|
|
/// Regression test for the third review on PR #4606: once the recorded
|
|
/// vec crosses `PRUNE_THRESHOLD`, subsequent `record` calls must NOT
|
|
/// sweep on every spawn. Without the `next_sweep_at` watermark, a large
|
|
/// fan-out run whose live children exceed the threshold turned every
|
|
/// spawn into an O(N) status probe of the whole retained set.
|
|
///
|
|
/// The check reasons about the observable side effect: after N records
|
|
/// past threshold with entries that CANNOT be pruned (all still live),
|
|
/// the retained size grows monotonically by exactly N — no sweep runs
|
|
/// have modified the vec in between. The direct signal of "did a sweep
|
|
/// happen" is a stable pinned handle count across records.
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn spawn_registry_watermark_bounds_sweep_frequency() {
|
|
let self_pid = i32::try_from(std::process::id()).expect("self pid fits in i32");
|
|
let registry = SpawnRegistry::new();
|
|
|
|
// Fill past threshold with entries that are permanently alive
|
|
// (pinning ourselves) so the pruner has nothing to remove.
|
|
let fill = SpawnRegistry::PRUNE_THRESHOLD + 10;
|
|
for _ in 0..fill {
|
|
registry.record(None, Process::from_pid(self_pid));
|
|
}
|
|
let after_fill = registry.state.lock().spawned.len();
|
|
assert_eq!(after_fill, fill, "live-only entries must not be pruned during warm-up");
|
|
let watermark_after_fill = registry.state.lock().next_sweep_at;
|
|
|
|
// Every additional record with a live entry must land in the vec
|
|
// verbatim and — critically — NOT re-enter `prune_exited` until the
|
|
// vec crosses the freshly scheduled watermark. If the guard were
|
|
// still `len >= PRUNE_THRESHOLD` (pre-fix), a sweep would fire on
|
|
// every one of these records.
|
|
let extra = 20;
|
|
for _ in 0..extra {
|
|
registry.record(None, Process::from_pid(self_pid));
|
|
}
|
|
let after_extra = registry.state.lock().spawned.len();
|
|
assert_eq!(
|
|
after_extra,
|
|
after_fill + extra,
|
|
"records with live entries must accumulate without triggering per-spawn sweeps"
|
|
);
|
|
assert_eq!(
|
|
registry.state.lock().next_sweep_at,
|
|
watermark_after_fill,
|
|
"watermark must not advance while the vec stays below it — otherwise a sweep ran"
|
|
);
|
|
}
|
|
}
|