use crate::core::name_match::{classify, Hit, NameMatch, Names};
use crate::error::{ProcError, Result};
use serde::{Deserialize, Serialize};
use std::sync::OnceLock;
use std::time::Duration;
use sysinfo::{Pid, ProcessStatus as SysProcessStatus, System, Uid, Users};
fn user_name(uid: &Uid) -> String {
static USERS: OnceLock<Users> = OnceLock::new();
USERS
.get_or_init(Users::new_with_refreshed_list)
.get_user_by_id(uid)
.map(|user| user.name().to_string())
.unwrap_or_else(|| uid.to_string())
}
pub fn escape_control(text: &str) -> std::borrow::Cow<'_, str> {
fn needs_escape(c: char) -> bool {
c.is_control()
|| matches!(c, '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
}
if !text.chars().any(needs_escape) {
return std::borrow::Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len() + 8);
for c in text.chars() {
match c {
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
c if needs_escape(c) => out.push_str(&format!("\\u{{{:x}}}", c as u32)),
c => out.push(c),
}
}
std::borrow::Cow::Owned(out)
}
const STUCK_READINGS: usize = 2;
const STUCK_READING_GAP: Duration = Duration::from_secs(1);
fn looks_stuck(cpu_percent: f32, status: SysProcessStatus) -> bool {
cpu_percent > 50.0 || status == SysProcessStatus::UninterruptibleDiskSleep
}
fn cpu_percent_between(used_before_ms: u64, used_after_ms: u64, wall: Duration) -> f32 {
let wall_ms = wall.as_secs_f64() * 1000.0;
if wall_ms <= 0.0 {
return 0.0;
}
(used_after_ms.saturating_sub(used_before_ms) as f64 / wall_ms * 100.0) as f32
}
#[cfg(not(unix))]
pub(crate) fn orphans_not_supported() -> ProcError {
ProcError::NotSupported(
"orphans is not supported on Windows: Windows doesn't re-parent processes, \
so there is no reliable way to tell an orphan"
.to_string(),
)
}
#[cfg(any(windows, test))]
fn command_failure(stdout: &[u8], stderr: &[u8], status: &str) -> String {
[stderr, stdout]
.iter()
.map(|out| {
String::from_utf8_lossy(out)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ")
})
.find(|text| !text.is_empty())
.unwrap_or_else(|| format!("exited with {status}"))
}
fn kill_outcome(delivered: bool, pid: u32) -> Result<()> {
if delivered {
Ok(())
} else {
Err(ProcError::SignalError(format!(
"Failed to kill process {}",
pid
)))
}
}
#[cfg(any(windows, test))]
fn taskkill_outcome(output: &std::process::Output) -> Result<()> {
if output.status.success() {
Ok(())
} else {
Err(ProcError::SignalError(command_failure(
&output.stdout,
&output.stderr,
&output.status.to_string(),
)))
}
}
fn cpu_used(sys: &System) -> std::collections::HashMap<Pid, u64> {
sys.processes()
.iter()
.map(|(pid, p)| (*pid, p.accumulated_cpu_time()))
.collect()
}
fn ran_long_enough(run_time: u64, timeout_secs: u64) -> bool {
run_time >= timeout_secs
}
fn counts_as_stuck(is_thread: bool, run_time: u64, timeout_secs: u64, looks_stuck: bool) -> bool {
!is_thread && ran_long_enough(run_time, timeout_secs) && looks_stuck
}
#[cfg(target_os = "macos")]
fn macos_zombies(known: &std::collections::HashSet<u32>) -> Vec<Process> {
let table = read_proc_table(|buf, size| {
let mut mib = [libc::CTL_KERN, libc::KERN_PROC, libc::KERN_PROC_ALL, 0];
let null = std::ptr::null_mut();
unsafe { libc::sysctl(mib.as_mut_ptr(), 4, buf, size, null, 0) }
});
zombies_in(&table, known)
}
#[cfg(target_os = "macos")]
const KINFO_PROC_SIZE: usize = 648;
#[cfg(target_os = "macos")]
fn read_proc_table(
sysctl: impl Fn(*mut libc::c_void, &mut libc::size_t) -> libc::c_int,
) -> Vec<u8> {
let mut size: libc::size_t = 0;
if sysctl(std::ptr::null_mut(), &mut size) != 0 {
return Vec::new();
}
size += 64 * KINFO_PROC_SIZE;
let mut buf: Vec<u8> = vec![0; size];
if sysctl(buf.as_mut_ptr().cast(), &mut size) != 0 {
return Vec::new();
}
buf.truncate(size);
buf
}
#[cfg(target_os = "macos")]
fn zombies_in(buf: &[u8], known: &std::collections::HashSet<u32>) -> Vec<Process> {
const P_STARTTIME_SEC: usize = 0;
const P_STAT: usize = 36;
const P_PID: usize = 40;
const P_COMM: usize = 243; const P_RUID: usize = 392;
const E_PPID: usize = 560;
const SZOMB: u8 = 5;
if !buf.len().is_multiple_of(KINFO_PROC_SIZE) {
return Vec::new(); }
let read_u32 = |p: &[u8], at: usize| u32::from_ne_bytes(p[at..at + 4].try_into().unwrap());
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
buf.as_chunks::<KINFO_PROC_SIZE>()
.0
.iter()
.filter(|p| p[P_STAT] == SZOMB)
.filter_map(|p| {
let pid = read_u32(p, P_PID);
if pid == 0 || known.contains(&pid) {
return None;
}
let comm = &p[P_COMM..P_COMM + 17];
let comm = &comm[..comm.iter().position(|&c| c == 0).unwrap_or(comm.len())];
let name = escape_control(&String::from_utf8_lossy(comm)).into_owned();
let start =
u64::from_ne_bytes(p[P_STARTTIME_SEC..P_STARTTIME_SEC + 8].try_into().unwrap());
Some(Process {
pid,
name: if name.is_empty() {
"<defunct>".to_string()
} else {
name
},
exe_path: None,
cwd: None,
command: None,
argv0: None,
raw: None,
cpu_percent: 0.0,
memory_mb: 0.0,
status: ProcessStatus::Zombie,
user: Uid::try_from(read_u32(p, P_RUID) as usize)
.ok()
.map(|uid| escape_control(&user_name(&uid)).into_owned()),
parent_pid: Some(read_u32(p, E_PPID)),
start_time: (start > 0).then_some(start),
uptime_secs: (start > 0).then(|| now.saturating_sub(start)),
})
})
.collect()
}
pub fn is_thread(proc: &sysinfo::Process) -> bool {
proc.thread_kind().is_some()
}
fn strip_deleted_marker(cwd: String) -> String {
match cwd.strip_suffix(" (deleted)") {
Some(path) if !std::path::Path::new(&cwd).exists() => path.to_string(),
_ => cwd,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ProcessStatus {
Running,
Sleeping,
Stopped,
Zombie,
Dead,
Unknown,
}
impl From<SysProcessStatus> for ProcessStatus {
fn from(status: SysProcessStatus) -> Self {
match status {
SysProcessStatus::Run => ProcessStatus::Running,
SysProcessStatus::Sleep => ProcessStatus::Sleeping,
SysProcessStatus::Stop => ProcessStatus::Stopped,
SysProcessStatus::Zombie => ProcessStatus::Zombie,
SysProcessStatus::Dead => ProcessStatus::Dead,
_ => ProcessStatus::Unknown,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Process {
pub pid: u32,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub exe_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip)]
pub argv0: Option<String>,
#[serde(skip)]
pub raw: Option<Box<RawText>>,
pub cpu_percent: f32,
pub memory_mb: f64,
pub status: ProcessStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_pid: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub start_time: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub uptime_secs: Option<u64>,
}
pub(crate) fn self_and_ancestors(sys: &System) -> std::collections::HashSet<Pid> {
let self_pid = Pid::from_u32(std::process::id());
let mut pids = std::collections::HashSet::new();
pids.insert(self_pid);
let mut current = self_pid;
for _ in 0..10 {
if let Some(parent) = sys.process(current).and_then(|p| p.parent()) {
pids.insert(parent);
current = parent;
} else {
break;
}
}
pids
}
#[derive(Debug, Clone)]
pub struct RawText {
pub name: String,
pub exe_path: Option<String>,
pub command: Option<String>,
pub cwd: Option<String>,
}
pub(crate) fn classify_sysinfo(pattern: &str, proc: &sysinfo::Process, mode: NameMatch) -> Hit {
let name = proc.name().to_string_lossy();
let exe = proc.exe().map(|exe| exe.to_string_lossy());
let argv0 = proc.cmd().first().map(|arg| arg.to_string_lossy());
let command = (!proc.cmd().is_empty()).then(|| {
proc.cmd()
.iter()
.map(|arg| arg.to_string_lossy())
.collect::<Vec<_>>()
.join(" ")
});
classify(
pattern,
&Names {
name: &name,
exe: exe.as_deref(),
argv0: argv0.as_deref(),
command: command.as_deref(),
},
mode,
)
}
impl Process {
pub fn names(&self) -> Names<'_> {
match &self.raw {
Some(raw) => Names {
name: &raw.name,
exe: raw.exe_path.as_deref(),
argv0: self.argv0.as_deref(),
command: raw.command.as_deref(),
},
None => Names {
name: &self.name,
exe: self.exe_path.as_deref(),
argv0: self.argv0.as_deref(),
command: self.command.as_deref(),
},
}
}
pub fn raw_cwd(&self) -> Option<&str> {
match &self.raw {
Some(raw) => raw.cwd.as_deref(),
None => self.cwd.as_deref(),
}
}
pub fn find_by_name(pattern: &str, mode: NameMatch) -> Result<Vec<Process>> {
let mut sys = System::new_all();
sys.refresh_all();
let ancestor_pids = self_and_ancestors(&sys);
let mut in_command_line_only = 0;
let mut processes: Vec<Process> = sys
.processes()
.iter()
.filter_map(|(pid, proc)| {
if ancestor_pids.contains(pid) || is_thread(proc) {
return None;
}
match classify_sysinfo(pattern, proc, mode) {
Hit::Match => Some(Process::from_sysinfo(*pid, proc)),
Hit::CommandLineOnly => {
in_command_line_only += 1;
None
}
Hit::No => None,
}
})
.collect();
processes.sort_by_key(|p| p.pid);
if processes.is_empty() {
return Err(ProcError::name_not_found(pattern, in_command_line_only));
}
Ok(processes)
}
pub fn find_by_pid(pid: u32) -> Result<Option<Process>> {
let mut sys = System::new_all();
sys.refresh_all();
let sysinfo_pid = Pid::from_u32(pid);
Ok(sys
.processes()
.get(&sysinfo_pid)
.map(|proc| Process::from_sysinfo(sysinfo_pid, proc)))
}
pub fn find_all() -> Result<Vec<Process>> {
let mut sys = System::new_all();
sys.refresh_all();
let self_pid = sysinfo::Pid::from_u32(std::process::id());
#[allow(unused_mut)]
let mut processes: Vec<Process> = sys
.processes()
.iter()
.filter(|(pid, proc)| **pid != self_pid && !is_thread(proc))
.map(|(pid, proc)| Process::from_sysinfo(*pid, proc))
.collect();
#[cfg(target_os = "macos")]
{
let known: std::collections::HashSet<u32> = processes.iter().map(|p| p.pid).collect();
processes.extend(macos_zombies(&known));
}
processes.sort_by_key(|p| p.pid);
Ok(processes)
}
pub fn find_by_exe_path(path: &std::path::Path) -> Result<Vec<Process>> {
let all = Self::find_all()?;
let path_str = path.to_string_lossy();
Ok(all
.into_iter()
.filter(|p| {
if let Some(ref exe) = p.exe_path {
exe == &*path_str || std::path::Path::new(exe) == path
} else {
false
}
})
.collect())
}
#[cfg(unix)]
pub fn find_by_open_file(path: &std::path::Path) -> Result<Vec<Process>> {
Self::find_by_open_file_with("lsof", path)
}
#[cfg(unix)]
fn find_by_open_file_with(lsof: &str, path: &std::path::Path) -> Result<Vec<Process>> {
use std::process::Command;
let output = crate::ui::interrupt::unblocked_in_child(&mut Command::new(lsof))
.args(["-t", &path.to_string_lossy()]) .output();
let output = match output {
Ok(o) => o,
Err(_) => return Ok(vec![]), };
if !output.status.success() {
return Ok(vec![]); }
let pids: Vec<u32> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.trim().parse().ok())
.collect();
let mut processes = Vec::new();
for pid in pids {
if let Ok(Some(proc)) = Self::find_by_pid(pid) {
processes.push(proc);
}
}
Ok(processes)
}
#[cfg(not(unix))]
pub fn find_by_open_file(_path: &std::path::Path) -> Result<Vec<Process>> {
Ok(vec![])
}
pub fn find_stuck(timeout: Duration) -> Result<Vec<Process>> {
let timeout_secs = timeout.as_secs();
let mut read_at = std::time::Instant::now();
let mut sys = System::new_all();
let mut used = cpu_used(&sys);
let mut cpu: std::collections::HashMap<Pid, f32> = std::collections::HashMap::new();
let mut stuck: Option<std::collections::HashSet<Pid>> = None;
for _ in 0..STUCK_READINGS {
std::thread::sleep(STUCK_READING_GAP);
let started = std::time::Instant::now();
sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
let wall = started - read_at;
read_at = started;
cpu = sys
.processes()
.iter()
.filter_map(|(pid, p)| {
let before = used.get(pid)?;
Some((
*pid,
cpu_percent_between(*before, p.accumulated_cpu_time(), wall),
))
})
.collect();
used = cpu_used(&sys);
let now: std::collections::HashSet<Pid> = sys
.processes()
.iter()
.filter(|(pid, p)| {
let busy = cpu.get(pid).copied().unwrap_or(0.0);
counts_as_stuck(
is_thread(p),
p.run_time(),
timeout_secs,
looks_stuck(busy, p.status()),
)
})
.map(|(pid, _)| *pid)
.collect();
stuck = Some(match stuck {
None => now,
Some(before) => before.intersection(&now).copied().collect(),
});
}
let stuck = stuck.unwrap_or_default();
let mut found: Vec<Process> = sys
.processes()
.iter()
.filter(|(pid, _)| stuck.contains(pid))
.map(|(pid, p)| {
let mut process = Process::from_sysinfo(*pid, p);
if let Some(&busy) = cpu.get(pid) {
process.cpu_percent = busy;
}
process
})
.collect();
found.sort_by_key(|p| p.pid);
Ok(found)
}
pub fn looks_stuck_now(&self) -> bool {
let pid = Pid::from_u32(self.pid);
let mut sys = System::new();
let mut read_at = std::time::Instant::now();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
let mut used = sys
.process(pid)
.filter(|p| self.is_same(p))
.map(|p| p.accumulated_cpu_time());
(0..STUCK_READINGS).all(|_| {
std::thread::sleep(STUCK_READING_GAP);
let started = std::time::Instant::now();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
let wall = started - read_at;
read_at = started;
let (Some(before), Some(p)) = (used, sys.process(pid).filter(|p| self.is_same(p)))
else {
return false;
};
let after = p.accumulated_cpu_time();
used = Some(after);
looks_stuck(cpu_percent_between(before, after, wall), p.status())
})
}
#[cfg(unix)]
pub fn cpu_over(&self, window: Duration) -> Option<f32> {
let pid = Pid::from_u32(self.pid);
let mut sys = System::new();
let started = std::time::Instant::now();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
let before = sys
.process(pid)
.filter(|p| self.is_same(p))
.map(|p| p.accumulated_cpu_time())?;
std::thread::sleep(window.max(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL));
let wall = started.elapsed();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
sys.process(pid)
.filter(|p| self.is_same(p))
.map(|p| cpu_percent_between(before, p.accumulated_cpu_time(), wall))
}
pub fn measure_cpu(processes: &mut [Process]) {
if processes.is_empty() {
return;
}
let pids: Vec<Pid> = processes.iter().map(|p| Pid::from_u32(p.pid)).collect();
let which = sysinfo::ProcessesToUpdate::Some(&pids);
let cpu_only = sysinfo::ProcessRefreshKind::nothing().with_cpu();
let mut sys = System::new();
let first = std::time::Instant::now();
sys.refresh_processes_specifics(which, true, cpu_only);
let before = cpu_used(&sys);
std::thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL);
let wall = first.elapsed();
sys.refresh_processes_specifics(which, true, cpu_only);
for process in processes.iter_mut() {
let pid = Pid::from_u32(process.pid);
let (Some(current), Some(&cpu_before)) = (sys.process(pid), before.get(&pid)) else {
continue;
};
if process.is_same(current) {
process.cpu_percent =
cpu_percent_between(cpu_before, current.accumulated_cpu_time(), wall);
}
}
}
pub fn kill(&self) -> Result<()> {
let mut sys = System::new();
sys.refresh_processes(
sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(self.pid)]),
true,
);
match sys.process(Pid::from_u32(self.pid)) {
Some(proc) if self.is_same(proc) => kill_outcome(proc.kill(), self.pid),
_ => Err(ProcError::ProcessNotFound(self.pid.to_string())),
}
}
fn is_same(&self, current: &sysinfo::Process) -> bool {
match self.start_time {
Some(ours) if current.start_time() > 0 => current.start_time().abs_diff(ours) <= 1,
_ => true,
}
}
fn ensure_same_process(&self) -> Result<()> {
let mut sys = System::new();
let pid = Pid::from_u32(self.pid);
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
match sys.process(pid) {
Some(current) if self.is_same(current) => Ok(()),
_ => Err(ProcError::ProcessNotFound(self.pid.to_string())),
}
}
pub fn kill_and_wait(&self) -> Result<()> {
self.kill_and_wait_within(Duration::from_secs(5))
}
fn kill_and_wait_within(&self, wait: Duration) -> Result<()> {
self.kill_then_wait(wait, Process::kill)
}
fn kill_then_wait(&self, wait: Duration, kill: impl Fn(&Process) -> Result<()>) -> Result<()> {
match kill(self) {
Err(ProcError::ProcessNotFound(_)) => return Ok(()),
other => other?,
}
let deadline = std::time::Instant::now() + wait;
while std::time::Instant::now() < deadline {
if !self.is_running() {
return Ok(());
}
std::thread::sleep(Duration::from_millis(50));
}
Err(ProcError::SignalError(format!(
"Process {} is still running {} seconds after SIGKILL",
self.pid,
wait.as_secs()
)))
}
#[cfg(unix)]
pub fn terminate(&self) -> Result<()> {
use nix::sys::signal::{kill, Signal};
use nix::unistd::Pid as NixPid;
self.ensure_same_process()?;
kill(NixPid::from_raw(self.pid as i32), Signal::SIGTERM)
.map_err(|e| ProcError::SignalError(e.to_string()))
}
#[cfg(windows)]
pub fn terminate(&self) -> Result<()> {
use std::process::Command;
self.ensure_same_process()?;
let output = Command::new("taskkill")
.args(["/PID", &self.pid.to_string()])
.output()
.map_err(|e| ProcError::SystemError(e.to_string()))?;
taskkill_outcome(&output)
}
#[cfg(unix)]
pub fn send_signal(&self, signal: nix::sys::signal::Signal) -> Result<()> {
use nix::sys::signal::kill;
use nix::unistd::Pid as NixPid;
self.ensure_same_process()?;
kill(NixPid::from_raw(self.pid as i32), signal)
.map_err(|e| ProcError::SignalError(format!("{}: {}", signal, e)))
}
#[cfg(unix)]
pub fn find_orphans() -> Result<Vec<Process>> {
Ok(Self::orphans_among(Self::find_all()?))
}
#[cfg(not(unix))]
pub fn find_orphans() -> Result<Vec<Process>> {
Err(orphans_not_supported())
}
#[cfg(unix)]
fn orphans_among(all: Vec<Process>) -> Vec<Process> {
all.into_iter()
.filter(|p| {
if let Some(ppid) = p.parent_pid {
ppid == 1 && p.pid != 1 && !Self::is_system_process(p)
} else {
false
}
})
.collect()
}
#[cfg(unix)]
fn is_system_process(p: &Process) -> bool {
if p.cwd.is_none() || p.cwd.as_deref() == Some("/") {
if let Some(ref exe) = p.exe_path {
if exe.starts_with("/System/") || exe.starts_with("/usr/libexec/") {
return true;
}
if exe.starts_with("/usr/sbin/")
|| exe.starts_with("/sbin/")
|| exe.starts_with("/usr/bin/")
|| exe.starts_with("/usr/lib/")
|| exe.starts_with("/usr/lib64/")
|| exe.starts_with("/lib/")
|| exe.starts_with("/lib64/")
|| exe.starts_with("/opt/")
|| exe.starts_with("/snap/")
{
return true;
}
}
return true; }
false
}
pub fn exists(&self) -> bool {
let mut sys = System::new();
let pid = Pid::from_u32(self.pid);
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
match sys.process(pid) {
None => false,
Some(current) if current.status() == SysProcessStatus::Zombie => false,
Some(current) => self.is_same(current),
}
}
pub fn is_running(&self) -> bool {
self.exists()
}
pub(crate) fn from_sysinfo(pid: Pid, proc: &sysinfo::Process) -> Self {
let cmd_vec = proc.cmd();
let raw_command = (!cmd_vec.is_empty()).then(|| {
cmd_vec
.iter()
.map(|s| s.to_string_lossy())
.collect::<Vec<_>>()
.join(" ")
});
let raw_name = proc.name().to_string_lossy();
let raw_exe = proc.exe().map(|p| p.to_string_lossy());
let raw_cwd = proc
.cwd()
.map(|p| strip_deleted_marker(p.to_string_lossy().to_string()));
let changed = |text: &str| matches!(escape_control(text), std::borrow::Cow::Owned(_));
let raw = (changed(&raw_name)
|| raw_exe.as_deref().is_some_and(changed)
|| raw_command.as_deref().is_some_and(changed)
|| raw_cwd.as_deref().is_some_and(changed))
.then(|| {
Box::new(RawText {
name: raw_name.to_string(),
exe_path: raw_exe.as_ref().map(|exe| exe.to_string()),
command: raw_command.clone(),
cwd: raw_cwd.clone(),
})
});
let command = raw_command
.as_deref()
.map(|joined| escape_control(joined).into_owned());
let exe_path = raw_exe
.as_deref()
.map(|exe| escape_control(exe).into_owned());
let cwd = raw_cwd
.as_deref()
.map(|path| escape_control(path).into_owned());
Process {
pid: pid.as_u32(),
name: escape_control(&raw_name).into_owned(),
exe_path,
cwd,
command,
argv0: cmd_vec
.first()
.map(|arg| arg.to_string_lossy().into_owned()),
raw,
cpu_percent: proc.cpu_usage(),
memory_mb: proc.memory() as f64 / 1024.0 / 1024.0,
status: ProcessStatus::from(proc.status()),
user: proc
.user_id()
.map(|uid| escape_control(&user_name(uid)).into_owned()),
parent_pid: proc.parent().map(|p| p.as_u32()),
start_time: (proc.start_time() > 0).then(|| proc.start_time()),
uptime_secs: (proc.start_time() > 0).then(|| proc.run_time()),
}
}
}
#[cfg(unix)]
pub fn parse_signal_name(name: &str) -> Result<nix::sys::signal::Signal> {
use nix::sys::signal::Signal;
let upper = name.to_uppercase();
let upper = upper.trim_start_matches("SIG");
match upper {
"HUP" => Ok(Signal::SIGHUP),
"INT" => Ok(Signal::SIGINT),
"QUIT" => Ok(Signal::SIGQUIT),
"ABRT" => Ok(Signal::SIGABRT),
"KILL" => Ok(Signal::SIGKILL),
"TERM" => Ok(Signal::SIGTERM),
"STOP" => Ok(Signal::SIGSTOP),
"CONT" => Ok(Signal::SIGCONT),
"USR1" => Ok(Signal::SIGUSR1),
"USR2" => Ok(Signal::SIGUSR2),
_ => Err(ProcError::InvalidInput(format!(
"Unknown signal: '{}'. Valid signals: HUP, INT, QUIT, ABRT, KILL, TERM, STOP, CONT, USR1, USR2",
name
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cpu_is_the_cpu_time_used_over_the_wall_time() {
let second = Duration::from_secs(1);
assert_eq!(cpu_percent_between(5_000, 6_000, second), 100.0);
assert_eq!(cpu_percent_between(5_000, 5_500, second), 50.0);
assert_eq!(cpu_percent_between(5_000, 5_000, second), 0.0);
assert_eq!(cpu_percent_between(0, 2_000, second), 200.0);
assert_eq!(cpu_percent_between(6_000, 5_000, second), 0.0);
assert_eq!(cpu_percent_between(0, 1_000, Duration::ZERO), 0.0);
}
#[test]
fn a_failed_command_is_explained_in_its_own_words() {
let reason = "ERROR: The process with PID 7 could not be terminated.";
assert_eq!(
command_failure(b"", reason.as_bytes(), "exit code: 1"),
reason
);
assert_eq!(
command_failure(b" not here\r\n", b"", "exit code: 1"),
"not here"
);
assert_eq!(
command_failure(
b"",
b"ERROR: first part\r\n second part.\r\n\r\n",
"exit code: 1"
),
"ERROR: first part second part."
);
assert_eq!(
command_failure(b"", b" \n", "exit code: 128"),
"exited with exit code: 128"
);
}
#[test]
fn busy_means_over_half_a_core_or_blocked_in_the_kernel() {
assert!(looks_stuck(50.1, SysProcessStatus::Run));
assert!(!looks_stuck(50.0, SysProcessStatus::Run));
assert!(!looks_stuck(0.0, SysProcessStatus::Sleep));
assert!(looks_stuck(0.0, SysProcessStatus::UninterruptibleDiskSleep));
}
#[test]
fn a_reading_counts_as_stuck_from_the_timeout_on() {
assert!(counts_as_stuck(false, 0, 0, true));
assert!(!counts_as_stuck(false, 59, 60, true));
assert!(counts_as_stuck(false, 60, 60, true));
assert!(counts_as_stuck(false, 61, 60, true));
assert!(!counts_as_stuck(false, 60, 60, false));
assert!(!counts_as_stuck(true, 60, 60, true));
}
#[test]
fn a_process_that_ran_the_timeout_has_run_long_enough() {
assert!(ran_long_enough(0, 0));
assert!(ran_long_enough(5, 5));
assert!(ran_long_enough(6, 5));
assert!(!ran_long_enough(4, 5));
}
#[test]
fn test_find_all_processes() {
let processes = Process::find_all().unwrap();
assert!(!processes.is_empty(), "Should find at least one process");
}
#[test]
fn every_process_source_lists_pid_ascending() {
use crate::test_support::{end, idle_child, record_of};
let mut children: Vec<_> = (0..3).map(|_| idle_child()).collect();
let mine: Vec<u32> = children.iter().map(|c| record_of(c).pid).collect();
let name = record_of(&children[0]).name;
let ascending = |what: &str, list: &[Process]| {
let pids: Vec<u32> = list.iter().map(|p| p.pid).collect();
let mut sorted = pids.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(pids, sorted, "{what} is not PID ascending");
pids
};
let all = ascending("find_all", &Process::find_all().unwrap());
let named = ascending(
"find_by_name",
&Process::find_by_name(&name, NameMatch::Exact).unwrap(),
);
ascending("find_stuck", &Process::find_stuck(Duration::ZERO).unwrap());
for pid in &mine {
assert!(all.contains(pid), "find_all has {pid}");
assert!(named.contains(pid), "find_by_name has {pid}");
}
children.iter_mut().for_each(end);
}
#[test]
fn test_find_by_pid_self() {
let pid = std::process::id();
let process = Process::find_by_pid(pid).unwrap();
assert!(process.is_some(), "Should find own process");
}
#[test]
fn test_is_running_tells_a_reused_pid_apart() {
let mut me = Process::find_by_pid(std::process::id()).unwrap().unwrap();
assert!(me.is_running());
let start = me.start_time.expect("a running process has a start time");
me.start_time = Some(start - 3600);
assert!(!me.is_running(), "a reused PID counted as the same process");
}
#[test]
fn test_escape_control_makes_terminal_input_harmless() {
assert_eq!(escape_control("node server.js"), "node server.js");
assert_eq!(escape_control("café ✓ 日本"), "café ✓ 日本");
assert_eq!(escape_control("a\x1b[31mb"), "a\\x1b[31mb");
assert_eq!(escape_control("\x1b]0;pwned\x07"), "\\x1b]0;pwned\\x07");
assert_eq!(escape_control("one\ntwo\tthree\r"), "one\\ntwo\\tthree\\r");
assert_eq!(escape_control("x\x7fy\u{9b}2J"), "x\\x7fy\\x9b2J");
assert_eq!(escape_control("ab\u{202e}cd"), "ab\\u{202e}cd");
}
#[cfg(unix)]
#[test]
fn test_looks_stuck_needs_sustained_cpu() {
let spawn = |script: &str| {
std::process::Command::new("sh")
.args(["-c", script])
.spawn()
.unwrap()
};
let mut spinning = spawn("while :; do :; done");
let mut idle = spawn("exec sleep 300");
std::thread::sleep(Duration::from_millis(300));
let record = |pid: u32| Process::find_by_pid(pid).unwrap().unwrap();
let spinning_stuck = record(spinning.id()).looks_stuck_now();
let idle_stuck = record(idle.id()).looks_stuck_now();
for child in [&mut spinning, &mut idle] {
let _ = child.kill();
let _ = child.wait();
}
assert!(
spinning_stuck,
"a process spinning at 100% CPU wasn't stuck"
);
assert!(!idle_stuck, "an idle process counted as stuck");
}
#[test]
fn test_signals_never_reach_a_process_that_reused_the_pid() {
let mut child = crate::test_support::idle_child();
let mut stale = crate::test_support::record_of(&child);
stale.start_time = stale.start_time.map(|t| t - 3600);
let kill = stale.kill();
let term = stale.terminate();
#[cfg(unix)]
let sig = stale.send_signal(nix::sys::signal::Signal::SIGKILL);
let alive = child.try_wait().unwrap().is_none();
let _ = child.kill();
let _ = child.wait();
assert!(
alive,
"a stale record signalled the process now using its PID"
);
assert!(
matches!(kill, Err(ProcError::ProcessNotFound(_))),
"{kill:?}"
);
assert!(
matches!(term, Err(ProcError::ProcessNotFound(_))),
"{term:?}"
);
#[cfg(unix)]
assert!(matches!(sig, Err(ProcError::ProcessNotFound(_))), "{sig:?}");
}
#[cfg(unix)]
#[test]
fn text_with_control_characters_matches_as_typed_and_displays_escaped() {
use crate::core::matches_by_filter;
use std::os::unix::process::CommandExt;
let word = format!("zz\u{7}ctl{}x", std::process::id());
let mut child = std::process::Command::new("sleep")
.arg0(&word)
.arg("300")
.spawn()
.unwrap();
let pid = child.id();
let record = std::iter::repeat_with(|| {
std::thread::sleep(Duration::from_millis(5));
Process::find_by_pid(pid).unwrap().unwrap()
})
.take(1000)
.find(|record| record.argv0.is_some());
let part = format!("\u{7}ctl{}", std::process::id());
let found = |pattern: &str, mode| {
Process::find_by_name(pattern, mode)
.map(|found| found.iter().any(|p| p.pid == pid))
.unwrap_or(false)
};
let named = found(&word, NameMatch::Exact);
let named_in_command = found(&part, NameMatch::Command);
let _ = child.kill();
let _ = child.wait();
let record = record.expect("the child's command line never appeared");
assert!(named, "find_by_name read the escaped first argument");
assert!(
named_in_command,
"find_by_name read the escaped command line"
);
assert!(
matches_by_filter(&record, &word, NameMatch::Exact),
"--by read the escaped first argument"
);
assert!(
matches_by_filter(&record, &part, NameMatch::Command),
"--by --cmd read the escaped command line"
);
let shown = record.command.as_deref().unwrap();
assert!(shown.contains("\\x07ctl"), "{shown}");
assert!(!shown.contains('\u{7}'), "{shown:?}");
}
#[cfg(windows)]
#[test]
fn a_control_character_in_a_windows_command_line_matches_as_typed_and_displays_escaped() {
use crate::core::matches_by_filter;
let mut exe = std::env::current_exe().unwrap();
exe.pop();
exe.pop();
exe.push("examples");
exe.push("listener.exe");
assert!(exe.exists(), "build the example first: {}", exe.display());
let part = format!("\u{7}ctl{}", std::process::id());
let mut child = std::process::Command::new(&exe)
.arg(&part)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.spawn()
.unwrap();
let pid = child.id();
let record = std::iter::repeat_with(|| {
std::thread::sleep(Duration::from_millis(20));
Process::find_by_pid(pid).unwrap()
})
.take(500)
.flatten()
.find(|record| record.command.is_some())
.expect("the child's command line never appeared");
let found = Process::find_by_name(&part, NameMatch::Command)
.map(|found| found.iter().any(|p| p.pid == pid))
.unwrap_or(false);
let kept = matches_by_filter(&record, &part, NameMatch::Command);
crate::test_support::end(&mut child);
assert!(found, "find_by_name read the escaped command line");
assert!(kept, "--by --cmd read the escaped command line");
let shown = record.command.as_deref().unwrap();
assert!(shown.contains("\\x07ctl"), "{shown}");
assert!(!shown.contains('\u{7}'), "{shown:?}");
}
#[test]
fn a_name_is_read_from_the_raw_text_when_escaping_changed_it() {
let record = Process {
name: "a\\x07b".to_string(),
raw: Some(Box::new(RawText {
name: "a\u{7}b".to_string(),
exe_path: Some("c\u{7}d".to_string()),
command: Some("e\u{7}f".to_string()),
cwd: None,
})),
argv0: Some("g".to_string()),
..ghost()
};
let names = record.names();
assert_eq!(names.name, "a\u{7}b");
assert_eq!(names.exe, Some("c\u{7}d"));
assert_eq!(names.argv0, Some("g"));
assert_eq!(names.command, Some("e\u{7}f"));
}
#[test]
fn the_ancestors_stop_where_the_process_list_does() {
let me = Pid::from_u32(std::process::id());
let mut sys = System::new();
sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[me]), true);
let parent = sys.process(me).and_then(|p| p.parent());
let expected: std::collections::HashSet<Pid> = std::iter::once(me).chain(parent).collect();
assert_eq!(self_and_ancestors(&sys), expected);
}
#[test]
fn test_strip_deleted_marker() {
assert_eq!(
strip_deleted_marker("/nonexistent/proc-gone (deleted)".to_string()),
"/nonexistent/proc-gone"
);
assert_eq!(
strip_deleted_marker("/tmp/project".to_string()),
"/tmp/project"
);
let real = std::env::temp_dir().join(format!("proc-real-{} (deleted)", std::process::id()));
std::fs::create_dir_all(&real).unwrap();
let path = real.to_string_lossy().to_string();
assert_eq!(strip_deleted_marker(path.clone()), path);
std::fs::remove_dir(&real).unwrap();
}
#[test]
fn a_name_nothing_has_is_plain_not_found_in_both_modes() {
for mode in [NameMatch::Exact, NameMatch::Command] {
let result = Process::find_by_name("nonexistent_process_12345", mode);
assert!(
matches!(&result, Err(ProcError::ProcessNotFound(n)) if n == "nonexistent_process_12345"),
"{result:?}"
);
}
}
#[cfg(unix)]
fn waiting_shell_with(marker: &str) -> std::process::Child {
std::process::Command::new("sh")
.args(["-c", &format!("read line; : {marker}")])
.stdin(std::process::Stdio::piped())
.spawn()
.unwrap()
}
#[cfg(unix)]
#[test]
fn a_word_only_in_a_command_line_is_found_with_cmd_and_hinted_without() {
let marker = format!("hintmarker{}", std::process::id());
let mut child = waiting_shell_with(&marker);
let pid = child.id();
let exact = Process::find_by_name(&marker, NameMatch::Exact);
let cmd = Process::find_by_name(&marker, NameMatch::Command);
let _ = child.kill();
let _ = child.wait();
assert!(
matches!(&exact, Err(ProcError::NameNotFound { name, in_command_line })
if name == &marker && *in_command_line >= 1),
"{exact:?}"
);
let found = cmd.unwrap();
assert_eq!(found.iter().map(|p| p.pid).collect::<Vec<_>>(), vec![pid]);
}
#[cfg(unix)]
#[test]
fn test_find_orphans_returns_ok() {
let result = Process::find_orphans();
assert!(result.is_ok());
}
#[cfg(not(unix))]
#[test]
fn find_orphans_is_not_supported_on_windows() {
let result = Process::find_orphans();
assert!(
matches!(&result, Err(ProcError::NotSupported(m)) if m.starts_with("orphans is not supported on Windows")),
"{result:?}"
);
}
#[cfg(unix)]
#[test]
fn test_is_system_process_system_paths() {
let make_proc = |exe: Option<&str>, cwd: Option<&str>| Process {
pid: 100,
name: "test".to_string(),
exe_path: exe.map(String::from),
cwd: cwd.map(String::from),
command: None,
argv0: None,
raw: None,
cpu_percent: 0.0,
memory_mb: 0.0,
status: ProcessStatus::Running,
user: None,
parent_pid: Some(1),
start_time: None,
uptime_secs: None,
};
assert!(Process::is_system_process(&make_proc(
Some("/usr/sbin/sshd"),
Some("/")
)));
assert!(Process::is_system_process(&make_proc(
Some("/System/Library/foo"),
Some("/")
)));
assert!(Process::is_system_process(&make_proc(
Some("/usr/bin/systemd"),
Some("/")
)));
assert!(Process::is_system_process(&make_proc(
Some("/usr/lib/snapd/snapd"),
Some("/")
)));
assert!(Process::is_system_process(&make_proc(None, Some("/"))));
assert!(Process::is_system_process(&make_proc(
Some("/usr/bin/foo"),
None
)));
for exe in [
"/System/x",
"/usr/libexec/x",
"/usr/sbin/x",
"/sbin/x",
"/usr/bin/x",
"/usr/lib/x",
"/usr/lib64/x",
"/lib/x",
"/lib64/x",
"/opt/x",
"/snap/x",
] {
assert!(
Process::is_system_process(&make_proc(Some(exe), Some("/"))),
"{exe}"
);
}
assert!(Process::is_system_process(&make_proc(
Some("/srv/x"),
Some("/")
)));
assert!(!Process::is_system_process(&make_proc(
Some("/usr/bin/node"),
Some("/home/user/project")
)));
assert!(!Process::is_system_process(&make_proc(
Some("/home/user/.local/bin/app"),
Some("/home/user")
)));
}
#[cfg(unix)]
#[test]
fn test_parse_signal_name_valid() {
use nix::sys::signal::Signal;
assert_eq!(parse_signal_name("HUP").unwrap(), Signal::SIGHUP);
assert_eq!(parse_signal_name("hup").unwrap(), Signal::SIGHUP);
assert_eq!(parse_signal_name("SIGHUP").unwrap(), Signal::SIGHUP);
assert_eq!(parse_signal_name("sighup").unwrap(), Signal::SIGHUP);
assert_eq!(parse_signal_name("INT").unwrap(), Signal::SIGINT);
assert_eq!(parse_signal_name("QUIT").unwrap(), Signal::SIGQUIT);
assert_eq!(parse_signal_name("ABRT").unwrap(), Signal::SIGABRT);
assert_eq!(parse_signal_name("KILL").unwrap(), Signal::SIGKILL);
assert_eq!(parse_signal_name("TERM").unwrap(), Signal::SIGTERM);
assert_eq!(parse_signal_name("STOP").unwrap(), Signal::SIGSTOP);
assert_eq!(parse_signal_name("CONT").unwrap(), Signal::SIGCONT);
assert_eq!(parse_signal_name("USR1").unwrap(), Signal::SIGUSR1);
assert_eq!(parse_signal_name("USR2").unwrap(), Signal::SIGUSR2);
}
#[cfg(unix)]
#[test]
fn test_parse_signal_name_invalid() {
assert!(parse_signal_name("INVALID").is_err());
assert!(parse_signal_name("FOO").is_err());
assert!(parse_signal_name("").is_err());
}
#[cfg(unix)]
#[test]
fn test_parse_signal_name_case_insensitive() {
use nix::sys::signal::Signal;
assert_eq!(parse_signal_name("term").unwrap(), Signal::SIGTERM);
assert_eq!(parse_signal_name("Term").unwrap(), Signal::SIGTERM);
assert_eq!(parse_signal_name("TERM").unwrap(), Signal::SIGTERM);
assert_eq!(parse_signal_name("sigterm").unwrap(), Signal::SIGTERM);
assert_eq!(parse_signal_name("SigTerm").unwrap(), Signal::SIGTERM);
}
#[cfg(unix)]
#[test]
fn test_send_signal_nonexistent_process() {
use nix::sys::signal::Signal;
let proc = Process {
pid: 99999999,
name: "ghost".to_string(),
exe_path: None,
cwd: None,
command: None,
argv0: None,
raw: None,
cpu_percent: 0.0,
memory_mb: 0.0,
status: ProcessStatus::Running,
user: None,
parent_pid: None,
start_time: None,
uptime_secs: None,
};
let result = proc.send_signal(Signal::SIGCONT);
assert!(result.is_err());
}
fn ghost() -> Process {
Process {
pid: 99999999,
name: "ghost".to_string(),
exe_path: None,
cwd: None,
command: None,
argv0: None,
raw: None,
cpu_percent: 0.0,
memory_mb: 0.0,
status: ProcessStatus::Running,
user: None,
parent_pid: None,
start_time: None,
uptime_secs: None,
}
}
#[test]
fn every_sysinfo_status_maps_to_ours() {
for (sys, ours) in [
(SysProcessStatus::Run, ProcessStatus::Running),
(SysProcessStatus::Sleep, ProcessStatus::Sleeping),
(SysProcessStatus::Stop, ProcessStatus::Stopped),
(SysProcessStatus::Zombie, ProcessStatus::Zombie),
(SysProcessStatus::Dead, ProcessStatus::Dead),
(SysProcessStatus::Idle, ProcessStatus::Unknown),
] {
assert_eq!(ProcessStatus::from(sys), ours, "{sys:?}");
}
}
#[test]
fn a_process_that_is_gone_does_not_look_stuck() {
assert!(!ghost().looks_stuck_now());
}
#[test]
fn killing_a_process_that_is_already_gone_is_success() {
assert!(matches!(ghost().kill(), Err(ProcError::ProcessNotFound(_))));
ghost().kill_and_wait().unwrap();
}
#[test]
fn a_kill_that_was_not_delivered_is_an_error() {
kill_outcome(true, 7).unwrap();
let err = kill_outcome(false, 7).unwrap_err();
assert!(
matches!(&err, ProcError::SignalError(m) if m == "Failed to kill process 7"),
"{err:?}"
);
}
#[test]
fn taskkill_without_force_reports_what_it_said() {
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
#[cfg(windows)]
use std::os::windows::process::ExitStatusExt;
use std::process::{ExitStatus, Output};
let output = |raw, stdout: &str, stderr: &str| Output {
status: ExitStatus::from_raw(raw),
stdout: stdout.as_bytes().to_vec(),
stderr: stderr.as_bytes().to_vec(),
};
taskkill_outcome(&output(0, "SUCCESS: sent termination signal", "")).unwrap();
#[cfg(unix)]
let failed = 1 << 8;
#[cfg(windows)]
let failed = 1;
let err =
taskkill_outcome(&output(failed, "", "ERROR: could not be terminated")).unwrap_err();
assert!(
matches!(&err, ProcError::SignalError(m) if m == "ERROR: could not be terminated"),
"{err:?}"
);
}
#[cfg(unix)]
#[test]
fn orphans_are_the_children_of_init_that_are_not_system_daemons() {
let proc = |pid: u32, ppid: Option<u32>, exe: Option<&str>, cwd: Option<&str>| Process {
pid,
name: format!("p{pid}"),
exe_path: exe.map(String::from),
cwd: cwd.map(String::from),
parent_pid: ppid,
..ghost()
};
let orphans = Process::orphans_among(vec![
proc(10, Some(1), Some("/home/z/app"), Some("/home/z")),
proc(11, Some(1), Some("/usr/sbin/sshd"), Some("/")),
proc(1, Some(1), Some("/sbin/init"), Some("/home/z")),
proc(12, Some(500), Some("/home/z/app"), Some("/home/z")),
proc(13, None, Some("/home/z/app"), Some("/home/z")),
]);
let pids: Vec<u32> = orphans.iter().map(|p| p.pid).collect();
assert_eq!(pids, vec![10]);
}
#[cfg(unix)]
#[test]
fn a_missing_lsof_finds_no_open_file_owners() {
let found = Process::find_by_open_file_with("proc-no-such-lsof", std::path::Path::new("/"));
assert!(found.unwrap().is_empty());
}
#[test]
fn a_wait_for_a_process_that_stays_is_a_wait_and_then_an_error() {
let mut child = crate::test_support::idle_child();
let record = crate::test_support::record_of(&child);
let started = std::time::Instant::now();
let result = record.kill_then_wait(Duration::from_millis(300), |_| Ok(()));
let waited = started.elapsed();
let still_there = child.try_wait().unwrap().is_none();
crate::test_support::end(&mut child);
assert!(
matches!(&result, Err(ProcError::SignalError(_))),
"{result:?}"
);
assert!(waited >= Duration::from_millis(300), "{waited:?}");
assert!(still_there);
}
#[test]
fn a_process_still_alive_when_the_wait_ends_is_an_error() {
let mut child = crate::test_support::idle_child();
let record = crate::test_support::record_of(&child);
let result = record.kill_and_wait_within(Duration::ZERO);
let _ = child.kill();
let _ = child.wait();
assert!(
matches!(&result, Err(ProcError::SignalError(m))
if m == &format!("Process {} is still running 0 seconds after SIGKILL", record.pid)),
"{result:?}"
);
}
#[cfg(target_os = "macos")]
fn kinfo(pid: u32, stat: u8, comm: &str, ruid: u32, ppid: u32, start: u64) -> Vec<u8> {
let mut rec = vec![0u8; KINFO_PROC_SIZE];
rec[0..8].copy_from_slice(&start.to_ne_bytes());
rec[36] = stat;
rec[40..44].copy_from_slice(&pid.to_ne_bytes());
rec[243..243 + comm.len()].copy_from_slice(comm.as_bytes());
rec[392..396].copy_from_slice(&ruid.to_ne_bytes());
rec[560..564].copy_from_slice(&ppid.to_ne_bytes());
rec
}
#[cfg(target_os = "macos")]
#[test]
fn zombies_are_read_from_the_kernels_table() {
let known = std::collections::HashSet::from([30]);
let table: Vec<u8> = [
kinfo(10, 5, "defunct-app", 0, 99, 1_700_000_000),
kinfo(11, 5, "", 0, 1, 0),
kinfo(20, 2, "alive", 0, 1, 1_700_000_000),
kinfo(30, 5, "known", 0, 1, 1_700_000_000),
kinfo(0, 5, "kernel", 0, 0, 0),
]
.concat();
let zombies = zombies_in(&table, &known);
let pids: Vec<u32> = zombies.iter().map(|z| z.pid).collect();
assert_eq!(pids, vec![10, 11]);
let named = &zombies[0];
assert_eq!(named.name, "defunct-app");
assert_eq!(named.status, ProcessStatus::Zombie);
assert_eq!(named.parent_pid, Some(99));
assert_eq!(named.start_time, Some(1_700_000_000));
assert!(named.uptime_secs.unwrap() > 0);
assert_eq!(named.user.as_deref(), Some("root"));
let unnamed = &zombies[1];
assert_eq!(unnamed.name, "<defunct>");
assert_eq!(unnamed.start_time, None);
assert_eq!(unnamed.uptime_secs, None);
}
#[cfg(target_os = "macos")]
#[test]
fn a_table_of_an_unknown_layout_shows_no_zombies() {
let known = std::collections::HashSet::new();
let mut table = kinfo(10, 5, "z", 0, 1, 0);
table.push(0);
assert!(zombies_in(&table, &known).is_empty());
}
#[cfg(target_os = "macos")]
#[test]
fn a_refused_sysctl_gives_an_empty_table() {
use std::cell::Cell;
assert!(read_proc_table(|_, _| -1).is_empty());
let calls = Cell::new(0);
let table = read_proc_table(|_, size| {
calls.set(calls.get() + 1);
if calls.get() == 1 {
*size = 2 * KINFO_PROC_SIZE;
0
} else {
-1
}
});
assert_eq!(calls.get(), 2);
assert!(table.is_empty());
let calls = Cell::new(0);
let table = read_proc_table(|buf, size| {
calls.set(calls.get() + 1);
if calls.get() == 1 {
*size = 2 * KINFO_PROC_SIZE;
} else {
assert_eq!(*size, 66 * KINFO_PROC_SIZE, "room for 64 newcomers");
unsafe { buf.cast::<u8>().write(7) };
*size = KINFO_PROC_SIZE;
}
0
});
assert_eq!(table.len(), KINFO_PROC_SIZE);
assert_eq!(table[0], 7);
}
}