Skip to main content

proc_cli/core/
process.rs

1//! Cross-platform process abstraction
2//!
3//! Provides a unified interface for discovering and managing processes
4//! across macOS, Linux, and Windows.
5
6use crate::core::name_match::{classify, Hit, NameMatch, Names};
7use crate::error::{ProcError, Result};
8use serde::{Deserialize, Serialize};
9use std::sync::OnceLock;
10use std::time::Duration;
11#[cfg(windows)]
12use sysinfo::Users;
13use sysinfo::{Pid, ProcessStatus as SysProcessStatus, System, Uid};
14
15/// Resolve a user ID to its user name, falling back to the ID itself.
16///
17/// On Unix this asks the system's user database (getpwuid_r), as `ps` and
18/// `id` do: sysinfo's user list leaves out every account whose shell ends in
19/// `/false` (macOS's daemon accounts, many service accounts) and every ID from
20/// 65536 up (directory and container users). Each ID is looked up once per run.
21#[cfg(unix)]
22fn user_name(uid: &Uid) -> String {
23    use std::collections::HashMap;
24    use std::sync::Mutex;
25
26    static NAMES: OnceLock<Mutex<HashMap<u32, String>>> = OnceLock::new();
27    let id: u32 = **uid;
28    let mut names = NAMES.get_or_init(Default::default).lock().unwrap();
29    names
30        .entry(id)
31        .or_insert_with(|| {
32            nix::unistd::User::from_uid(nix::unistd::Uid::from_raw(id))
33                .ok()
34                .flatten()
35                .map(|user| user.name)
36                .unwrap_or_else(|| id.to_string())
37        })
38        .clone()
39}
40
41/// Resolve a user ID to its user name, falling back to the ID itself.
42/// The user list is read once per run.
43#[cfg(windows)]
44fn user_name(uid: &Uid) -> String {
45    static USERS: OnceLock<Users> = OnceLock::new();
46    USERS
47        .get_or_init(Users::new_with_refreshed_list)
48        .get_user_by_id(uid)
49        .map(|user| user.name().to_string())
50        .unwrap_or_else(|| uid.to_string())
51}
52
53/// Make text from other processes safe to print: control characters become
54/// visible escapes, as `lsof` and `ps` do. Names, command lines and paths are
55/// set by whoever runs a process, and printed raw an ESC sequence could
56/// recolour, retitle or rewrite the terminal of whoever runs proc (and a
57/// newline would split a table row). Applied where process data is read, so
58/// every view and the JSON show the same text.
59///
60/// Escaped: C0 controls and DEL (`\x1b`, with `\n`, `\r`, `\t` by name), C1
61/// controls (`\x9b`), and bidirectional overrides that reorder text on screen
62/// (`\u{202e}`). Everything else, non-ASCII included, is unchanged. Works on
63/// chars, never byte offsets, so it can't split a character.
64pub fn escape_control(text: &str) -> std::borrow::Cow<'_, str> {
65    fn needs_escape(c: char) -> bool {
66        c.is_control()
67            || matches!(c, '\u{200e}' | '\u{200f}' | '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')
68    }
69    if !text.chars().any(needs_escape) {
70        return std::borrow::Cow::Borrowed(text);
71    }
72    let mut out = String::with_capacity(text.len() + 8);
73    for c in text.chars() {
74        match c {
75            '\n' => out.push_str("\\n"),
76            '\r' => out.push_str("\\r"),
77            '\t' => out.push_str("\\t"),
78            c if c.is_control() => out.push_str(&format!("\\x{:02x}", c as u32)),
79            c if needs_escape(c) => out.push_str(&format!("\\u{{{:x}}}", c as u32)),
80            c => out.push(c),
81        }
82    }
83    std::borrow::Cow::Owned(out)
84}
85
86/// `stuck` and `unstick` take this many readings, this far apart, and a process
87/// must look stuck in every one
88const STUCK_READINGS: usize = 2;
89const STUCK_READING_GAP: Duration = Duration::from_secs(1);
90
91/// Stuck in one reading: busy (over half a core), or in uninterruptible sleep,
92/// blocked in the kernel (usually on I/O) where it can't even be interrupted
93fn looks_stuck(cpu_percent: f32, status: SysProcessStatus) -> bool {
94    cpu_percent > 50.0 || status == SysProcessStatus::UninterruptibleDiskSleep
95}
96
97/// CPU use in percent of one core: the CPU time a process used between two
98/// reads (`accumulated_cpu_time`, in ms) over the wall time between them.
99/// sysinfo's own `cpu_usage` can't be trusted across two reads: on Windows a
100/// process's first update stores no baseline, so the next one divides its
101/// lifetime CPU by all CPU time since boot and reads ~0% for a full core.
102fn cpu_percent_between(used_before_ms: u64, used_after_ms: u64, wall: Duration) -> f32 {
103    let wall_ms = wall.as_secs_f64() * 1000.0;
104    if wall_ms <= 0.0 {
105        return 0.0;
106    }
107    (used_after_ms.saturating_sub(used_before_ms) as f64 / wall_ms * 100.0) as f32
108}
109
110/// The error for finding orphans on Windows (shared by the library call and the command)
111#[cfg(not(unix))]
112pub(crate) fn orphans_not_supported() -> ProcError {
113    ProcError::NotSupported(
114        "orphans is not supported on Windows: Windows doesn't re-parent processes, \
115         so there is no reliable way to tell an orphan"
116            .to_string(),
117    )
118}
119
120/// Why a command such as `taskkill` failed, in its own words: what it wrote to
121/// stderr, else to stdout, else its exit status
122/// (compiled on Unix for the tests only)
123#[cfg(any(windows, test))]
124fn command_failure(stdout: &[u8], stderr: &[u8], status: &str) -> String {
125    [stderr, stdout]
126        .iter()
127        .map(|out| {
128            // one line, whatever the tool wrapped its reason over
129            String::from_utf8_lossy(out)
130                .lines()
131                .map(str::trim)
132                .filter(|line| !line.is_empty())
133                .collect::<Vec<_>>()
134                .join(" ")
135        })
136        .find(|text| !text.is_empty())
137        .unwrap_or_else(|| format!("exited with {status}"))
138}
139
140/// The result of asking sysinfo to kill process `pid`, which says whether the
141/// kill was delivered
142fn kill_outcome(delivered: bool, pid: u32) -> Result<()> {
143    if delivered {
144        Ok(())
145    } else {
146        Err(ProcError::SignalError(format!(
147            "Failed to kill process {}",
148            pid
149        )))
150    }
151}
152
153/// What a `taskkill` run (no `/F`) came to: success, or the reason it gave
154/// (compiled on Unix for the tests only)
155#[cfg(any(windows, test))]
156fn taskkill_outcome(output: &std::process::Output) -> Result<()> {
157    if output.status.success() {
158        Ok(())
159    } else {
160        Err(ProcError::SignalError(command_failure(
161            &output.stdout,
162            &output.stderr,
163            &output.status.to_string(),
164        )))
165    }
166}
167
168/// The CPU time each process in `sys` has used so far, in ms
169fn cpu_used(sys: &System) -> std::collections::HashMap<Pid, u64> {
170    sys.processes()
171        .iter()
172        .map(|(pid, p)| (*pid, p.accumulated_cpu_time()))
173        .collect()
174}
175
176/// Whether a process has been running for `timeout_secs`. A process's age is in whole
177/// seconds, so a process under a second old reads 0 and must still count for
178/// `--timeout 0`: "longer than" means this many seconds or more.
179fn ran_long_enough(run_time: u64, timeout_secs: u64) -> bool {
180    run_time >= timeout_secs
181}
182
183/// Whether one reading of a process counts toward "stuck": a process, not a
184/// thread, that has run for `timeout_secs` and looks stuck. `find_stuck` filters
185/// through this, so a test of it guards the comparison where it is used.
186fn counts_as_stuck(is_thread: bool, run_time: u64, timeout_secs: u64, looks_stuck: bool) -> bool {
187    !is_thread && ran_long_enough(run_time, timeout_secs) && looks_stuck
188}
189
190/// macOS: sysinfo drops zombies, because they have no executable path or arguments
191/// left to read, so `proc list --status zombie` could never find one. Read every
192/// process from the kernel, as `ps` does, and keep the zombies sysinfo didn't return.
193#[cfg(target_os = "macos")]
194fn macos_zombies(known: &std::collections::HashSet<u32>) -> Vec<Process> {
195    let table = read_proc_table(|buf, size| {
196        let mut mib = [libc::CTL_KERN, libc::KERN_PROC, libc::KERN_PROC_ALL, 0];
197        let null = std::ptr::null_mut();
198        // SAFETY: `buf` is null (report the size needed) or points to `*size`
199        // writable bytes, which is how read_proc_table calls it
200        unsafe { libc::sysctl(mib.as_mut_ptr(), 4, buf, size, null, 0) }
201    });
202    zombies_in(&table, known)
203}
204
205/// libc has no kinfo_proc for macOS, so the few fields we need are read at their
206/// offsets in the 64-bit layout (checked against a live process's known values)
207#[cfg(target_os = "macos")]
208const KINFO_PROC_SIZE: usize = 648;
209
210/// The kernel's table of every process, as raw kinfo_proc records; empty if the
211/// kernel refuses. `sysctl` is the call to make (a buffer or null, and the size
212/// in and out), so a test can make it fail.
213#[cfg(target_os = "macos")]
214fn read_proc_table(
215    sysctl: impl Fn(*mut libc::c_void, &mut libc::size_t) -> libc::c_int,
216) -> Vec<u8> {
217    let mut size: libc::size_t = 0;
218    // the first call only reports the size needed
219    if sysctl(std::ptr::null_mut(), &mut size) != 0 {
220        return Vec::new();
221    }
222    // room for processes started in between
223    size += 64 * KINFO_PROC_SIZE;
224    let mut buf: Vec<u8> = vec![0; size];
225    if sysctl(buf.as_mut_ptr().cast(), &mut size) != 0 {
226        return Vec::new();
227    }
228    buf.truncate(size);
229    buf
230}
231
232/// The zombies in a table read by `read_proc_table` that `known` doesn't list
233#[cfg(target_os = "macos")]
234fn zombies_in(buf: &[u8], known: &std::collections::HashSet<u32>) -> Vec<Process> {
235    const P_STARTTIME_SEC: usize = 0;
236    const P_STAT: usize = 36;
237    const P_PID: usize = 40;
238    const P_COMM: usize = 243; // MAXCOMLEN + 1 bytes
239    const P_RUID: usize = 392;
240    const E_PPID: usize = 560;
241    const SZOMB: u8 = 5;
242
243    if !buf.len().is_multiple_of(KINFO_PROC_SIZE) {
244        return Vec::new(); // a layout we don't know; better to show nothing than garbage
245    }
246
247    let read_u32 = |p: &[u8], at: usize| u32::from_ne_bytes(p[at..at + 4].try_into().unwrap());
248    let now = std::time::SystemTime::now()
249        .duration_since(std::time::UNIX_EPOCH)
250        .map(|d| d.as_secs())
251        .unwrap_or(0);
252
253    buf.as_chunks::<KINFO_PROC_SIZE>()
254        .0
255        .iter()
256        .filter(|p| p[P_STAT] == SZOMB)
257        .filter_map(|p| {
258            let pid = read_u32(p, P_PID);
259            if pid == 0 || known.contains(&pid) {
260                return None;
261            }
262            let comm = &p[P_COMM..P_COMM + 17];
263            let comm = &comm[..comm.iter().position(|&c| c == 0).unwrap_or(comm.len())];
264            let name = escape_control(&String::from_utf8_lossy(comm)).into_owned();
265            let start =
266                u64::from_ne_bytes(p[P_STARTTIME_SEC..P_STARTTIME_SEC + 8].try_into().unwrap());
267            Some(Process {
268                pid,
269                name: if name.is_empty() {
270                    "<defunct>".to_string()
271                } else {
272                    name
273                },
274                exe_path: None,
275                cwd: None,
276                command: None,
277                argv0: None,
278                raw: None,
279                cpu_percent: 0.0,
280                memory_mb: 0.0,
281                status: ProcessStatus::Zombie,
282                user: Uid::try_from(read_u32(p, P_RUID) as usize)
283                    .ok()
284                    .map(|uid| escape_control(&user_name(&uid)).into_owned()),
285                parent_pid: Some(read_u32(p, E_PPID)),
286                start_time: (start > 0).then_some(start),
287                uptime_secs: (start > 0).then(|| now.saturating_sub(start)),
288            })
289        })
290        .collect()
291}
292
293/// On Linux, sysinfo lists every thread of a process as an entry of its own,
294/// with the process's command line, so a node server shows up a dozen times.
295/// Process listings skip them; `thread_kind` is always `None` elsewhere.
296pub fn is_thread(proc: &sysinfo::Process) -> bool {
297    proc.thread_kind().is_some()
298}
299
300/// Linux reports a working directory that was deleted as "/path (deleted)".
301/// Strip that marker so the cwd matches `--in` and reads the same as on macOS;
302/// a real directory with that name still exists, so it is kept as is.
303fn strip_deleted_marker(cwd: String) -> String {
304    match cwd.strip_suffix(" (deleted)") {
305        Some(path) if !std::path::Path::new(&cwd).exists() => path.to_string(),
306        _ => cwd,
307    }
308}
309
310/// Process status
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "lowercase")]
313pub enum ProcessStatus {
314    /// Process is actively executing on CPU
315    Running,
316    /// Process is waiting for an event or resource
317    Sleeping,
318    /// Process has been stopped (e.g., by SIGSTOP)
319    Stopped,
320    /// Process has terminated but not yet been reaped by parent
321    Zombie,
322    /// Process is being terminated
323    Dead,
324    /// Process status could not be determined
325    Unknown,
326}
327
328impl From<SysProcessStatus> for ProcessStatus {
329    fn from(status: SysProcessStatus) -> Self {
330        match status {
331            SysProcessStatus::Run => ProcessStatus::Running,
332            SysProcessStatus::Sleep => ProcessStatus::Sleeping,
333            SysProcessStatus::Stop => ProcessStatus::Stopped,
334            SysProcessStatus::Zombie => ProcessStatus::Zombie,
335            SysProcessStatus::Dead => ProcessStatus::Dead,
336            _ => ProcessStatus::Unknown,
337        }
338    }
339}
340
341/// Represents a system process with relevant information
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct Process {
344    /// Process ID
345    pub pid: u32,
346    /// Process name (executable name)
347    pub name: String,
348    /// Path to the executable
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub exe_path: Option<String>,
351    /// Current working directory
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub cwd: Option<String>,
354    /// Full command line (if available)
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub command: Option<String>,
357    /// The first word of the command line, kept whole (a path with spaces in
358    /// it stays one piece) and exactly as the OS gave it, for matching names;
359    /// never printed, and not part of the JSON
360    #[serde(skip)]
361    pub argv0: Option<String>,
362    /// The name, executable and command line exactly as the OS gave them, when
363    /// escaping control characters for display changed any of them. Matching
364    /// reads these, so a name matches the same way whether it is looked up in
365    /// the process list or against a record; `None` when nothing was escaped
366    /// (the fields above are then the raw text). Not part of the JSON.
367    #[serde(skip)]
368    pub raw: Option<Box<RawText>>,
369    /// CPU usage percentage (0.0 - 100.0+)
370    pub cpu_percent: f32,
371    /// Memory usage in megabytes
372    pub memory_mb: f64,
373    /// Process status
374    pub status: ProcessStatus,
375    /// User who owns the process
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub user: Option<String>,
378    /// Parent process ID
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub parent_pid: Option<u32>,
381    /// Process start time (Unix timestamp)
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub start_time: Option<u64>,
384    /// Seconds the process has been running
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub uptime_secs: Option<u64>,
387}
388
389/// This process and its ancestors, as far up as ten levels (proc → shell →
390/// shell wrapper → IDE → ...). A name lookup skips them all: their command
391/// lines hold proc's own arguments (`zsh -c 'proc by node'`).
392pub(crate) fn self_and_ancestors(sys: &System) -> std::collections::HashSet<Pid> {
393    let self_pid = Pid::from_u32(std::process::id());
394    let mut pids = std::collections::HashSet::new();
395    pids.insert(self_pid);
396    let mut current = self_pid;
397    for _ in 0..10 {
398        if let Some(parent) = sys.process(current).and_then(|p| p.parent()) {
399            pids.insert(parent);
400            current = parent;
401        } else {
402            break;
403        }
404    }
405    pids
406}
407
408/// The text of a process that name matching reads, as the OS gave it: control
409/// characters unescaped, unlike the fields a process is displayed from
410#[derive(Debug, Clone)]
411pub struct RawText {
412    /// The process name
413    pub name: String,
414    /// Path of the executable
415    pub exe_path: Option<String>,
416    /// The full command line
417    pub command: Option<String>,
418    /// The working directory
419    pub cwd: Option<String>,
420}
421
422/// How a name relates to a process sysinfo listed, under `mode`. Reads the
423/// name, executable and command line straight from sysinfo, so no record is
424/// built for the many processes that don't match.
425pub(crate) fn classify_sysinfo(pattern: &str, proc: &sysinfo::Process, mode: NameMatch) -> Hit {
426    let name = proc.name().to_string_lossy();
427    let exe = proc.exe().map(|exe| exe.to_string_lossy());
428    let argv0 = proc.cmd().first().map(|arg| arg.to_string_lossy());
429    // the joined command line is only needed to search inside it
430    let command = (!proc.cmd().is_empty()).then(|| {
431        proc.cmd()
432            .iter()
433            .map(|arg| arg.to_string_lossy())
434            .collect::<Vec<_>>()
435            .join(" ")
436    });
437    classify(
438        pattern,
439        &Names {
440            name: &name,
441            exe: exe.as_deref(),
442            argv0: argv0.as_deref(),
443            command: command.as_deref(),
444        },
445        mode,
446    )
447}
448
449impl Process {
450    /// The names a name target is compared with
451    pub fn names(&self) -> Names<'_> {
452        match &self.raw {
453            Some(raw) => Names {
454                name: &raw.name,
455                exe: raw.exe_path.as_deref(),
456                argv0: self.argv0.as_deref(),
457                command: raw.command.as_deref(),
458            },
459            None => Names {
460                name: &self.name,
461                exe: self.exe_path.as_deref(),
462                argv0: self.argv0.as_deref(),
463                command: self.command.as_deref(),
464            },
465        }
466    }
467
468    /// The working directory exactly as the OS gave it, for matching `--in`.
469    /// `cwd` is what is shown, with control characters escaped, so a directory
470    /// named `a<newline>b` and one named `a\nb` (backslash, `n`) both read
471    /// `a\nb` there; this one tells them apart.
472    pub fn raw_cwd(&self) -> Option<&str> {
473        match &self.raw {
474            Some(raw) => raw.cwd.as_deref(),
475            None => self.cwd.as_deref(),
476        }
477    }
478
479    /// Find all processes a name matches (see [`NameMatch`]): by default the
480    /// whole name, with `--cmd` any part of the name or command line.
481    ///
482    /// Proc itself and its ancestor shells never match. When nothing matches by
483    /// name but processes have the text in their command line, the error says
484    /// so: it costs nothing, the same pass over the processes counts them.
485    pub fn find_by_name(pattern: &str, mode: NameMatch) -> Result<Vec<Process>> {
486        let mut sys = System::new_all();
487        sys.refresh_all();
488
489        // Exclude the entire ancestor chain — their command lines contain proc's
490        // arguments (e.g. "zsh -c proc by node"), causing false positives
491        let ancestor_pids = self_and_ancestors(&sys);
492        let mut in_command_line_only = 0;
493        let mut processes: Vec<Process> = sys
494            .processes()
495            .iter()
496            .filter_map(|(pid, proc)| {
497                // Exclude self, ancestor shells, and Linux threads
498                if ancestor_pids.contains(pid) || is_thread(proc) {
499                    return None;
500                }
501                match classify_sysinfo(pattern, proc, mode) {
502                    Hit::Match => Some(Process::from_sysinfo(*pid, proc)),
503                    Hit::CommandLineOnly => {
504                        in_command_line_only += 1;
505                        None
506                    }
507                    Hit::No => None,
508                }
509            })
510            .collect();
511        // sysinfo keeps its processes in a randomly ordered map: PID ascending
512        // makes every list that starts here the same on every run
513        processes.sort_by_key(|p| p.pid);
514
515        if processes.is_empty() {
516            return Err(ProcError::name_not_found(pattern, in_command_line_only));
517        }
518
519        Ok(processes)
520    }
521
522    /// Find a specific process by PID
523    pub fn find_by_pid(pid: u32) -> Result<Option<Process>> {
524        let mut sys = System::new_all();
525        sys.refresh_all();
526
527        let sysinfo_pid = Pid::from_u32(pid);
528
529        Ok(sys
530            .processes()
531            .get(&sysinfo_pid)
532            .map(|proc| Process::from_sysinfo(sysinfo_pid, proc)))
533    }
534
535    /// Get all running processes (excludes proc's own process)
536    pub fn find_all() -> Result<Vec<Process>> {
537        let mut sys = System::new_all();
538        sys.refresh_all();
539
540        let self_pid = sysinfo::Pid::from_u32(std::process::id());
541        #[allow(unused_mut)]
542        let mut processes: Vec<Process> = sys
543            .processes()
544            .iter()
545            .filter(|(pid, proc)| **pid != self_pid && !is_thread(proc))
546            .map(|(pid, proc)| Process::from_sysinfo(*pid, proc))
547            .collect();
548
549        #[cfg(target_os = "macos")]
550        {
551            let known: std::collections::HashSet<u32> = processes.iter().map(|p| p.pid).collect();
552            processes.extend(macos_zombies(&known));
553        }
554
555        // sysinfo keeps its processes in a randomly ordered map (and the
556        // zombies are appended after them): PID ascending makes every list
557        // that starts here the same on every run
558        processes.sort_by_key(|p| p.pid);
559
560        Ok(processes)
561    }
562
563    /// Find processes running a specific executable path
564    pub fn find_by_exe_path(path: &std::path::Path) -> Result<Vec<Process>> {
565        let all = Self::find_all()?;
566        let path_str = path.to_string_lossy();
567
568        Ok(all
569            .into_iter()
570            .filter(|p| {
571                if let Some(ref exe) = p.exe_path {
572                    // Exact match (compare as strings to avoid PathBuf allocation)
573                    exe == &*path_str || std::path::Path::new(exe) == path
574                } else {
575                    false
576                }
577            })
578            .collect())
579    }
580
581    /// Find processes that have a file open (Unix only via lsof)
582    #[cfg(unix)]
583    pub fn find_by_open_file(path: &std::path::Path) -> Result<Vec<Process>> {
584        Self::find_by_open_file_with("lsof", path)
585    }
586
587    /// `find_by_open_file` with the lsof program to run, so a test can have it missing
588    #[cfg(unix)]
589    fn find_by_open_file_with(lsof: &str, path: &std::path::Path) -> Result<Vec<Process>> {
590        use std::process::Command;
591
592        let output = crate::ui::interrupt::unblocked_in_child(&mut Command::new(lsof))
593            .args(["-t", &path.to_string_lossy()]) // -t = terse (PIDs only)
594            .output();
595
596        let output = match output {
597            Ok(o) => o,
598            Err(_) => return Ok(vec![]), // lsof not available
599        };
600
601        if !output.status.success() {
602            return Ok(vec![]); // No processes have file open
603        }
604
605        let pids: Vec<u32> = String::from_utf8_lossy(&output.stdout)
606            .lines()
607            .filter_map(|line| line.trim().parse().ok())
608            .collect();
609
610        let mut processes = Vec::new();
611        for pid in pids {
612            if let Ok(Some(proc)) = Self::find_by_pid(pid) {
613                processes.push(proc);
614            }
615        }
616
617        Ok(processes)
618    }
619
620    /// Find processes that have a file open (Windows stub)
621    #[cfg(not(unix))]
622    pub fn find_by_open_file(_path: &std::path::Path) -> Result<Vec<Process>> {
623        // Windows: Could use handle.exe from Sysinternals, but skip for now
624        Ok(vec![])
625    }
626
627    /// Find processes that look stuck: running longer than `timeout`, and in each
628    /// of two readings a second apart either using over half a core or blocked in
629    /// uninterruptible sleep. One reading
630    /// used to be enough, so a momentary spike counted. A long build at full CPU
631    /// still looks the same as a loop that never ends; only the user can tell.
632    pub fn find_stuck(timeout: Duration) -> Result<Vec<Process>> {
633        let timeout_secs = timeout.as_secs();
634        // each read is timed from its start, as in `measure_cpu`
635        let mut read_at = std::time::Instant::now();
636        let mut sys = System::new_all();
637        let mut used = cpu_used(&sys);
638        let mut cpu: std::collections::HashMap<Pid, f32> = std::collections::HashMap::new();
639        let mut stuck: Option<std::collections::HashSet<Pid>> = None;
640
641        for _ in 0..STUCK_READINGS {
642            std::thread::sleep(STUCK_READING_GAP);
643            let started = std::time::Instant::now();
644            sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
645            let wall = started - read_at;
646            read_at = started;
647            // a process first seen in this read has no CPU figure yet
648            cpu = sys
649                .processes()
650                .iter()
651                .filter_map(|(pid, p)| {
652                    let before = used.get(pid)?;
653                    Some((
654                        *pid,
655                        cpu_percent_between(*before, p.accumulated_cpu_time(), wall),
656                    ))
657                })
658                .collect();
659            used = cpu_used(&sys);
660            let now: std::collections::HashSet<Pid> = sys
661                .processes()
662                .iter()
663                .filter(|(pid, p)| {
664                    let busy = cpu.get(pid).copied().unwrap_or(0.0);
665                    counts_as_stuck(
666                        is_thread(p),
667                        p.run_time(),
668                        timeout_secs,
669                        looks_stuck(busy, p.status()),
670                    )
671                })
672                .map(|(pid, _)| *pid)
673                .collect();
674            stuck = Some(match stuck {
675                None => now,
676                Some(before) => before.intersection(&now).copied().collect(),
677            });
678        }
679
680        let stuck = stuck.unwrap_or_default();
681        let mut found: Vec<Process> = sys
682            .processes()
683            .iter()
684            .filter(|(pid, _)| stuck.contains(pid))
685            .map(|(pid, p)| {
686                let mut process = Process::from_sysinfo(*pid, p);
687                // the figure the last read measured, not sysinfo's own
688                if let Some(&busy) = cpu.get(pid) {
689                    process.cpu_percent = busy;
690                }
691                process
692            })
693            .collect();
694        // sysinfo keeps its processes in a randomly ordered map: PID ascending
695        found.sort_by_key(|p| p.pid);
696        Ok(found)
697    }
698
699    /// Whether this process looks stuck now, measured the way `find_stuck` does
700    /// (stuck in two readings a second apart), for a process named as a target.
701    pub fn looks_stuck_now(&self) -> bool {
702        let pid = Pid::from_u32(self.pid);
703        let mut sys = System::new();
704        let mut read_at = std::time::Instant::now();
705        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
706        let mut used = sys
707            .process(pid)
708            .filter(|p| self.is_same(p))
709            .map(|p| p.accumulated_cpu_time());
710        (0..STUCK_READINGS).all(|_| {
711            std::thread::sleep(STUCK_READING_GAP);
712            let started = std::time::Instant::now();
713            sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
714            let wall = started - read_at;
715            read_at = started;
716            let (Some(before), Some(p)) = (used, sys.process(pid).filter(|p| self.is_same(p)))
717            else {
718                return false;
719            };
720            let after = p.accumulated_cpu_time();
721            used = Some(after);
722            looks_stuck(cpu_percent_between(before, after, wall), p.status())
723        })
724    }
725
726    /// CPU use over `window` (at least sysinfo's minimum), in percent of one core;
727    /// None if the process is gone. A reading over a few milliseconds is ~0 for
728    /// any process, so a real window is needed.
729    ///
730    /// Unix only (the method does not exist on Windows): its one caller is the gentle recovery `unstick` tries there;
731    /// on Windows `unstick` only terminates.
732    #[cfg(unix)]
733    pub fn cpu_over(&self, window: Duration) -> Option<f32> {
734        let pid = Pid::from_u32(self.pid);
735        let mut sys = System::new();
736        let started = std::time::Instant::now();
737        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
738        let before = sys
739            .process(pid)
740            .filter(|p| self.is_same(p))
741            .map(|p| p.accumulated_cpu_time())?;
742        std::thread::sleep(window.max(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL));
743        let wall = started.elapsed();
744        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
745        sys.process(pid)
746            .filter(|p| self.is_same(p))
747            .map(|p| cpu_percent_between(before, p.accumulated_cpu_time(), wall))
748    }
749
750    /// Measure the CPU use of these processes over 0.2 s and set `cpu_percent`,
751    /// in percent of one core. A lookup reads CPU a few milliseconds after its
752    /// first refresh, which is ~0% even for a process using a whole core, so
753    /// commands call this before they show, filter or sort by CPU. One 0.2 s wait
754    /// covers the whole batch; commands that never show CPU skip it.
755    ///
756    /// CPU is the CPU time each process used between two reads, over the wall
757    /// time we measure between them. sysinfo's own `cpu_usage` divides by its
758    /// system-wide tick interval instead, which read about 2% for a process
759    /// spinning at 100% in some runs.
760    pub fn measure_cpu(processes: &mut [Process]) {
761        if processes.is_empty() {
762            return;
763        }
764        let pids: Vec<Pid> = processes.iter().map(|p| Pid::from_u32(p.pid)).collect();
765        let which = sysinfo::ProcessesToUpdate::Some(&pids);
766        let cpu_only = sysinfo::ProcessRefreshKind::nothing().with_cpu();
767        let mut sys = System::new();
768
769        let first = std::time::Instant::now();
770        sys.refresh_processes_specifics(which, true, cpu_only);
771        let before = cpu_used(&sys);
772        std::thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL);
773        // each refresh reads the processes in the same order, so timing both
774        // from their start gives every process the same window
775        let wall = first.elapsed();
776        sys.refresh_processes_specifics(which, true, cpu_only);
777
778        for process in processes.iter_mut() {
779            let pid = Pid::from_u32(process.pid);
780            let (Some(current), Some(&cpu_before)) = (sys.process(pid), before.get(&pid)) else {
781                continue;
782            };
783            if process.is_same(current) {
784                process.cpu_percent =
785                    cpu_percent_between(cpu_before, current.accumulated_cpu_time(), wall);
786            }
787        }
788    }
789
790    /// Force kill the process (SIGKILL on Unix, taskkill /F on Windows)
791    pub fn kill(&self) -> Result<()> {
792        let mut sys = System::new();
793        sys.refresh_processes(
794            sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(self.pid)]),
795            true,
796        );
797
798        match sys.process(Pid::from_u32(self.pid)) {
799            Some(proc) if self.is_same(proc) => kill_outcome(proc.kill(), self.pid),
800            // gone, or the PID now belongs to a new process
801            _ => Err(ProcError::ProcessNotFound(self.pid.to_string())),
802        }
803    }
804
805    /// Whether `current`, the process now holding this PID, is the one this record
806    /// describes. A different start time means the PID was reused by a new process.
807    fn is_same(&self, current: &sysinfo::Process) -> bool {
808        match self.start_time {
809            Some(ours) if current.start_time() > 0 => current.start_time().abs_diff(ours) <= 1,
810            _ => true,
811        }
812    }
813
814    /// Checked right before each signal, so a delayed one (stop's SIGKILL comes up
815    /// to --timeout seconds after the target was resolved) can't reach a new
816    /// process that reused the PID.
817    fn ensure_same_process(&self) -> Result<()> {
818        let mut sys = System::new();
819        let pid = Pid::from_u32(self.pid);
820        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
821        match sys.process(pid) {
822            Some(current) if self.is_same(current) => Ok(()),
823            _ => Err(ProcError::ProcessNotFound(self.pid.to_string())),
824        }
825    }
826
827    /// Force kill and wait up to 5 seconds for the process to be gone.
828    ///
829    /// Never blocks forever (sysinfo's own kill_and_wait polls with no limit and
830    /// never ends for a zombie). A process still alive after 5 seconds, such as
831    /// one stuck in the kernel, is an error.
832    pub fn kill_and_wait(&self) -> Result<()> {
833        self.kill_and_wait_within(Duration::from_secs(5))
834    }
835
836    /// `kill_and_wait` with the time to wait for the process to be gone
837    fn kill_and_wait_within(&self, wait: Duration) -> Result<()> {
838        self.kill_then_wait(wait, Process::kill)
839    }
840
841    /// `kill_and_wait_within` with the kill to send, so a test can send one that
842    /// leaves the process running (a real kill is over before it returns on
843    /// Windows, though the process can stay listed for a moment after)
844    fn kill_then_wait(&self, wait: Duration, kill: impl Fn(&Process) -> Result<()>) -> Result<()> {
845        match kill(self) {
846            // it exited on its own just before the kill
847            Err(ProcError::ProcessNotFound(_)) => return Ok(()),
848            other => other?,
849        }
850        let deadline = std::time::Instant::now() + wait;
851        while std::time::Instant::now() < deadline {
852            if !self.is_running() {
853                return Ok(());
854            }
855            std::thread::sleep(Duration::from_millis(50));
856        }
857        Err(ProcError::SignalError(format!(
858            "Process {} is still running {} seconds after SIGKILL",
859            self.pid,
860            wait.as_secs()
861        )))
862    }
863
864    /// Send SIGTERM for graceful termination (Unix) or taskkill (Windows)
865    #[cfg(unix)]
866    pub fn terminate(&self) -> Result<()> {
867        use nix::sys::signal::{kill, Signal};
868        use nix::unistd::Pid as NixPid;
869
870        self.ensure_same_process()?;
871        kill(NixPid::from_raw(self.pid as i32), Signal::SIGTERM)
872            .map_err(|e| ProcError::SignalError(e.to_string()))
873    }
874
875    /// Graceful termination (Windows): `taskkill` without `/F` asks the
876    /// process to close its windows, so it fails for a console program, which
877    /// has none. That failure is returned, with taskkill's reason, rather than
878    /// reported as success.
879    #[cfg(windows)]
880    pub fn terminate(&self) -> Result<()> {
881        use std::process::Command;
882
883        self.ensure_same_process()?;
884        let output = Command::new("taskkill")
885            .args(["/PID", &self.pid.to_string()])
886            .output()
887            .map_err(|e| ProcError::SystemError(e.to_string()))?;
888
889        taskkill_outcome(&output)
890    }
891
892    /// Send an arbitrary signal to the process (Unix only)
893    #[cfg(unix)]
894    pub fn send_signal(&self, signal: nix::sys::signal::Signal) -> Result<()> {
895        use nix::sys::signal::kill;
896        use nix::unistd::Pid as NixPid;
897        self.ensure_same_process()?;
898        kill(NixPid::from_raw(self.pid as i32), signal)
899            .map_err(|e| ProcError::SignalError(format!("{}: {}", signal, e)))
900    }
901
902    // Note: On Windows, send_signal is not available. Commands that use it
903    // (freeze, thaw) have their own #[cfg(not(unix))] stubs that return NotSupported.
904
905    /// Find orphaned processes (parent is PID 1 / init / launchd, excluding system daemons)
906    ///
907    /// Unix only: on Windows this returns `ProcError::NotSupported`, because
908    /// Windows has no PID 1 and does not re-parent a process whose parent exited,
909    /// so an empty list would claim "no orphans" where nothing could be checked.
910    #[cfg(unix)]
911    pub fn find_orphans() -> Result<Vec<Process>> {
912        Ok(Self::orphans_among(Self::find_all()?))
913    }
914
915    /// Windows: orphans cannot be told, so this is an error, not an empty list
916    #[cfg(not(unix))]
917    pub fn find_orphans() -> Result<Vec<Process>> {
918        Err(orphans_not_supported())
919    }
920
921    /// The orphans among `all`
922    #[cfg(unix)]
923    fn orphans_among(all: Vec<Process>) -> Vec<Process> {
924        all.into_iter()
925            .filter(|p| {
926                if let Some(ppid) = p.parent_pid {
927                    ppid == 1 && p.pid != 1 && !Self::is_system_process(p)
928                } else {
929                    false
930                }
931            })
932            .collect()
933    }
934
935    /// Check if a process is a system daemon (naturally has PPID 1)
936    ///
937    /// Filters out processes that legitimately have PPID 1 — system daemons,
938    /// kernel threads, and services managed by init/systemd/launchd.
939    /// Heuristic: no cwd or cwd is "/" with exe in system paths.
940    #[cfg(unix)]
941    fn is_system_process(p: &Process) -> bool {
942        if p.cwd.is_none() || p.cwd.as_deref() == Some("/") {
943            if let Some(ref exe) = p.exe_path {
944                // macOS system paths
945                if exe.starts_with("/System/") || exe.starts_with("/usr/libexec/") {
946                    return true;
947                }
948                // Shared Unix system paths (macOS + Linux)
949                if exe.starts_with("/usr/sbin/")
950                    || exe.starts_with("/sbin/")
951                    || exe.starts_with("/usr/bin/")
952                    || exe.starts_with("/usr/lib/")
953                    || exe.starts_with("/usr/lib64/")
954                    || exe.starts_with("/lib/")
955                    || exe.starts_with("/lib64/")
956                    || exe.starts_with("/opt/")
957                    || exe.starts_with("/snap/")
958                {
959                    return true;
960                }
961            }
962            return true; // No exe path = likely kernel thread
963        }
964        false
965    }
966
967    /// Check if the process is still alive. A zombie (exited, not yet reaped by
968    /// its parent) has exited, and a process with a different start time is a new
969    /// one that reused the PID, so neither counts.
970    pub fn exists(&self) -> bool {
971        let mut sys = System::new();
972        let pid = Pid::from_u32(self.pid);
973        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
974        match sys.process(pid) {
975            None => false,
976            Some(current) if current.status() == SysProcessStatus::Zombie => false,
977            Some(current) => self.is_same(current),
978        }
979    }
980
981    /// Check if the process is still running (alias for exists for compatibility)
982    pub fn is_running(&self) -> bool {
983        self.exists()
984    }
985
986    /// Convert from sysinfo Process
987    pub(crate) fn from_sysinfo(pid: Pid, proc: &sysinfo::Process) -> Self {
988        let cmd_vec = proc.cmd();
989        // Every text field is set by whoever runs the process: escape control
990        // characters before anything prints them. Name matching reads the raw
991        // text (`raw`, kept only when escaping changed something).
992        let raw_command = (!cmd_vec.is_empty()).then(|| {
993            cmd_vec
994                .iter()
995                .map(|s| s.to_string_lossy())
996                .collect::<Vec<_>>()
997                .join(" ")
998        });
999        let raw_name = proc.name().to_string_lossy();
1000        let raw_exe = proc.exe().map(|p| p.to_string_lossy());
1001        let raw_cwd = proc
1002            .cwd()
1003            .map(|p| strip_deleted_marker(p.to_string_lossy().to_string()));
1004        let changed = |text: &str| matches!(escape_control(text), std::borrow::Cow::Owned(_));
1005        let raw = (changed(&raw_name)
1006            || raw_exe.as_deref().is_some_and(changed)
1007            || raw_command.as_deref().is_some_and(changed)
1008            || raw_cwd.as_deref().is_some_and(changed))
1009        .then(|| {
1010            Box::new(RawText {
1011                name: raw_name.to_string(),
1012                exe_path: raw_exe.as_ref().map(|exe| exe.to_string()),
1013                command: raw_command.clone(),
1014                cwd: raw_cwd.clone(),
1015            })
1016        });
1017        let command = raw_command
1018            .as_deref()
1019            .map(|joined| escape_control(joined).into_owned());
1020
1021        let exe_path = raw_exe
1022            .as_deref()
1023            .map(|exe| escape_control(exe).into_owned());
1024        let cwd = raw_cwd
1025            .as_deref()
1026            .map(|path| escape_control(path).into_owned());
1027
1028        Process {
1029            pid: pid.as_u32(),
1030            name: escape_control(&raw_name).into_owned(),
1031            exe_path,
1032            cwd,
1033            command,
1034            argv0: cmd_vec
1035                .first()
1036                .map(|arg| arg.to_string_lossy().into_owned()),
1037            raw,
1038            cpu_percent: proc.cpu_usage(),
1039            memory_mb: proc.memory() as f64 / 1024.0 / 1024.0,
1040            status: ProcessStatus::from(proc.status()),
1041            user: proc
1042                .user_id()
1043                .map(|uid| escape_control(&user_name(uid)).into_owned()),
1044            parent_pid: proc.parent().map(|p| p.as_u32()),
1045            // sysinfo reports 0 when it can't read the start time (on macOS, any
1046            // other user's process), which would make the uptime about 56 years
1047            start_time: (proc.start_time() > 0).then(|| proc.start_time()),
1048            uptime_secs: (proc.start_time() > 0).then(|| proc.run_time()),
1049        }
1050    }
1051}
1052
1053/// Parse a signal name to a nix Signal (Unix only)
1054///
1055/// Accepts: "HUP", "SIGHUP", "hup" (signal names only, not numbers —
1056/// numeric signal values differ between macOS and Linux)
1057#[cfg(unix)]
1058pub fn parse_signal_name(name: &str) -> Result<nix::sys::signal::Signal> {
1059    use nix::sys::signal::Signal;
1060
1061    let upper = name.to_uppercase();
1062    let upper = upper.trim_start_matches("SIG");
1063    match upper {
1064        "HUP" => Ok(Signal::SIGHUP),
1065        "INT" => Ok(Signal::SIGINT),
1066        "QUIT" => Ok(Signal::SIGQUIT),
1067        "ABRT" => Ok(Signal::SIGABRT),
1068        "KILL" => Ok(Signal::SIGKILL),
1069        "TERM" => Ok(Signal::SIGTERM),
1070        "STOP" => Ok(Signal::SIGSTOP),
1071        "CONT" => Ok(Signal::SIGCONT),
1072        "USR1" => Ok(Signal::SIGUSR1),
1073        "USR2" => Ok(Signal::SIGUSR2),
1074        _ => Err(ProcError::InvalidInput(format!(
1075            "Unknown signal: '{}'. Valid signals: HUP, INT, QUIT, ABRT, KILL, TERM, STOP, CONT, USR1, USR2",
1076            name
1077        ))),
1078    }
1079}
1080
1081#[cfg(test)]
1082mod tests {
1083    use super::*;
1084
1085    /// A service account (shell `/bin/false` or `/usr/bin/false`) is named like
1086    /// any other: sysinfo's user list leaves such accounts out, so proc showed
1087    /// their user ID instead. macOS gives every daemon account that shell; a
1088    /// Linux machine need not have one, so the test runs on macOS
1089    #[cfg(target_os = "macos")]
1090    #[test]
1091    fn an_account_whose_shell_is_false_is_named() {
1092        let passwd = std::fs::read_to_string("/etc/passwd").unwrap();
1093        let f = passwd
1094            .lines()
1095            .filter(|line| !line.starts_with('#'))
1096            .map(|line| line.split(':').collect::<Vec<_>>())
1097            .find(|f| f.len() == 7 && f[6].ends_with("/false") && f[2].parse::<u32>().is_ok())
1098            .expect("macOS has daemon accounts with a false shell");
1099        let uid = Uid::try_from(f[2].parse::<usize>().unwrap()).unwrap();
1100        assert_eq!(user_name(&uid), f[0]);
1101    }
1102
1103    #[cfg(unix)]
1104    #[test]
1105    fn a_user_id_nobody_has_is_shown_as_the_number() {
1106        let uid = Uid::try_from(4_000_000_000usize).unwrap();
1107        assert_eq!(user_name(&uid), "4000000000");
1108    }
1109
1110    #[test]
1111    fn cpu_is_the_cpu_time_used_over_the_wall_time() {
1112        let second = Duration::from_secs(1);
1113        // a full core for a second, half a core, and nothing
1114        assert_eq!(cpu_percent_between(5_000, 6_000, second), 100.0);
1115        assert_eq!(cpu_percent_between(5_000, 5_500, second), 50.0);
1116        assert_eq!(cpu_percent_between(5_000, 5_000, second), 0.0);
1117        // two cores' worth reads 200%, in percent of one core
1118        assert_eq!(cpu_percent_between(0, 2_000, second), 200.0);
1119        // a counter that went backwards (a reused PID) or no time passing is 0
1120        assert_eq!(cpu_percent_between(6_000, 5_000, second), 0.0);
1121        assert_eq!(cpu_percent_between(0, 1_000, Duration::ZERO), 0.0);
1122    }
1123
1124    #[test]
1125    fn a_failed_command_is_explained_in_its_own_words() {
1126        let reason = "ERROR: The process with PID 7 could not be terminated.";
1127        assert_eq!(
1128            command_failure(b"", reason.as_bytes(), "exit code: 1"),
1129            reason
1130        );
1131        // stdout when stderr is empty, trimmed
1132        assert_eq!(
1133            command_failure(b"  not here\r\n", b"", "exit code: 1"),
1134            "not here"
1135        );
1136        // taskkill wraps its reason over several lines; the error keeps them on one
1137        assert_eq!(
1138            command_failure(
1139                b"",
1140                b"ERROR: first part\r\n  second part.\r\n\r\n",
1141                "exit code: 1"
1142            ),
1143            "ERROR: first part second part."
1144        );
1145        assert_eq!(
1146            command_failure(b"", b"  \n", "exit code: 128"),
1147            "exited with exit code: 128"
1148        );
1149    }
1150
1151    #[test]
1152    fn busy_means_over_half_a_core_or_blocked_in_the_kernel() {
1153        assert!(looks_stuck(50.1, SysProcessStatus::Run));
1154        assert!(!looks_stuck(50.0, SysProcessStatus::Run));
1155        assert!(!looks_stuck(0.0, SysProcessStatus::Sleep));
1156        assert!(looks_stuck(0.0, SysProcessStatus::UninterruptibleDiskSleep));
1157    }
1158
1159    #[test]
1160    fn a_reading_counts_as_stuck_from_the_timeout_on() {
1161        assert!(counts_as_stuck(false, 0, 0, true));
1162        assert!(!counts_as_stuck(false, 59, 60, true));
1163        assert!(counts_as_stuck(false, 60, 60, true));
1164        assert!(counts_as_stuck(false, 61, 60, true));
1165        // and only a busy process, never a thread
1166        assert!(!counts_as_stuck(false, 60, 60, false));
1167        assert!(!counts_as_stuck(true, 60, 60, true));
1168    }
1169
1170    #[test]
1171    fn a_process_that_ran_the_timeout_has_run_long_enough() {
1172        // --timeout 0 finds a process that reads 0 seconds old
1173        assert!(ran_long_enough(0, 0));
1174        assert!(ran_long_enough(5, 5));
1175        assert!(ran_long_enough(6, 5));
1176        assert!(!ran_long_enough(4, 5));
1177    }
1178
1179    #[test]
1180    fn test_find_all_processes() {
1181        let processes = Process::find_all().unwrap();
1182        assert!(!processes.is_empty(), "Should find at least one process");
1183    }
1184
1185    #[test]
1186    fn every_process_source_lists_pid_ascending() {
1187        use crate::test_support::{end, idle_child, record_of};
1188
1189        let mut children: Vec<_> = (0..3).map(|_| idle_child()).collect();
1190        let mine: Vec<u32> = children.iter().map(|c| record_of(c).pid).collect();
1191        let name = record_of(&children[0]).name;
1192
1193        let ascending = |what: &str, list: &[Process]| {
1194            let pids: Vec<u32> = list.iter().map(|p| p.pid).collect();
1195            let mut sorted = pids.clone();
1196            sorted.sort_unstable();
1197            sorted.dedup();
1198            assert_eq!(pids, sorted, "{what} is not PID ascending");
1199            pids
1200        };
1201        let all = ascending("find_all", &Process::find_all().unwrap());
1202        let named = ascending(
1203            "find_by_name",
1204            &Process::find_by_name(&name, NameMatch::Exact).unwrap(),
1205        );
1206        ascending("find_stuck", &Process::find_stuck(Duration::ZERO).unwrap());
1207        for pid in &mine {
1208            assert!(all.contains(pid), "find_all has {pid}");
1209            assert!(named.contains(pid), "find_by_name has {pid}");
1210        }
1211        children.iter_mut().for_each(end);
1212    }
1213
1214    #[test]
1215    fn test_find_by_pid_self() {
1216        let pid = std::process::id();
1217        let process = Process::find_by_pid(pid).unwrap();
1218        assert!(process.is_some(), "Should find own process");
1219    }
1220
1221    #[test]
1222    fn test_is_running_tells_a_reused_pid_apart() {
1223        let mut me = Process::find_by_pid(std::process::id()).unwrap().unwrap();
1224        assert!(me.is_running());
1225        // Same PID, different start time: a new process that reused the PID
1226        let start = me.start_time.expect("a running process has a start time");
1227        me.start_time = Some(start - 3600);
1228        assert!(!me.is_running(), "a reused PID counted as the same process");
1229    }
1230
1231    #[test]
1232    fn test_escape_control_makes_terminal_input_harmless() {
1233        // Ordinary text, including non-ASCII, is untouched
1234        assert_eq!(escape_control("node server.js"), "node server.js");
1235        assert_eq!(escape_control("café ✓ 日本"), "café ✓ 日本");
1236        // ESC sequences: colours, window title (OSC), clipboard (OSC 52)
1237        assert_eq!(escape_control("a\x1b[31mb"), "a\\x1b[31mb");
1238        assert_eq!(escape_control("\x1b]0;pwned\x07"), "\\x1b]0;pwned\\x07");
1239        // Line breaks and tabs would split or shift a table row
1240        assert_eq!(escape_control("one\ntwo\tthree\r"), "one\\ntwo\\tthree\\r");
1241        // DEL, and C1 controls such as the 8-bit CSI (U+009B)
1242        assert_eq!(escape_control("x\x7fy\u{9b}2J"), "x\\x7fy\\x9b2J");
1243        // Bidirectional overrides can reorder text on screen ("Trojan Source")
1244        assert_eq!(escape_control("ab\u{202e}cd"), "ab\\u{202e}cd");
1245    }
1246
1247    #[cfg(unix)]
1248    #[test]
1249    fn test_looks_stuck_needs_sustained_cpu() {
1250        let spawn = |script: &str| {
1251            std::process::Command::new("sh")
1252                .args(["-c", script])
1253                .spawn()
1254                .unwrap()
1255        };
1256        let mut spinning = spawn("while :; do :; done");
1257        let mut idle = spawn("exec sleep 300");
1258        std::thread::sleep(Duration::from_millis(300));
1259        let record = |pid: u32| Process::find_by_pid(pid).unwrap().unwrap();
1260
1261        let spinning_stuck = record(spinning.id()).looks_stuck_now();
1262        let idle_stuck = record(idle.id()).looks_stuck_now();
1263        for child in [&mut spinning, &mut idle] {
1264            let _ = child.kill();
1265            let _ = child.wait();
1266        }
1267        assert!(
1268            spinning_stuck,
1269            "a process spinning at 100% CPU wasn't stuck"
1270        );
1271        assert!(!idle_stuck, "an idle process counted as stuck");
1272    }
1273
1274    #[test]
1275    fn test_signals_never_reach_a_process_that_reused_the_pid() {
1276        let mut child = crate::test_support::idle_child();
1277        let mut stale = crate::test_support::record_of(&child);
1278        // A record from before the PID was reused: same PID, older start time
1279        stale.start_time = stale.start_time.map(|t| t - 3600);
1280
1281        let kill = stale.kill();
1282        let term = stale.terminate();
1283        #[cfg(unix)]
1284        let sig = stale.send_signal(nix::sys::signal::Signal::SIGKILL);
1285        let alive = child.try_wait().unwrap().is_none();
1286        let _ = child.kill();
1287        let _ = child.wait();
1288
1289        assert!(
1290            alive,
1291            "a stale record signalled the process now using its PID"
1292        );
1293        assert!(
1294            matches!(kill, Err(ProcError::ProcessNotFound(_))),
1295            "{kill:?}"
1296        );
1297        assert!(
1298            matches!(term, Err(ProcError::ProcessNotFound(_))),
1299            "{term:?}"
1300        );
1301        #[cfg(unix)]
1302        assert!(matches!(sig, Err(ProcError::ProcessNotFound(_))), "{sig:?}");
1303    }
1304
1305    /// A control character in a process's name, executable or command line is
1306    /// shown escaped, but name matching reads what the OS gave: a process found
1307    /// by `find_by_name` is also kept by `--by`, and the other way round
1308    #[cfg(unix)]
1309    #[test]
1310    fn text_with_control_characters_matches_as_typed_and_displays_escaped() {
1311        use crate::core::matches_by_filter;
1312        use std::os::unix::process::CommandExt;
1313
1314        let word = format!("zz\u{7}ctl{}x", std::process::id());
1315        // A shell waiting on its stdin, not `sleep`: where `sleep` is one name
1316        // of a multi-call binary (Nix's coreutils), it refuses to run under
1317        // another first argument. The shell ends when the pipe closes.
1318        let mut child = std::process::Command::new("sh")
1319            .arg0(&word)
1320            .args(["-c", "read line"])
1321            .stdin(std::process::Stdio::piped())
1322            .spawn()
1323            .unwrap();
1324        let pid = child.id();
1325        // `spawn` returns once the exec has begun, not once it has finished:
1326        // measured on Linux, /proc/<pid>/cmdline is still empty for a moment
1327        // after it (name and exe are already the new program's). A record taken
1328        // in that moment has no first argument, so wait for the command line.
1329        let record = std::iter::repeat_with(|| {
1330            std::thread::sleep(Duration::from_millis(5));
1331            Process::find_by_pid(pid).unwrap().unwrap()
1332        })
1333        .take(1000)
1334        .find(|record| record.argv0.is_some());
1335        let part = format!("\u{7}ctl{}", std::process::id());
1336
1337        let found = |pattern: &str, mode| {
1338            Process::find_by_name(pattern, mode)
1339                .map(|found| found.iter().any(|p| p.pid == pid))
1340                .unwrap_or(false)
1341        };
1342        let named = found(&word, NameMatch::Exact);
1343        let named_in_command = found(&part, NameMatch::Command);
1344        let _ = child.kill();
1345        let _ = child.wait();
1346
1347        let record = record.expect("the child's command line never appeared");
1348        assert!(named, "find_by_name read the escaped first argument");
1349        assert!(
1350            named_in_command,
1351            "find_by_name read the escaped command line"
1352        );
1353        assert!(
1354            matches_by_filter(&record, &word, NameMatch::Exact),
1355            "--by read the escaped first argument"
1356        );
1357        assert!(
1358            matches_by_filter(&record, &part, NameMatch::Command),
1359            "--by --cmd read the escaped command line"
1360        );
1361        // what is shown is escaped
1362        let shown = record.command.as_deref().unwrap();
1363        assert!(shown.contains("\\x07ctl"), "{shown}");
1364        assert!(!shown.contains('\u{7}'), "{shown:?}");
1365    }
1366
1367    /// The same, on Windows, where a process has no `arg0` to set: a control
1368    /// character in the command line (the listener example ignores extra
1369    /// arguments) is shown escaped and still matches as typed with `--cmd`
1370    #[cfg(windows)]
1371    #[test]
1372    fn a_control_character_in_a_windows_command_line_matches_as_typed_and_displays_escaped() {
1373        use crate::core::matches_by_filter;
1374
1375        let mut exe = std::env::current_exe().unwrap();
1376        exe.pop();
1377        exe.pop();
1378        exe.push("examples");
1379        exe.push("listener.exe");
1380        assert!(exe.exists(), "build the example first: {}", exe.display());
1381        let part = format!("\u{7}ctl{}", std::process::id());
1382        let mut child = std::process::Command::new(&exe)
1383            .arg(&part)
1384            .stdin(std::process::Stdio::null())
1385            .stdout(std::process::Stdio::null())
1386            .spawn()
1387            .unwrap();
1388        let pid = child.id();
1389        let record = std::iter::repeat_with(|| {
1390            std::thread::sleep(Duration::from_millis(20));
1391            Process::find_by_pid(pid).unwrap()
1392        })
1393        .take(500)
1394        .flatten()
1395        .find(|record| record.command.is_some())
1396        .expect("the child's command line never appeared");
1397        let found = Process::find_by_name(&part, NameMatch::Command)
1398            .map(|found| found.iter().any(|p| p.pid == pid))
1399            .unwrap_or(false);
1400        let kept = matches_by_filter(&record, &part, NameMatch::Command);
1401        crate::test_support::end(&mut child);
1402
1403        assert!(found, "find_by_name read the escaped command line");
1404        assert!(kept, "--by --cmd read the escaped command line");
1405        let shown = record.command.as_deref().unwrap();
1406        assert!(shown.contains("\\x07ctl"), "{shown}");
1407        assert!(!shown.contains('\u{7}'), "{shown:?}");
1408    }
1409
1410    #[test]
1411    fn a_name_is_read_from_the_raw_text_when_escaping_changed_it() {
1412        let record = Process {
1413            name: "a\\x07b".to_string(),
1414            raw: Some(Box::new(RawText {
1415                name: "a\u{7}b".to_string(),
1416                exe_path: Some("c\u{7}d".to_string()),
1417                command: Some("e\u{7}f".to_string()),
1418                cwd: None,
1419            })),
1420            argv0: Some("g".to_string()),
1421            ..ghost()
1422        };
1423        let names = record.names();
1424        assert_eq!(names.name, "a\u{7}b");
1425        assert_eq!(names.exe, Some("c\u{7}d"));
1426        assert_eq!(names.argv0, Some("g"));
1427        assert_eq!(names.command, Some("e\u{7}f"));
1428    }
1429
1430    #[test]
1431    fn the_ancestors_stop_where_the_process_list_does() {
1432        // A list that holds only this process: its parent is named but not
1433        // listed, so the climb ends there instead of going on
1434        let me = Pid::from_u32(std::process::id());
1435        let mut sys = System::new();
1436        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[me]), true);
1437        let parent = sys.process(me).and_then(|p| p.parent());
1438        let expected: std::collections::HashSet<Pid> = std::iter::once(me).chain(parent).collect();
1439        assert_eq!(self_and_ancestors(&sys), expected);
1440    }
1441
1442    #[test]
1443    fn test_strip_deleted_marker() {
1444        assert_eq!(
1445            strip_deleted_marker("/nonexistent/proc-gone (deleted)".to_string()),
1446            "/nonexistent/proc-gone"
1447        );
1448        assert_eq!(
1449            strip_deleted_marker("/tmp/project".to_string()),
1450            "/tmp/project"
1451        );
1452
1453        // A directory really named "x (deleted)" keeps its name
1454        let real = std::env::temp_dir().join(format!("proc-real-{} (deleted)", std::process::id()));
1455        std::fs::create_dir_all(&real).unwrap();
1456        let path = real.to_string_lossy().to_string();
1457        assert_eq!(strip_deleted_marker(path.clone()), path);
1458        std::fs::remove_dir(&real).unwrap();
1459    }
1460
1461    #[test]
1462    fn a_name_nothing_has_is_plain_not_found_in_both_modes() {
1463        for mode in [NameMatch::Exact, NameMatch::Command] {
1464            let result = Process::find_by_name("nonexistent_process_12345", mode);
1465            assert!(
1466                matches!(&result, Err(ProcError::ProcessNotFound(n)) if n == "nonexistent_process_12345"),
1467                "{result:?}"
1468            );
1469        }
1470    }
1471
1472    /// A shell waiting for input that never comes, with a marker in its command
1473    /// line: it is named `sh`, and the marker is only in `sh -c ...`. It runs
1474    /// nothing else, so there is no child to leave behind.
1475    #[cfg(unix)]
1476    fn waiting_shell_with(marker: &str) -> std::process::Child {
1477        std::process::Command::new("sh")
1478            .args(["-c", &format!("read line; : {marker}")])
1479            .stdin(std::process::Stdio::piped())
1480            .spawn()
1481            .unwrap()
1482    }
1483
1484    #[cfg(unix)]
1485    #[test]
1486    fn a_word_only_in_a_command_line_is_found_with_cmd_and_hinted_without() {
1487        let marker = format!("hintmarker{}", std::process::id());
1488        let mut child = waiting_shell_with(&marker);
1489        let pid = child.id();
1490        let exact = Process::find_by_name(&marker, NameMatch::Exact);
1491        let cmd = Process::find_by_name(&marker, NameMatch::Command);
1492        let _ = child.kill();
1493        let _ = child.wait();
1494
1495        // by default no process is named that, and the error says one has it
1496        // in its command line
1497        assert!(
1498            matches!(&exact, Err(ProcError::NameNotFound { name, in_command_line })
1499                if name == &marker && *in_command_line >= 1),
1500            "{exact:?}"
1501        );
1502        // with --cmd that process is the match, and the only one
1503        let found = cmd.unwrap();
1504        assert_eq!(found.iter().map(|p| p.pid).collect::<Vec<_>>(), vec![pid]);
1505    }
1506
1507    #[cfg(unix)]
1508    #[test]
1509    fn test_find_orphans_returns_ok() {
1510        // Should not error — may or may not find orphans depending on system state
1511        let result = Process::find_orphans();
1512        assert!(result.is_ok());
1513    }
1514
1515    #[cfg(not(unix))]
1516    #[test]
1517    fn find_orphans_is_not_supported_on_windows() {
1518        let result = Process::find_orphans();
1519        assert!(
1520            matches!(&result, Err(ProcError::NotSupported(m)) if m.starts_with("orphans is not supported on Windows")),
1521            "{result:?}"
1522        );
1523    }
1524
1525    #[cfg(unix)]
1526    #[test]
1527    fn test_is_system_process_system_paths() {
1528        let make_proc = |exe: Option<&str>, cwd: Option<&str>| Process {
1529            pid: 100,
1530            name: "test".to_string(),
1531            exe_path: exe.map(String::from),
1532            cwd: cwd.map(String::from),
1533            command: None,
1534            argv0: None,
1535            raw: None,
1536            cpu_percent: 0.0,
1537            memory_mb: 0.0,
1538            status: ProcessStatus::Running,
1539            user: None,
1540            parent_pid: Some(1),
1541            start_time: None,
1542            uptime_secs: None,
1543        };
1544
1545        // System daemons with cwd "/" and system exe paths
1546        assert!(Process::is_system_process(&make_proc(
1547            Some("/usr/sbin/sshd"),
1548            Some("/")
1549        )));
1550        assert!(Process::is_system_process(&make_proc(
1551            Some("/System/Library/foo"),
1552            Some("/")
1553        )));
1554        assert!(Process::is_system_process(&make_proc(
1555            Some("/usr/bin/systemd"),
1556            Some("/")
1557        )));
1558        assert!(Process::is_system_process(&make_proc(
1559            Some("/usr/lib/snapd/snapd"),
1560            Some("/")
1561        )));
1562
1563        // No exe path with cwd "/" = kernel thread
1564        assert!(Process::is_system_process(&make_proc(None, Some("/"))));
1565
1566        // No cwd at all = likely kernel thread
1567        assert!(Process::is_system_process(&make_proc(
1568            Some("/usr/bin/foo"),
1569            None
1570        )));
1571
1572        // Every system directory counts, each checked on its own
1573        for exe in [
1574            "/System/x",
1575            "/usr/libexec/x",
1576            "/usr/sbin/x",
1577            "/sbin/x",
1578            "/usr/bin/x",
1579            "/usr/lib/x",
1580            "/usr/lib64/x",
1581            "/lib/x",
1582            "/lib64/x",
1583            "/opt/x",
1584            "/snap/x",
1585        ] {
1586            assert!(
1587                Process::is_system_process(&make_proc(Some(exe), Some("/"))),
1588                "{exe}"
1589            );
1590        }
1591        // a daemon with cwd "/" outside them still counts: nothing says it is a user's
1592        assert!(Process::is_system_process(&make_proc(
1593            Some("/srv/x"),
1594            Some("/")
1595        )));
1596
1597        // User process with user cwd = NOT system
1598        assert!(!Process::is_system_process(&make_proc(
1599            Some("/usr/bin/node"),
1600            Some("/home/user/project")
1601        )));
1602        assert!(!Process::is_system_process(&make_proc(
1603            Some("/home/user/.local/bin/app"),
1604            Some("/home/user")
1605        )));
1606    }
1607
1608    #[cfg(unix)]
1609    #[test]
1610    fn test_parse_signal_name_valid() {
1611        use nix::sys::signal::Signal;
1612
1613        assert_eq!(parse_signal_name("HUP").unwrap(), Signal::SIGHUP);
1614        assert_eq!(parse_signal_name("hup").unwrap(), Signal::SIGHUP);
1615        assert_eq!(parse_signal_name("SIGHUP").unwrap(), Signal::SIGHUP);
1616        assert_eq!(parse_signal_name("sighup").unwrap(), Signal::SIGHUP);
1617        assert_eq!(parse_signal_name("INT").unwrap(), Signal::SIGINT);
1618        assert_eq!(parse_signal_name("QUIT").unwrap(), Signal::SIGQUIT);
1619        assert_eq!(parse_signal_name("ABRT").unwrap(), Signal::SIGABRT);
1620        assert_eq!(parse_signal_name("KILL").unwrap(), Signal::SIGKILL);
1621        assert_eq!(parse_signal_name("TERM").unwrap(), Signal::SIGTERM);
1622        assert_eq!(parse_signal_name("STOP").unwrap(), Signal::SIGSTOP);
1623        assert_eq!(parse_signal_name("CONT").unwrap(), Signal::SIGCONT);
1624        assert_eq!(parse_signal_name("USR1").unwrap(), Signal::SIGUSR1);
1625        assert_eq!(parse_signal_name("USR2").unwrap(), Signal::SIGUSR2);
1626    }
1627
1628    #[cfg(unix)]
1629    #[test]
1630    fn test_parse_signal_name_invalid() {
1631        assert!(parse_signal_name("INVALID").is_err());
1632        assert!(parse_signal_name("FOO").is_err());
1633        assert!(parse_signal_name("").is_err());
1634    }
1635
1636    #[cfg(unix)]
1637    #[test]
1638    fn test_parse_signal_name_case_insensitive() {
1639        use nix::sys::signal::Signal;
1640
1641        assert_eq!(parse_signal_name("term").unwrap(), Signal::SIGTERM);
1642        assert_eq!(parse_signal_name("Term").unwrap(), Signal::SIGTERM);
1643        assert_eq!(parse_signal_name("TERM").unwrap(), Signal::SIGTERM);
1644        assert_eq!(parse_signal_name("sigterm").unwrap(), Signal::SIGTERM);
1645        assert_eq!(parse_signal_name("SigTerm").unwrap(), Signal::SIGTERM);
1646    }
1647
1648    #[cfg(unix)]
1649    #[test]
1650    fn test_send_signal_nonexistent_process() {
1651        use nix::sys::signal::Signal;
1652
1653        // PID 99999999 almost certainly doesn't exist
1654        let proc = Process {
1655            pid: 99999999,
1656            name: "ghost".to_string(),
1657            exe_path: None,
1658            cwd: None,
1659            command: None,
1660            argv0: None,
1661            raw: None,
1662            cpu_percent: 0.0,
1663            memory_mb: 0.0,
1664            status: ProcessStatus::Running,
1665            user: None,
1666            parent_pid: None,
1667            start_time: None,
1668            uptime_secs: None,
1669        };
1670
1671        let result = proc.send_signal(Signal::SIGCONT);
1672        assert!(result.is_err());
1673    }
1674
1675    /// A record of a process that doesn't exist
1676    fn ghost() -> Process {
1677        Process {
1678            pid: 99999999,
1679            name: "ghost".to_string(),
1680            exe_path: None,
1681            cwd: None,
1682            command: None,
1683            argv0: None,
1684            raw: None,
1685            cpu_percent: 0.0,
1686            memory_mb: 0.0,
1687            status: ProcessStatus::Running,
1688            user: None,
1689            parent_pid: None,
1690            start_time: None,
1691            uptime_secs: None,
1692        }
1693    }
1694
1695    #[test]
1696    fn every_sysinfo_status_maps_to_ours() {
1697        for (sys, ours) in [
1698            (SysProcessStatus::Run, ProcessStatus::Running),
1699            (SysProcessStatus::Sleep, ProcessStatus::Sleeping),
1700            (SysProcessStatus::Stop, ProcessStatus::Stopped),
1701            (SysProcessStatus::Zombie, ProcessStatus::Zombie),
1702            (SysProcessStatus::Dead, ProcessStatus::Dead),
1703            (SysProcessStatus::Idle, ProcessStatus::Unknown),
1704        ] {
1705            assert_eq!(ProcessStatus::from(sys), ours, "{sys:?}");
1706        }
1707    }
1708
1709    #[test]
1710    fn a_process_that_is_gone_does_not_look_stuck() {
1711        assert!(!ghost().looks_stuck_now());
1712    }
1713
1714    #[test]
1715    fn killing_a_process_that_is_already_gone_is_success() {
1716        assert!(matches!(ghost().kill(), Err(ProcError::ProcessNotFound(_))));
1717        ghost().kill_and_wait().unwrap();
1718    }
1719
1720    #[test]
1721    fn a_kill_that_was_not_delivered_is_an_error() {
1722        kill_outcome(true, 7).unwrap();
1723        let err = kill_outcome(false, 7).unwrap_err();
1724        assert!(
1725            matches!(&err, ProcError::SignalError(m) if m == "Failed to kill process 7"),
1726            "{err:?}"
1727        );
1728    }
1729
1730    #[test]
1731    fn taskkill_without_force_reports_what_it_said() {
1732        #[cfg(unix)]
1733        use std::os::unix::process::ExitStatusExt;
1734        #[cfg(windows)]
1735        use std::os::windows::process::ExitStatusExt;
1736        use std::process::{ExitStatus, Output};
1737
1738        let output = |raw, stdout: &str, stderr: &str| Output {
1739            status: ExitStatus::from_raw(raw),
1740            stdout: stdout.as_bytes().to_vec(),
1741            stderr: stderr.as_bytes().to_vec(),
1742        };
1743        taskkill_outcome(&output(0, "SUCCESS: sent termination signal", "")).unwrap();
1744
1745        // exit code 1 on Windows; the same wait status on Unix
1746        #[cfg(unix)]
1747        let failed = 1 << 8;
1748        #[cfg(windows)]
1749        let failed = 1;
1750        let err =
1751            taskkill_outcome(&output(failed, "", "ERROR: could not be terminated")).unwrap_err();
1752        assert!(
1753            matches!(&err, ProcError::SignalError(m) if m == "ERROR: could not be terminated"),
1754            "{err:?}"
1755        );
1756    }
1757
1758    #[cfg(unix)]
1759    #[test]
1760    fn orphans_are_the_children_of_init_that_are_not_system_daemons() {
1761        let proc = |pid: u32, ppid: Option<u32>, exe: Option<&str>, cwd: Option<&str>| Process {
1762            pid,
1763            name: format!("p{pid}"),
1764            exe_path: exe.map(String::from),
1765            cwd: cwd.map(String::from),
1766            parent_pid: ppid,
1767            ..ghost()
1768        };
1769        let orphans = Process::orphans_among(vec![
1770            // a user's process whose parent died
1771            proc(10, Some(1), Some("/home/z/app"), Some("/home/z")),
1772            // a system daemon: cwd "/" and an exe in a system directory
1773            proc(11, Some(1), Some("/usr/sbin/sshd"), Some("/")),
1774            // init itself, and a process with a live parent
1775            proc(1, Some(1), Some("/sbin/init"), Some("/home/z")),
1776            proc(12, Some(500), Some("/home/z/app"), Some("/home/z")),
1777            // no parent known
1778            proc(13, None, Some("/home/z/app"), Some("/home/z")),
1779        ]);
1780        let pids: Vec<u32> = orphans.iter().map(|p| p.pid).collect();
1781        assert_eq!(pids, vec![10]);
1782    }
1783
1784    #[cfg(unix)]
1785    #[test]
1786    fn a_missing_lsof_finds_no_open_file_owners() {
1787        let found = Process::find_by_open_file_with("proc-no-such-lsof", std::path::Path::new("/"));
1788        assert!(found.unwrap().is_empty());
1789    }
1790
1791    #[test]
1792    fn a_wait_for_a_process_that_stays_is_a_wait_and_then_an_error() {
1793        let mut child = crate::test_support::idle_child();
1794        let record = crate::test_support::record_of(&child);
1795        let started = std::time::Instant::now();
1796        // a kill that does nothing: the process is still there, however long
1797        // it is looked at
1798        let result = record.kill_then_wait(Duration::from_millis(300), |_| Ok(()));
1799        let waited = started.elapsed();
1800        let still_there = child.try_wait().unwrap().is_none();
1801        crate::test_support::end(&mut child);
1802        assert!(
1803            matches!(&result, Err(ProcError::SignalError(_))),
1804            "{result:?}"
1805        );
1806        assert!(waited >= Duration::from_millis(300), "{waited:?}");
1807        assert!(still_there);
1808    }
1809
1810    #[test]
1811    fn a_process_still_alive_when_the_wait_ends_is_an_error() {
1812        let mut child = crate::test_support::idle_child();
1813        let record = crate::test_support::record_of(&child);
1814        // no time to wait: the SIGKILL is sent, but the process isn't seen gone
1815        let result = record.kill_and_wait_within(Duration::ZERO);
1816        let _ = child.kill();
1817        let _ = child.wait();
1818        assert!(
1819            matches!(&result, Err(ProcError::SignalError(m))
1820                if m == &format!("Process {} is still running 0 seconds after SIGKILL", record.pid)),
1821            "{result:?}"
1822        );
1823    }
1824
1825    /// One kinfo_proc record, with the fields `zombies_in` reads
1826    #[cfg(target_os = "macos")]
1827    fn kinfo(pid: u32, stat: u8, comm: &str, ruid: u32, ppid: u32, start: u64) -> Vec<u8> {
1828        let mut rec = vec![0u8; KINFO_PROC_SIZE];
1829        rec[0..8].copy_from_slice(&start.to_ne_bytes());
1830        rec[36] = stat;
1831        rec[40..44].copy_from_slice(&pid.to_ne_bytes());
1832        rec[243..243 + comm.len()].copy_from_slice(comm.as_bytes());
1833        rec[392..396].copy_from_slice(&ruid.to_ne_bytes());
1834        rec[560..564].copy_from_slice(&ppid.to_ne_bytes());
1835        rec
1836    }
1837
1838    #[cfg(target_os = "macos")]
1839    #[test]
1840    fn zombies_are_read_from_the_kernels_table() {
1841        let known = std::collections::HashSet::from([30]);
1842        let table: Vec<u8> = [
1843            kinfo(10, 5, "defunct-app", 0, 99, 1_700_000_000),
1844            // no name left, and no start time
1845            kinfo(11, 5, "", 0, 1, 0),
1846            // alive, not a zombie
1847            kinfo(20, 2, "alive", 0, 1, 1_700_000_000),
1848            // already listed by sysinfo
1849            kinfo(30, 5, "known", 0, 1, 1_700_000_000),
1850            // the kernel's own slot
1851            kinfo(0, 5, "kernel", 0, 0, 0),
1852        ]
1853        .concat();
1854
1855        let zombies = zombies_in(&table, &known);
1856        let pids: Vec<u32> = zombies.iter().map(|z| z.pid).collect();
1857        assert_eq!(pids, vec![10, 11]);
1858
1859        let named = &zombies[0];
1860        assert_eq!(named.name, "defunct-app");
1861        assert_eq!(named.status, ProcessStatus::Zombie);
1862        assert_eq!(named.parent_pid, Some(99));
1863        assert_eq!(named.start_time, Some(1_700_000_000));
1864        assert!(named.uptime_secs.unwrap() > 0);
1865        assert_eq!(named.user.as_deref(), Some("root"));
1866
1867        let unnamed = &zombies[1];
1868        assert_eq!(unnamed.name, "<defunct>");
1869        assert_eq!(unnamed.start_time, None);
1870        assert_eq!(unnamed.uptime_secs, None);
1871    }
1872
1873    #[cfg(target_os = "macos")]
1874    #[test]
1875    fn a_table_of_an_unknown_layout_shows_no_zombies() {
1876        let known = std::collections::HashSet::new();
1877        let mut table = kinfo(10, 5, "z", 0, 1, 0);
1878        table.push(0);
1879        assert!(zombies_in(&table, &known).is_empty());
1880    }
1881
1882    #[cfg(target_os = "macos")]
1883    #[test]
1884    fn a_refused_sysctl_gives_an_empty_table() {
1885        use std::cell::Cell;
1886
1887        // refused when asked for the size
1888        assert!(read_proc_table(|_, _| -1).is_empty());
1889
1890        // refused when asked for the table
1891        let calls = Cell::new(0);
1892        let table = read_proc_table(|_, size| {
1893            calls.set(calls.get() + 1);
1894            if calls.get() == 1 {
1895                *size = 2 * KINFO_PROC_SIZE;
1896                0
1897            } else {
1898                -1
1899            }
1900        });
1901        assert_eq!(calls.get(), 2);
1902        assert!(table.is_empty());
1903
1904        // answered: the table is cut to the size the kernel reports
1905        let calls = Cell::new(0);
1906        let table = read_proc_table(|buf, size| {
1907            calls.set(calls.get() + 1);
1908            if calls.get() == 1 {
1909                *size = 2 * KINFO_PROC_SIZE;
1910            } else {
1911                assert_eq!(*size, 66 * KINFO_PROC_SIZE, "room for 64 newcomers");
1912                // SAFETY: `buf` has `*size` writable bytes
1913                unsafe { buf.cast::<u8>().write(7) };
1914                *size = KINFO_PROC_SIZE;
1915            }
1916            0
1917        });
1918        assert_eq!(table.len(), KINFO_PROC_SIZE);
1919        assert_eq!(table[0], 7);
1920    }
1921}