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