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}
390
391/// How a name relates to a process sysinfo listed, under `mode`. Reads the
392/// name, executable and command line straight from sysinfo, so no record is
393/// built for the many processes that don't match.
394pub(crate) fn classify_sysinfo(pattern: &str, proc: &sysinfo::Process, mode: NameMatch) -> Hit {
395    let name = proc.name().to_string_lossy();
396    let exe = proc.exe().map(|exe| exe.to_string_lossy());
397    let argv0 = proc.cmd().first().map(|arg| arg.to_string_lossy());
398    // the joined command line is only needed to search inside it
399    let command = (!proc.cmd().is_empty()).then(|| {
400        proc.cmd()
401            .iter()
402            .map(|arg| arg.to_string_lossy())
403            .collect::<Vec<_>>()
404            .join(" ")
405    });
406    classify(
407        pattern,
408        &Names {
409            name: &name,
410            exe: exe.as_deref(),
411            argv0: argv0.as_deref(),
412            command: command.as_deref(),
413        },
414        mode,
415    )
416}
417
418impl Process {
419    /// The names a name target is compared with
420    pub fn names(&self) -> Names<'_> {
421        match &self.raw {
422            Some(raw) => Names {
423                name: &raw.name,
424                exe: raw.exe_path.as_deref(),
425                argv0: self.argv0.as_deref(),
426                command: raw.command.as_deref(),
427            },
428            None => Names {
429                name: &self.name,
430                exe: self.exe_path.as_deref(),
431                argv0: self.argv0.as_deref(),
432                command: self.command.as_deref(),
433            },
434        }
435    }
436
437    /// Find all processes a name matches (see [`NameMatch`]): by default the
438    /// whole name, with `--cmd` any part of the name or command line.
439    ///
440    /// Proc itself and its ancestor shells never match. When nothing matches by
441    /// name but processes have the text in their command line, the error says
442    /// so: it costs nothing, the same pass over the processes counts them.
443    pub fn find_by_name(pattern: &str, mode: NameMatch) -> Result<Vec<Process>> {
444        let mut sys = System::new_all();
445        sys.refresh_all();
446
447        // Exclude the entire ancestor chain — their command lines contain proc's
448        // arguments (e.g. "zsh -c proc by node"), causing false positives
449        let ancestor_pids = self_and_ancestors(&sys);
450        let mut in_command_line_only = 0;
451        let processes: Vec<Process> = sys
452            .processes()
453            .iter()
454            .filter_map(|(pid, proc)| {
455                // Exclude self, ancestor shells, and Linux threads
456                if ancestor_pids.contains(pid) || is_thread(proc) {
457                    return None;
458                }
459                match classify_sysinfo(pattern, proc, mode) {
460                    Hit::Match => Some(Process::from_sysinfo(*pid, proc)),
461                    Hit::CommandLineOnly => {
462                        in_command_line_only += 1;
463                        None
464                    }
465                    Hit::No => None,
466                }
467            })
468            .collect();
469
470        if processes.is_empty() {
471            return Err(if in_command_line_only > 0 {
472                ProcError::NameNotFound {
473                    name: pattern.to_string(),
474                    in_command_line: in_command_line_only,
475                }
476            } else {
477                ProcError::ProcessNotFound(pattern.to_string())
478            });
479        }
480
481        Ok(processes)
482    }
483
484    /// Find a specific process by PID
485    pub fn find_by_pid(pid: u32) -> Result<Option<Process>> {
486        let mut sys = System::new_all();
487        sys.refresh_all();
488
489        let sysinfo_pid = Pid::from_u32(pid);
490
491        Ok(sys
492            .processes()
493            .get(&sysinfo_pid)
494            .map(|proc| Process::from_sysinfo(sysinfo_pid, proc)))
495    }
496
497    /// Get all running processes (excludes proc's own process)
498    pub fn find_all() -> Result<Vec<Process>> {
499        let mut sys = System::new_all();
500        sys.refresh_all();
501
502        let self_pid = sysinfo::Pid::from_u32(std::process::id());
503        #[allow(unused_mut)]
504        let mut processes: Vec<Process> = sys
505            .processes()
506            .iter()
507            .filter(|(pid, proc)| **pid != self_pid && !is_thread(proc))
508            .map(|(pid, proc)| Process::from_sysinfo(*pid, proc))
509            .collect();
510
511        #[cfg(target_os = "macos")]
512        {
513            let known: std::collections::HashSet<u32> = processes.iter().map(|p| p.pid).collect();
514            processes.extend(macos_zombies(&known));
515        }
516
517        Ok(processes)
518    }
519
520    /// Find processes running a specific executable path
521    pub fn find_by_exe_path(path: &std::path::Path) -> Result<Vec<Process>> {
522        let all = Self::find_all()?;
523        let path_str = path.to_string_lossy();
524
525        Ok(all
526            .into_iter()
527            .filter(|p| {
528                if let Some(ref exe) = p.exe_path {
529                    // Exact match (compare as strings to avoid PathBuf allocation)
530                    exe == &*path_str || std::path::Path::new(exe) == path
531                } else {
532                    false
533                }
534            })
535            .collect())
536    }
537
538    /// Find processes that have a file open (Unix only via lsof)
539    #[cfg(unix)]
540    pub fn find_by_open_file(path: &std::path::Path) -> Result<Vec<Process>> {
541        Self::find_by_open_file_with("lsof", path)
542    }
543
544    /// `find_by_open_file` with the lsof program to run, so a test can have it missing
545    #[cfg(unix)]
546    fn find_by_open_file_with(lsof: &str, path: &std::path::Path) -> Result<Vec<Process>> {
547        use std::process::Command;
548
549        let output = Command::new(lsof)
550            .args(["-t", &path.to_string_lossy()]) // -t = terse (PIDs only)
551            .output();
552
553        let output = match output {
554            Ok(o) => o,
555            Err(_) => return Ok(vec![]), // lsof not available
556        };
557
558        if !output.status.success() {
559            return Ok(vec![]); // No processes have file open
560        }
561
562        let pids: Vec<u32> = String::from_utf8_lossy(&output.stdout)
563            .lines()
564            .filter_map(|line| line.trim().parse().ok())
565            .collect();
566
567        let mut processes = Vec::new();
568        for pid in pids {
569            if let Ok(Some(proc)) = Self::find_by_pid(pid) {
570                processes.push(proc);
571            }
572        }
573
574        Ok(processes)
575    }
576
577    /// Find processes that have a file open (Windows stub)
578    #[cfg(not(unix))]
579    pub fn find_by_open_file(_path: &std::path::Path) -> Result<Vec<Process>> {
580        // Windows: Could use handle.exe from Sysinternals, but skip for now
581        Ok(vec![])
582    }
583
584    /// Find processes that look stuck: running longer than `timeout`, and in each
585    /// of two readings a second apart either using over half a core or blocked in
586    /// uninterruptible sleep. One reading
587    /// used to be enough, so a momentary spike counted. A long build at full CPU
588    /// still looks the same as a loop that never ends; only the user can tell.
589    pub fn find_stuck(timeout: Duration) -> Result<Vec<Process>> {
590        let timeout_secs = timeout.as_secs();
591        // each read is timed from its start, as in `measure_cpu`
592        let mut read_at = std::time::Instant::now();
593        let mut sys = System::new_all();
594        let mut used = cpu_used(&sys);
595        let mut cpu: std::collections::HashMap<Pid, f32> = std::collections::HashMap::new();
596        let mut stuck: Option<std::collections::HashSet<Pid>> = None;
597
598        for _ in 0..STUCK_READINGS {
599            std::thread::sleep(STUCK_READING_GAP);
600            let started = std::time::Instant::now();
601            sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
602            let wall = started - read_at;
603            read_at = started;
604            // a process first seen in this read has no CPU figure yet
605            cpu = sys
606                .processes()
607                .iter()
608                .filter_map(|(pid, p)| {
609                    let before = used.get(pid)?;
610                    Some((
611                        *pid,
612                        cpu_percent_between(*before, p.accumulated_cpu_time(), wall),
613                    ))
614                })
615                .collect();
616            used = cpu_used(&sys);
617            let now: std::collections::HashSet<Pid> = sys
618                .processes()
619                .iter()
620                .filter(|(pid, p)| {
621                    let busy = cpu.get(pid).copied().unwrap_or(0.0);
622                    counts_as_stuck(
623                        is_thread(p),
624                        p.run_time(),
625                        timeout_secs,
626                        looks_stuck(busy, p.status()),
627                    )
628                })
629                .map(|(pid, _)| *pid)
630                .collect();
631            stuck = Some(match stuck {
632                None => now,
633                Some(before) => before.intersection(&now).copied().collect(),
634            });
635        }
636
637        let stuck = stuck.unwrap_or_default();
638        Ok(sys
639            .processes()
640            .iter()
641            .filter(|(pid, _)| stuck.contains(pid))
642            .map(|(pid, p)| {
643                let mut process = Process::from_sysinfo(*pid, p);
644                // the figure the last read measured, not sysinfo's own
645                if let Some(&busy) = cpu.get(pid) {
646                    process.cpu_percent = busy;
647                }
648                process
649            })
650            .collect())
651    }
652
653    /// Whether this process looks stuck now, measured the way `find_stuck` does
654    /// (stuck in two readings a second apart), for a process named as a target.
655    pub fn looks_stuck_now(&self) -> bool {
656        let pid = Pid::from_u32(self.pid);
657        let mut sys = System::new();
658        let mut read_at = std::time::Instant::now();
659        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
660        let mut used = sys
661            .process(pid)
662            .filter(|p| self.is_same(p))
663            .map(|p| p.accumulated_cpu_time());
664        (0..STUCK_READINGS).all(|_| {
665            std::thread::sleep(STUCK_READING_GAP);
666            let started = std::time::Instant::now();
667            sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
668            let wall = started - read_at;
669            read_at = started;
670            let (Some(before), Some(p)) = (used, sys.process(pid).filter(|p| self.is_same(p)))
671            else {
672                return false;
673            };
674            let after = p.accumulated_cpu_time();
675            used = Some(after);
676            looks_stuck(cpu_percent_between(before, after, wall), p.status())
677        })
678    }
679
680    /// CPU use over `window` (at least sysinfo's minimum), in percent of one core;
681    /// None if the process is gone. A reading over a few milliseconds is ~0 for
682    /// any process, so a real window is needed.
683    ///
684    /// Unix only (the method does not exist on Windows): its one caller is the gentle recovery `unstick` tries there;
685    /// on Windows `unstick` only terminates.
686    #[cfg(unix)]
687    pub fn cpu_over(&self, window: Duration) -> Option<f32> {
688        let pid = Pid::from_u32(self.pid);
689        let mut sys = System::new();
690        let started = std::time::Instant::now();
691        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
692        let before = sys
693            .process(pid)
694            .filter(|p| self.is_same(p))
695            .map(|p| p.accumulated_cpu_time())?;
696        std::thread::sleep(window.max(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL));
697        let wall = started.elapsed();
698        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
699        sys.process(pid)
700            .filter(|p| self.is_same(p))
701            .map(|p| cpu_percent_between(before, p.accumulated_cpu_time(), wall))
702    }
703
704    /// Measure the CPU use of these processes over 0.2 s and set `cpu_percent`,
705    /// in percent of one core. A lookup reads CPU a few milliseconds after its
706    /// first refresh, which is ~0% even for a process using a whole core, so
707    /// commands call this before they show, filter or sort by CPU. One 0.2 s wait
708    /// covers the whole batch; commands that never show CPU skip it.
709    ///
710    /// CPU is the CPU time each process used between two reads, over the wall
711    /// time we measure between them. sysinfo's own `cpu_usage` divides by its
712    /// system-wide tick interval instead, which read about 2% for a process
713    /// spinning at 100% in some runs.
714    pub fn measure_cpu(processes: &mut [Process]) {
715        if processes.is_empty() {
716            return;
717        }
718        let pids: Vec<Pid> = processes.iter().map(|p| Pid::from_u32(p.pid)).collect();
719        let which = sysinfo::ProcessesToUpdate::Some(&pids);
720        let cpu_only = sysinfo::ProcessRefreshKind::nothing().with_cpu();
721        let mut sys = System::new();
722
723        let first = std::time::Instant::now();
724        sys.refresh_processes_specifics(which, true, cpu_only);
725        let before = cpu_used(&sys);
726        std::thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL);
727        // each refresh reads the processes in the same order, so timing both
728        // from their start gives every process the same window
729        let wall = first.elapsed();
730        sys.refresh_processes_specifics(which, true, cpu_only);
731
732        for process in processes.iter_mut() {
733            let pid = Pid::from_u32(process.pid);
734            let (Some(current), Some(&cpu_before)) = (sys.process(pid), before.get(&pid)) else {
735                continue;
736            };
737            if process.is_same(current) {
738                process.cpu_percent =
739                    cpu_percent_between(cpu_before, current.accumulated_cpu_time(), wall);
740            }
741        }
742    }
743
744    /// Force kill the process (SIGKILL on Unix, taskkill /F on Windows)
745    pub fn kill(&self) -> Result<()> {
746        let mut sys = System::new();
747        sys.refresh_processes(
748            sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(self.pid)]),
749            true,
750        );
751
752        match sys.process(Pid::from_u32(self.pid)) {
753            Some(proc) if self.is_same(proc) => kill_outcome(proc.kill(), self.pid),
754            // gone, or the PID now belongs to a new process
755            _ => Err(ProcError::ProcessNotFound(self.pid.to_string())),
756        }
757    }
758
759    /// Whether `current`, the process now holding this PID, is the one this record
760    /// describes. A different start time means the PID was reused by a new process.
761    fn is_same(&self, current: &sysinfo::Process) -> bool {
762        match self.start_time {
763            Some(ours) if current.start_time() > 0 => current.start_time().abs_diff(ours) <= 1,
764            _ => true,
765        }
766    }
767
768    /// Checked right before each signal, so a delayed one (stop's SIGKILL comes up
769    /// to --timeout seconds after the target was resolved) can't reach a new
770    /// process that reused the PID.
771    fn ensure_same_process(&self) -> Result<()> {
772        let mut sys = System::new();
773        let pid = Pid::from_u32(self.pid);
774        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
775        match sys.process(pid) {
776            Some(current) if self.is_same(current) => Ok(()),
777            _ => Err(ProcError::ProcessNotFound(self.pid.to_string())),
778        }
779    }
780
781    /// Force kill and wait up to 5 seconds for the process to be gone.
782    ///
783    /// Never blocks forever (sysinfo's own kill_and_wait polls with no limit and
784    /// never ends for a zombie). A process still alive after 5 seconds, such as
785    /// one stuck in the kernel, is an error.
786    pub fn kill_and_wait(&self) -> Result<()> {
787        self.kill_and_wait_within(Duration::from_secs(5))
788    }
789
790    /// `kill_and_wait` with the time to wait for the process to be gone
791    fn kill_and_wait_within(&self, wait: Duration) -> Result<()> {
792        self.kill_then_wait(wait, Process::kill)
793    }
794
795    /// `kill_and_wait_within` with the kill to send, so a test can send one that
796    /// leaves the process running (a real kill is over before it returns on
797    /// Windows, so the wait would never be needed)
798    fn kill_then_wait(&self, wait: Duration, kill: impl Fn(&Process) -> Result<()>) -> Result<()> {
799        match kill(self) {
800            // it exited on its own just before the kill
801            Err(ProcError::ProcessNotFound(_)) => return Ok(()),
802            other => other?,
803        }
804        let deadline = std::time::Instant::now() + wait;
805        while std::time::Instant::now() < deadline {
806            if !self.is_running() {
807                return Ok(());
808            }
809            std::thread::sleep(Duration::from_millis(50));
810        }
811        Err(ProcError::SignalError(format!(
812            "Process {} is still running {} seconds after SIGKILL",
813            self.pid,
814            wait.as_secs()
815        )))
816    }
817
818    /// Send SIGTERM for graceful termination (Unix) or taskkill (Windows)
819    #[cfg(unix)]
820    pub fn terminate(&self) -> Result<()> {
821        use nix::sys::signal::{kill, Signal};
822        use nix::unistd::Pid as NixPid;
823
824        self.ensure_same_process()?;
825        kill(NixPid::from_raw(self.pid as i32), Signal::SIGTERM)
826            .map_err(|e| ProcError::SignalError(e.to_string()))
827    }
828
829    /// Graceful termination (Windows): `taskkill` without `/F` asks the
830    /// process to close its windows, so it fails for a console program, which
831    /// has none. That failure is returned, with taskkill's reason, rather than
832    /// reported as success.
833    #[cfg(windows)]
834    pub fn terminate(&self) -> Result<()> {
835        use std::process::Command;
836
837        self.ensure_same_process()?;
838        let output = Command::new("taskkill")
839            .args(["/PID", &self.pid.to_string()])
840            .output()
841            .map_err(|e| ProcError::SystemError(e.to_string()))?;
842
843        taskkill_outcome(&output)
844    }
845
846    /// Send an arbitrary signal to the process (Unix only)
847    #[cfg(unix)]
848    pub fn send_signal(&self, signal: nix::sys::signal::Signal) -> Result<()> {
849        use nix::sys::signal::kill;
850        use nix::unistd::Pid as NixPid;
851        self.ensure_same_process()?;
852        kill(NixPid::from_raw(self.pid as i32), signal)
853            .map_err(|e| ProcError::SignalError(format!("{}: {}", signal, e)))
854    }
855
856    // Note: On Windows, send_signal is not available. Commands that use it
857    // (freeze, thaw) have their own #[cfg(not(unix))] stubs that return NotSupported.
858
859    /// Find orphaned processes (parent is PID 1 / init / launchd, excluding system daemons)
860    ///
861    /// Unix only: on Windows this returns `ProcError::NotSupported`, because
862    /// Windows has no PID 1 and does not re-parent a process whose parent exited,
863    /// so an empty list would claim "no orphans" where nothing could be checked.
864    #[cfg(unix)]
865    pub fn find_orphans() -> Result<Vec<Process>> {
866        Ok(Self::orphans_among(Self::find_all()?))
867    }
868
869    /// Windows: orphans cannot be told, so this is an error, not an empty list
870    #[cfg(not(unix))]
871    pub fn find_orphans() -> Result<Vec<Process>> {
872        Err(orphans_not_supported())
873    }
874
875    /// The orphans among `all`
876    #[cfg(unix)]
877    fn orphans_among(all: Vec<Process>) -> Vec<Process> {
878        all.into_iter()
879            .filter(|p| {
880                if let Some(ppid) = p.parent_pid {
881                    ppid == 1 && p.pid != 1 && !Self::is_system_process(p)
882                } else {
883                    false
884                }
885            })
886            .collect()
887    }
888
889    /// Check if a process is a system daemon (naturally has PPID 1)
890    ///
891    /// Filters out processes that legitimately have PPID 1 — system daemons,
892    /// kernel threads, and services managed by init/systemd/launchd.
893    /// Heuristic: no cwd or cwd is "/" with exe in system paths.
894    #[cfg(unix)]
895    fn is_system_process(p: &Process) -> bool {
896        if p.cwd.is_none() || p.cwd.as_deref() == Some("/") {
897            if let Some(ref exe) = p.exe_path {
898                // macOS system paths
899                if exe.starts_with("/System/") || exe.starts_with("/usr/libexec/") {
900                    return true;
901                }
902                // Shared Unix system paths (macOS + Linux)
903                if exe.starts_with("/usr/sbin/")
904                    || exe.starts_with("/sbin/")
905                    || exe.starts_with("/usr/bin/")
906                    || exe.starts_with("/usr/lib/")
907                    || exe.starts_with("/usr/lib64/")
908                    || exe.starts_with("/lib/")
909                    || exe.starts_with("/lib64/")
910                    || exe.starts_with("/opt/")
911                    || exe.starts_with("/snap/")
912                {
913                    return true;
914                }
915            }
916            return true; // No exe path = likely kernel thread
917        }
918        false
919    }
920
921    /// Check if the process is still alive. A zombie (exited, not yet reaped by
922    /// its parent) has exited, and a process with a different start time is a new
923    /// one that reused the PID, so neither counts.
924    pub fn exists(&self) -> bool {
925        let mut sys = System::new();
926        let pid = Pid::from_u32(self.pid);
927        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
928        match sys.process(pid) {
929            None => false,
930            Some(current) if current.status() == SysProcessStatus::Zombie => false,
931            Some(current) => self.is_same(current),
932        }
933    }
934
935    /// Check if the process is still running (alias for exists for compatibility)
936    pub fn is_running(&self) -> bool {
937        self.exists()
938    }
939
940    /// Convert from sysinfo Process
941    pub(crate) fn from_sysinfo(pid: Pid, proc: &sysinfo::Process) -> Self {
942        let cmd_vec = proc.cmd();
943        // Every text field is set by whoever runs the process: escape control
944        // characters before anything prints them. Name matching reads the raw
945        // text (`raw`, kept only when escaping changed something).
946        let raw_command = (!cmd_vec.is_empty()).then(|| {
947            cmd_vec
948                .iter()
949                .map(|s| s.to_string_lossy())
950                .collect::<Vec<_>>()
951                .join(" ")
952        });
953        let raw_name = proc.name().to_string_lossy();
954        let raw_exe = proc.exe().map(|p| p.to_string_lossy());
955        let changed = |text: &str| matches!(escape_control(text), std::borrow::Cow::Owned(_));
956        let raw = (changed(&raw_name)
957            || raw_exe.as_deref().is_some_and(changed)
958            || raw_command.as_deref().is_some_and(changed))
959        .then(|| {
960            Box::new(RawText {
961                name: raw_name.to_string(),
962                exe_path: raw_exe.as_ref().map(|exe| exe.to_string()),
963                command: raw_command.clone(),
964            })
965        });
966        let command = raw_command
967            .as_deref()
968            .map(|joined| escape_control(joined).into_owned());
969
970        let exe_path = raw_exe
971            .as_deref()
972            .map(|exe| escape_control(exe).into_owned());
973        let cwd = proc.cwd().map(|p| {
974            let path = strip_deleted_marker(p.to_string_lossy().to_string());
975            escape_control(&path).into_owned()
976        });
977
978        Process {
979            pid: pid.as_u32(),
980            name: escape_control(&raw_name).into_owned(),
981            exe_path,
982            cwd,
983            command,
984            argv0: cmd_vec
985                .first()
986                .map(|arg| arg.to_string_lossy().into_owned()),
987            raw,
988            cpu_percent: proc.cpu_usage(),
989            memory_mb: proc.memory() as f64 / 1024.0 / 1024.0,
990            status: ProcessStatus::from(proc.status()),
991            user: proc
992                .user_id()
993                .map(|uid| escape_control(&user_name(uid)).into_owned()),
994            parent_pid: proc.parent().map(|p| p.as_u32()),
995            // sysinfo reports 0 when it can't read the start time (on macOS, any
996            // other user's process), which would make the uptime about 56 years
997            start_time: (proc.start_time() > 0).then(|| proc.start_time()),
998            uptime_secs: (proc.start_time() > 0).then(|| proc.run_time()),
999        }
1000    }
1001}
1002
1003/// Parse a signal name to a nix Signal (Unix only)
1004///
1005/// Accepts: "HUP", "SIGHUP", "hup" (signal names only, not numbers —
1006/// numeric signal values differ between macOS and Linux)
1007#[cfg(unix)]
1008pub fn parse_signal_name(name: &str) -> Result<nix::sys::signal::Signal> {
1009    use nix::sys::signal::Signal;
1010
1011    let upper = name.to_uppercase();
1012    let upper = upper.trim_start_matches("SIG");
1013    match upper {
1014        "HUP" => Ok(Signal::SIGHUP),
1015        "INT" => Ok(Signal::SIGINT),
1016        "QUIT" => Ok(Signal::SIGQUIT),
1017        "ABRT" => Ok(Signal::SIGABRT),
1018        "KILL" => Ok(Signal::SIGKILL),
1019        "TERM" => Ok(Signal::SIGTERM),
1020        "STOP" => Ok(Signal::SIGSTOP),
1021        "CONT" => Ok(Signal::SIGCONT),
1022        "USR1" => Ok(Signal::SIGUSR1),
1023        "USR2" => Ok(Signal::SIGUSR2),
1024        _ => Err(ProcError::InvalidInput(format!(
1025            "Unknown signal: '{}'. Valid signals: HUP, INT, QUIT, ABRT, KILL, TERM, STOP, CONT, USR1, USR2",
1026            name
1027        ))),
1028    }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033    use super::*;
1034
1035    #[test]
1036    fn cpu_is_the_cpu_time_used_over_the_wall_time() {
1037        let second = Duration::from_secs(1);
1038        // a full core for a second, half a core, and nothing
1039        assert_eq!(cpu_percent_between(5_000, 6_000, second), 100.0);
1040        assert_eq!(cpu_percent_between(5_000, 5_500, second), 50.0);
1041        assert_eq!(cpu_percent_between(5_000, 5_000, second), 0.0);
1042        // two cores' worth reads 200%, in percent of one core
1043        assert_eq!(cpu_percent_between(0, 2_000, second), 200.0);
1044        // a counter that went backwards (a reused PID) or no time passing is 0
1045        assert_eq!(cpu_percent_between(6_000, 5_000, second), 0.0);
1046        assert_eq!(cpu_percent_between(0, 1_000, Duration::ZERO), 0.0);
1047    }
1048
1049    #[test]
1050    fn a_failed_command_is_explained_in_its_own_words() {
1051        let reason = "ERROR: The process with PID 7 could not be terminated.";
1052        assert_eq!(
1053            command_failure(b"", reason.as_bytes(), "exit code: 1"),
1054            reason
1055        );
1056        // stdout when stderr is empty, trimmed
1057        assert_eq!(
1058            command_failure(b"  not here\r\n", b"", "exit code: 1"),
1059            "not here"
1060        );
1061        // taskkill wraps its reason over several lines; the error keeps them on one
1062        assert_eq!(
1063            command_failure(
1064                b"",
1065                b"ERROR: first part\r\n  second part.\r\n\r\n",
1066                "exit code: 1"
1067            ),
1068            "ERROR: first part second part."
1069        );
1070        assert_eq!(
1071            command_failure(b"", b"  \n", "exit code: 128"),
1072            "exited with exit code: 128"
1073        );
1074    }
1075
1076    #[test]
1077    fn busy_means_over_half_a_core_or_blocked_in_the_kernel() {
1078        assert!(looks_stuck(50.1, SysProcessStatus::Run));
1079        assert!(!looks_stuck(50.0, SysProcessStatus::Run));
1080        assert!(!looks_stuck(0.0, SysProcessStatus::Sleep));
1081        assert!(looks_stuck(0.0, SysProcessStatus::UninterruptibleDiskSleep));
1082    }
1083
1084    #[test]
1085    fn a_reading_counts_as_stuck_from_the_timeout_on() {
1086        assert!(counts_as_stuck(false, 0, 0, true));
1087        assert!(!counts_as_stuck(false, 59, 60, true));
1088        assert!(counts_as_stuck(false, 60, 60, true));
1089        assert!(counts_as_stuck(false, 61, 60, true));
1090        // and only a busy process, never a thread
1091        assert!(!counts_as_stuck(false, 60, 60, false));
1092        assert!(!counts_as_stuck(true, 60, 60, true));
1093    }
1094
1095    #[test]
1096    fn a_process_that_ran_the_timeout_has_run_long_enough() {
1097        // --timeout 0 finds a process that reads 0 seconds old
1098        assert!(ran_long_enough(0, 0));
1099        assert!(ran_long_enough(5, 5));
1100        assert!(ran_long_enough(6, 5));
1101        assert!(!ran_long_enough(4, 5));
1102    }
1103
1104    #[test]
1105    fn test_find_all_processes() {
1106        let processes = Process::find_all().unwrap();
1107        assert!(!processes.is_empty(), "Should find at least one process");
1108    }
1109
1110    #[test]
1111    fn test_find_by_pid_self() {
1112        let pid = std::process::id();
1113        let process = Process::find_by_pid(pid).unwrap();
1114        assert!(process.is_some(), "Should find own process");
1115    }
1116
1117    #[test]
1118    fn test_is_running_tells_a_reused_pid_apart() {
1119        let mut me = Process::find_by_pid(std::process::id()).unwrap().unwrap();
1120        assert!(me.is_running());
1121        // Same PID, different start time: a new process that reused the PID
1122        let start = me.start_time.expect("a running process has a start time");
1123        me.start_time = Some(start - 3600);
1124        assert!(!me.is_running(), "a reused PID counted as the same process");
1125    }
1126
1127    #[test]
1128    fn test_escape_control_makes_terminal_input_harmless() {
1129        // Ordinary text, including non-ASCII, is untouched
1130        assert_eq!(escape_control("node server.js"), "node server.js");
1131        assert_eq!(escape_control("café ✓ 日本"), "café ✓ 日本");
1132        // ESC sequences: colours, window title (OSC), clipboard (OSC 52)
1133        assert_eq!(escape_control("a\x1b[31mb"), "a\\x1b[31mb");
1134        assert_eq!(escape_control("\x1b]0;pwned\x07"), "\\x1b]0;pwned\\x07");
1135        // Line breaks and tabs would split or shift a table row
1136        assert_eq!(escape_control("one\ntwo\tthree\r"), "one\\ntwo\\tthree\\r");
1137        // DEL, and C1 controls such as the 8-bit CSI (U+009B)
1138        assert_eq!(escape_control("x\x7fy\u{9b}2J"), "x\\x7fy\\x9b2J");
1139        // Bidirectional overrides can reorder text on screen ("Trojan Source")
1140        assert_eq!(escape_control("ab\u{202e}cd"), "ab\\u{202e}cd");
1141    }
1142
1143    #[cfg(unix)]
1144    #[test]
1145    fn test_looks_stuck_needs_sustained_cpu() {
1146        let spawn = |script: &str| {
1147            std::process::Command::new("sh")
1148                .args(["-c", script])
1149                .spawn()
1150                .unwrap()
1151        };
1152        let mut spinning = spawn("while :; do :; done");
1153        let mut idle = spawn("exec sleep 300");
1154        std::thread::sleep(Duration::from_millis(300));
1155        let record = |pid: u32| Process::find_by_pid(pid).unwrap().unwrap();
1156
1157        let spinning_stuck = record(spinning.id()).looks_stuck_now();
1158        let idle_stuck = record(idle.id()).looks_stuck_now();
1159        for child in [&mut spinning, &mut idle] {
1160            let _ = child.kill();
1161            let _ = child.wait();
1162        }
1163        assert!(
1164            spinning_stuck,
1165            "a process spinning at 100% CPU wasn't stuck"
1166        );
1167        assert!(!idle_stuck, "an idle process counted as stuck");
1168    }
1169
1170    #[test]
1171    fn test_signals_never_reach_a_process_that_reused_the_pid() {
1172        let mut child = crate::test_support::idle_child();
1173        let mut stale = crate::test_support::record_of(&child);
1174        // A record from before the PID was reused: same PID, older start time
1175        stale.start_time = stale.start_time.map(|t| t - 3600);
1176
1177        let kill = stale.kill();
1178        let term = stale.terminate();
1179        #[cfg(unix)]
1180        let sig = stale.send_signal(nix::sys::signal::Signal::SIGKILL);
1181        let alive = child.try_wait().unwrap().is_none();
1182        let _ = child.kill();
1183        let _ = child.wait();
1184
1185        assert!(
1186            alive,
1187            "a stale record signalled the process now using its PID"
1188        );
1189        assert!(
1190            matches!(kill, Err(ProcError::ProcessNotFound(_))),
1191            "{kill:?}"
1192        );
1193        assert!(
1194            matches!(term, Err(ProcError::ProcessNotFound(_))),
1195            "{term:?}"
1196        );
1197        #[cfg(unix)]
1198        assert!(matches!(sig, Err(ProcError::ProcessNotFound(_))), "{sig:?}");
1199    }
1200
1201    /// A control character in a process's name, executable or command line is
1202    /// shown escaped, but name matching reads what the OS gave: a process found
1203    /// by `find_by_name` is also kept by `--by`, and the other way round
1204    #[cfg(unix)]
1205    #[test]
1206    fn text_with_control_characters_matches_as_typed_and_displays_escaped() {
1207        use crate::core::matches_by_filter;
1208        use std::os::unix::process::CommandExt;
1209
1210        let word = format!("zz\u{7}ctl{}x", std::process::id());
1211        let mut child = std::process::Command::new("sleep")
1212            .arg0(&word)
1213            .arg("300")
1214            .spawn()
1215            .unwrap();
1216        let pid = child.id();
1217        // `spawn` returns once the exec has begun, not once it has finished:
1218        // measured on Linux, /proc/<pid>/cmdline is still empty for a moment
1219        // after it (name and exe are already the new program's). A record taken
1220        // in that moment has no first argument, so wait for the command line.
1221        let record = std::iter::repeat_with(|| {
1222            std::thread::sleep(Duration::from_millis(5));
1223            Process::find_by_pid(pid).unwrap().unwrap()
1224        })
1225        .take(1000)
1226        .find(|record| record.argv0.is_some());
1227        let part = format!("\u{7}ctl{}", std::process::id());
1228
1229        let found = |pattern: &str, mode| {
1230            Process::find_by_name(pattern, mode)
1231                .map(|found| found.iter().any(|p| p.pid == pid))
1232                .unwrap_or(false)
1233        };
1234        let named = found(&word, NameMatch::Exact);
1235        let named_in_command = found(&part, NameMatch::Command);
1236        let _ = child.kill();
1237        let _ = child.wait();
1238
1239        let record = record.expect("the child's command line never appeared");
1240        assert!(named, "find_by_name read the escaped first argument");
1241        assert!(
1242            named_in_command,
1243            "find_by_name read the escaped command line"
1244        );
1245        assert!(
1246            matches_by_filter(&record, &word, NameMatch::Exact),
1247            "--by read the escaped first argument"
1248        );
1249        assert!(
1250            matches_by_filter(&record, &part, NameMatch::Command),
1251            "--by --cmd read the escaped command line"
1252        );
1253        // what is shown is escaped
1254        let shown = record.command.as_deref().unwrap();
1255        assert!(shown.contains("\\x07ctl"), "{shown}");
1256        assert!(!shown.contains('\u{7}'), "{shown:?}");
1257    }
1258
1259    /// The same, on Windows, where a process has no `arg0` to set: a control
1260    /// character in the command line (the listener example ignores extra
1261    /// arguments) is shown escaped and still matches as typed with `--cmd`
1262    #[cfg(windows)]
1263    #[test]
1264    fn a_control_character_in_a_windows_command_line_matches_as_typed_and_displays_escaped() {
1265        use crate::core::matches_by_filter;
1266
1267        let mut exe = std::env::current_exe().unwrap();
1268        exe.pop();
1269        exe.pop();
1270        exe.push("examples");
1271        exe.push("listener.exe");
1272        assert!(exe.exists(), "build the example first: {}", exe.display());
1273        let part = format!("\u{7}ctl{}", std::process::id());
1274        let mut child = std::process::Command::new(&exe)
1275            .arg(&part)
1276            .stdin(std::process::Stdio::null())
1277            .stdout(std::process::Stdio::null())
1278            .spawn()
1279            .unwrap();
1280        let pid = child.id();
1281        let record = std::iter::repeat_with(|| {
1282            std::thread::sleep(Duration::from_millis(20));
1283            Process::find_by_pid(pid).unwrap()
1284        })
1285        .take(500)
1286        .flatten()
1287        .find(|record| record.command.is_some())
1288        .expect("the child's command line never appeared");
1289        let found = Process::find_by_name(&part, NameMatch::Command)
1290            .map(|found| found.iter().any(|p| p.pid == pid))
1291            .unwrap_or(false);
1292        let kept = matches_by_filter(&record, &part, NameMatch::Command);
1293        crate::test_support::end(&mut child);
1294
1295        assert!(found, "find_by_name read the escaped command line");
1296        assert!(kept, "--by --cmd read the escaped command line");
1297        let shown = record.command.as_deref().unwrap();
1298        assert!(shown.contains("\\x07ctl"), "{shown}");
1299        assert!(!shown.contains('\u{7}'), "{shown:?}");
1300    }
1301
1302    #[test]
1303    fn a_name_is_read_from_the_raw_text_when_escaping_changed_it() {
1304        let record = Process {
1305            name: "a\\x07b".to_string(),
1306            raw: Some(Box::new(RawText {
1307                name: "a\u{7}b".to_string(),
1308                exe_path: Some("c\u{7}d".to_string()),
1309                command: Some("e\u{7}f".to_string()),
1310            })),
1311            argv0: Some("g".to_string()),
1312            ..ghost()
1313        };
1314        let names = record.names();
1315        assert_eq!(names.name, "a\u{7}b");
1316        assert_eq!(names.exe, Some("c\u{7}d"));
1317        assert_eq!(names.argv0, Some("g"));
1318        assert_eq!(names.command, Some("e\u{7}f"));
1319    }
1320
1321    #[test]
1322    fn the_ancestors_stop_where_the_process_list_does() {
1323        // A list that holds only this process: its parent is named but not
1324        // listed, so the climb ends there instead of going on
1325        let me = Pid::from_u32(std::process::id());
1326        let mut sys = System::new();
1327        sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[me]), true);
1328        let parent = sys.process(me).and_then(|p| p.parent());
1329        let expected: std::collections::HashSet<Pid> = std::iter::once(me).chain(parent).collect();
1330        assert_eq!(self_and_ancestors(&sys), expected);
1331    }
1332
1333    #[test]
1334    fn test_strip_deleted_marker() {
1335        assert_eq!(
1336            strip_deleted_marker("/nonexistent/proc-gone (deleted)".to_string()),
1337            "/nonexistent/proc-gone"
1338        );
1339        assert_eq!(
1340            strip_deleted_marker("/tmp/project".to_string()),
1341            "/tmp/project"
1342        );
1343
1344        // A directory really named "x (deleted)" keeps its name
1345        let real = std::env::temp_dir().join(format!("proc-real-{} (deleted)", std::process::id()));
1346        std::fs::create_dir_all(&real).unwrap();
1347        let path = real.to_string_lossy().to_string();
1348        assert_eq!(strip_deleted_marker(path.clone()), path);
1349        std::fs::remove_dir(&real).unwrap();
1350    }
1351
1352    #[test]
1353    fn a_name_nothing_has_is_plain_not_found_in_both_modes() {
1354        for mode in [NameMatch::Exact, NameMatch::Command] {
1355            let result = Process::find_by_name("nonexistent_process_12345", mode);
1356            assert!(
1357                matches!(&result, Err(ProcError::ProcessNotFound(n)) if n == "nonexistent_process_12345"),
1358                "{result:?}"
1359            );
1360        }
1361    }
1362
1363    /// A shell waiting for input that never comes, with a marker in its command
1364    /// line: it is named `sh`, and the marker is only in `sh -c ...`. It runs
1365    /// nothing else, so there is no child to leave behind.
1366    #[cfg(unix)]
1367    fn waiting_shell_with(marker: &str) -> std::process::Child {
1368        std::process::Command::new("sh")
1369            .args(["-c", &format!("read line; : {marker}")])
1370            .stdin(std::process::Stdio::piped())
1371            .spawn()
1372            .unwrap()
1373    }
1374
1375    #[cfg(unix)]
1376    #[test]
1377    fn a_word_only_in_a_command_line_is_found_with_cmd_and_hinted_without() {
1378        let marker = format!("hintmarker{}", std::process::id());
1379        let mut child = waiting_shell_with(&marker);
1380        let pid = child.id();
1381        let exact = Process::find_by_name(&marker, NameMatch::Exact);
1382        let cmd = Process::find_by_name(&marker, NameMatch::Command);
1383        let _ = child.kill();
1384        let _ = child.wait();
1385
1386        // by default no process is named that, and the error says one has it
1387        // in its command line
1388        assert!(
1389            matches!(&exact, Err(ProcError::NameNotFound { name, in_command_line })
1390                if name == &marker && *in_command_line >= 1),
1391            "{exact:?}"
1392        );
1393        // with --cmd that process is the match, and the only one
1394        let found = cmd.unwrap();
1395        assert_eq!(found.iter().map(|p| p.pid).collect::<Vec<_>>(), vec![pid]);
1396    }
1397
1398    #[cfg(unix)]
1399    #[test]
1400    fn test_find_orphans_returns_ok() {
1401        // Should not error — may or may not find orphans depending on system state
1402        let result = Process::find_orphans();
1403        assert!(result.is_ok());
1404    }
1405
1406    #[cfg(not(unix))]
1407    #[test]
1408    fn find_orphans_is_not_supported_on_windows() {
1409        let result = Process::find_orphans();
1410        assert!(
1411            matches!(&result, Err(ProcError::NotSupported(m)) if m.starts_with("orphans is not supported on Windows")),
1412            "{result:?}"
1413        );
1414    }
1415
1416    #[cfg(unix)]
1417    #[test]
1418    fn test_is_system_process_system_paths() {
1419        let make_proc = |exe: Option<&str>, cwd: Option<&str>| Process {
1420            pid: 100,
1421            name: "test".to_string(),
1422            exe_path: exe.map(String::from),
1423            cwd: cwd.map(String::from),
1424            command: None,
1425            argv0: None,
1426            raw: None,
1427            cpu_percent: 0.0,
1428            memory_mb: 0.0,
1429            status: ProcessStatus::Running,
1430            user: None,
1431            parent_pid: Some(1),
1432            start_time: None,
1433            uptime_secs: None,
1434        };
1435
1436        // System daemons with cwd "/" and system exe paths
1437        assert!(Process::is_system_process(&make_proc(
1438            Some("/usr/sbin/sshd"),
1439            Some("/")
1440        )));
1441        assert!(Process::is_system_process(&make_proc(
1442            Some("/System/Library/foo"),
1443            Some("/")
1444        )));
1445        assert!(Process::is_system_process(&make_proc(
1446            Some("/usr/bin/systemd"),
1447            Some("/")
1448        )));
1449        assert!(Process::is_system_process(&make_proc(
1450            Some("/usr/lib/snapd/snapd"),
1451            Some("/")
1452        )));
1453
1454        // No exe path with cwd "/" = kernel thread
1455        assert!(Process::is_system_process(&make_proc(None, Some("/"))));
1456
1457        // No cwd at all = likely kernel thread
1458        assert!(Process::is_system_process(&make_proc(
1459            Some("/usr/bin/foo"),
1460            None
1461        )));
1462
1463        // Every system directory counts, each checked on its own
1464        for exe in [
1465            "/System/x",
1466            "/usr/libexec/x",
1467            "/usr/sbin/x",
1468            "/sbin/x",
1469            "/usr/bin/x",
1470            "/usr/lib/x",
1471            "/usr/lib64/x",
1472            "/lib/x",
1473            "/lib64/x",
1474            "/opt/x",
1475            "/snap/x",
1476        ] {
1477            assert!(
1478                Process::is_system_process(&make_proc(Some(exe), Some("/"))),
1479                "{exe}"
1480            );
1481        }
1482        // a daemon with cwd "/" outside them still counts: nothing says it is a user's
1483        assert!(Process::is_system_process(&make_proc(
1484            Some("/srv/x"),
1485            Some("/")
1486        )));
1487
1488        // User process with user cwd = NOT system
1489        assert!(!Process::is_system_process(&make_proc(
1490            Some("/usr/bin/node"),
1491            Some("/home/user/project")
1492        )));
1493        assert!(!Process::is_system_process(&make_proc(
1494            Some("/home/user/.local/bin/app"),
1495            Some("/home/user")
1496        )));
1497    }
1498
1499    #[cfg(unix)]
1500    #[test]
1501    fn test_parse_signal_name_valid() {
1502        use nix::sys::signal::Signal;
1503
1504        assert_eq!(parse_signal_name("HUP").unwrap(), Signal::SIGHUP);
1505        assert_eq!(parse_signal_name("hup").unwrap(), Signal::SIGHUP);
1506        assert_eq!(parse_signal_name("SIGHUP").unwrap(), Signal::SIGHUP);
1507        assert_eq!(parse_signal_name("sighup").unwrap(), Signal::SIGHUP);
1508        assert_eq!(parse_signal_name("INT").unwrap(), Signal::SIGINT);
1509        assert_eq!(parse_signal_name("QUIT").unwrap(), Signal::SIGQUIT);
1510        assert_eq!(parse_signal_name("ABRT").unwrap(), Signal::SIGABRT);
1511        assert_eq!(parse_signal_name("KILL").unwrap(), Signal::SIGKILL);
1512        assert_eq!(parse_signal_name("TERM").unwrap(), Signal::SIGTERM);
1513        assert_eq!(parse_signal_name("STOP").unwrap(), Signal::SIGSTOP);
1514        assert_eq!(parse_signal_name("CONT").unwrap(), Signal::SIGCONT);
1515        assert_eq!(parse_signal_name("USR1").unwrap(), Signal::SIGUSR1);
1516        assert_eq!(parse_signal_name("USR2").unwrap(), Signal::SIGUSR2);
1517    }
1518
1519    #[cfg(unix)]
1520    #[test]
1521    fn test_parse_signal_name_invalid() {
1522        assert!(parse_signal_name("INVALID").is_err());
1523        assert!(parse_signal_name("FOO").is_err());
1524        assert!(parse_signal_name("").is_err());
1525    }
1526
1527    #[cfg(unix)]
1528    #[test]
1529    fn test_parse_signal_name_case_insensitive() {
1530        use nix::sys::signal::Signal;
1531
1532        assert_eq!(parse_signal_name("term").unwrap(), Signal::SIGTERM);
1533        assert_eq!(parse_signal_name("Term").unwrap(), Signal::SIGTERM);
1534        assert_eq!(parse_signal_name("TERM").unwrap(), Signal::SIGTERM);
1535        assert_eq!(parse_signal_name("sigterm").unwrap(), Signal::SIGTERM);
1536        assert_eq!(parse_signal_name("SigTerm").unwrap(), Signal::SIGTERM);
1537    }
1538
1539    #[cfg(unix)]
1540    #[test]
1541    fn test_send_signal_nonexistent_process() {
1542        use nix::sys::signal::Signal;
1543
1544        // PID 99999999 almost certainly doesn't exist
1545        let proc = Process {
1546            pid: 99999999,
1547            name: "ghost".to_string(),
1548            exe_path: None,
1549            cwd: None,
1550            command: None,
1551            argv0: None,
1552            raw: None,
1553            cpu_percent: 0.0,
1554            memory_mb: 0.0,
1555            status: ProcessStatus::Running,
1556            user: None,
1557            parent_pid: None,
1558            start_time: None,
1559            uptime_secs: None,
1560        };
1561
1562        let result = proc.send_signal(Signal::SIGCONT);
1563        assert!(result.is_err());
1564    }
1565
1566    /// A record of a process that doesn't exist
1567    fn ghost() -> Process {
1568        Process {
1569            pid: 99999999,
1570            name: "ghost".to_string(),
1571            exe_path: None,
1572            cwd: None,
1573            command: None,
1574            argv0: None,
1575            raw: None,
1576            cpu_percent: 0.0,
1577            memory_mb: 0.0,
1578            status: ProcessStatus::Running,
1579            user: None,
1580            parent_pid: None,
1581            start_time: None,
1582            uptime_secs: None,
1583        }
1584    }
1585
1586    #[test]
1587    fn every_sysinfo_status_maps_to_ours() {
1588        for (sys, ours) in [
1589            (SysProcessStatus::Run, ProcessStatus::Running),
1590            (SysProcessStatus::Sleep, ProcessStatus::Sleeping),
1591            (SysProcessStatus::Stop, ProcessStatus::Stopped),
1592            (SysProcessStatus::Zombie, ProcessStatus::Zombie),
1593            (SysProcessStatus::Dead, ProcessStatus::Dead),
1594            (SysProcessStatus::Idle, ProcessStatus::Unknown),
1595        ] {
1596            assert_eq!(ProcessStatus::from(sys), ours, "{sys:?}");
1597        }
1598    }
1599
1600    #[test]
1601    fn a_process_that_is_gone_does_not_look_stuck() {
1602        assert!(!ghost().looks_stuck_now());
1603    }
1604
1605    #[test]
1606    fn killing_a_process_that_is_already_gone_is_success() {
1607        assert!(matches!(ghost().kill(), Err(ProcError::ProcessNotFound(_))));
1608        ghost().kill_and_wait().unwrap();
1609    }
1610
1611    #[test]
1612    fn a_kill_that_was_not_delivered_is_an_error() {
1613        kill_outcome(true, 7).unwrap();
1614        let err = kill_outcome(false, 7).unwrap_err();
1615        assert!(
1616            matches!(&err, ProcError::SignalError(m) if m == "Failed to kill process 7"),
1617            "{err:?}"
1618        );
1619    }
1620
1621    #[test]
1622    fn taskkill_without_force_reports_what_it_said() {
1623        #[cfg(unix)]
1624        use std::os::unix::process::ExitStatusExt;
1625        #[cfg(windows)]
1626        use std::os::windows::process::ExitStatusExt;
1627        use std::process::{ExitStatus, Output};
1628
1629        let output = |raw, stdout: &str, stderr: &str| Output {
1630            status: ExitStatus::from_raw(raw),
1631            stdout: stdout.as_bytes().to_vec(),
1632            stderr: stderr.as_bytes().to_vec(),
1633        };
1634        taskkill_outcome(&output(0, "SUCCESS: sent termination signal", "")).unwrap();
1635
1636        // exit code 1 on Windows; the same wait status on Unix
1637        #[cfg(unix)]
1638        let failed = 1 << 8;
1639        #[cfg(windows)]
1640        let failed = 1;
1641        let err =
1642            taskkill_outcome(&output(failed, "", "ERROR: could not be terminated")).unwrap_err();
1643        assert!(
1644            matches!(&err, ProcError::SignalError(m) if m == "ERROR: could not be terminated"),
1645            "{err:?}"
1646        );
1647    }
1648
1649    #[cfg(unix)]
1650    #[test]
1651    fn orphans_are_the_children_of_init_that_are_not_system_daemons() {
1652        let proc = |pid: u32, ppid: Option<u32>, exe: Option<&str>, cwd: Option<&str>| Process {
1653            pid,
1654            name: format!("p{pid}"),
1655            exe_path: exe.map(String::from),
1656            cwd: cwd.map(String::from),
1657            parent_pid: ppid,
1658            ..ghost()
1659        };
1660        let orphans = Process::orphans_among(vec![
1661            // a user's process whose parent died
1662            proc(10, Some(1), Some("/home/z/app"), Some("/home/z")),
1663            // a system daemon: cwd "/" and an exe in a system directory
1664            proc(11, Some(1), Some("/usr/sbin/sshd"), Some("/")),
1665            // init itself, and a process with a live parent
1666            proc(1, Some(1), Some("/sbin/init"), Some("/home/z")),
1667            proc(12, Some(500), Some("/home/z/app"), Some("/home/z")),
1668            // no parent known
1669            proc(13, None, Some("/home/z/app"), Some("/home/z")),
1670        ]);
1671        let pids: Vec<u32> = orphans.iter().map(|p| p.pid).collect();
1672        assert_eq!(pids, vec![10]);
1673    }
1674
1675    #[cfg(unix)]
1676    #[test]
1677    fn a_missing_lsof_finds_no_open_file_owners() {
1678        let found = Process::find_by_open_file_with("proc-no-such-lsof", std::path::Path::new("/"));
1679        assert!(found.unwrap().is_empty());
1680    }
1681
1682    #[test]
1683    fn a_wait_for_a_process_that_stays_is_a_wait_and_then_an_error() {
1684        let mut child = crate::test_support::idle_child();
1685        let record = crate::test_support::record_of(&child);
1686        let started = std::time::Instant::now();
1687        // a kill that does nothing: the process is still there, however long
1688        // it is looked at
1689        let result = record.kill_then_wait(Duration::from_millis(300), |_| Ok(()));
1690        let waited = started.elapsed();
1691        let still_there = child.try_wait().unwrap().is_none();
1692        crate::test_support::end(&mut child);
1693        assert!(
1694            matches!(&result, Err(ProcError::SignalError(_))),
1695            "{result:?}"
1696        );
1697        assert!(waited >= Duration::from_millis(300), "{waited:?}");
1698        assert!(still_there);
1699    }
1700
1701    #[test]
1702    fn a_process_still_alive_when_the_wait_ends_is_an_error() {
1703        let mut child = crate::test_support::idle_child();
1704        let record = crate::test_support::record_of(&child);
1705        // no time to wait: the SIGKILL is sent, but the process isn't seen gone
1706        let result = record.kill_and_wait_within(Duration::ZERO);
1707        let _ = child.kill();
1708        let _ = child.wait();
1709        assert!(
1710            matches!(&result, Err(ProcError::SignalError(m))
1711                if m == &format!("Process {} is still running 0 seconds after SIGKILL", record.pid)),
1712            "{result:?}"
1713        );
1714    }
1715
1716    /// One kinfo_proc record, with the fields `zombies_in` reads
1717    #[cfg(target_os = "macos")]
1718    fn kinfo(pid: u32, stat: u8, comm: &str, ruid: u32, ppid: u32, start: u64) -> Vec<u8> {
1719        let mut rec = vec![0u8; KINFO_PROC_SIZE];
1720        rec[0..8].copy_from_slice(&start.to_ne_bytes());
1721        rec[36] = stat;
1722        rec[40..44].copy_from_slice(&pid.to_ne_bytes());
1723        rec[243..243 + comm.len()].copy_from_slice(comm.as_bytes());
1724        rec[392..396].copy_from_slice(&ruid.to_ne_bytes());
1725        rec[560..564].copy_from_slice(&ppid.to_ne_bytes());
1726        rec
1727    }
1728
1729    #[cfg(target_os = "macos")]
1730    #[test]
1731    fn zombies_are_read_from_the_kernels_table() {
1732        let known = std::collections::HashSet::from([30]);
1733        let table: Vec<u8> = [
1734            kinfo(10, 5, "defunct-app", 0, 99, 1_700_000_000),
1735            // no name left, and no start time
1736            kinfo(11, 5, "", 0, 1, 0),
1737            // alive, not a zombie
1738            kinfo(20, 2, "alive", 0, 1, 1_700_000_000),
1739            // already listed by sysinfo
1740            kinfo(30, 5, "known", 0, 1, 1_700_000_000),
1741            // the kernel's own slot
1742            kinfo(0, 5, "kernel", 0, 0, 0),
1743        ]
1744        .concat();
1745
1746        let zombies = zombies_in(&table, &known);
1747        let pids: Vec<u32> = zombies.iter().map(|z| z.pid).collect();
1748        assert_eq!(pids, vec![10, 11]);
1749
1750        let named = &zombies[0];
1751        assert_eq!(named.name, "defunct-app");
1752        assert_eq!(named.status, ProcessStatus::Zombie);
1753        assert_eq!(named.parent_pid, Some(99));
1754        assert_eq!(named.start_time, Some(1_700_000_000));
1755        assert!(named.uptime_secs.unwrap() > 0);
1756        assert_eq!(named.user.as_deref(), Some("root"));
1757
1758        let unnamed = &zombies[1];
1759        assert_eq!(unnamed.name, "<defunct>");
1760        assert_eq!(unnamed.start_time, None);
1761        assert_eq!(unnamed.uptime_secs, None);
1762    }
1763
1764    #[cfg(target_os = "macos")]
1765    #[test]
1766    fn a_table_of_an_unknown_layout_shows_no_zombies() {
1767        let known = std::collections::HashSet::new();
1768        let mut table = kinfo(10, 5, "z", 0, 1, 0);
1769        table.push(0);
1770        assert!(zombies_in(&table, &known).is_empty());
1771    }
1772
1773    #[cfg(target_os = "macos")]
1774    #[test]
1775    fn a_refused_sysctl_gives_an_empty_table() {
1776        use std::cell::Cell;
1777
1778        // refused when asked for the size
1779        assert!(read_proc_table(|_, _| -1).is_empty());
1780
1781        // refused when asked for the table
1782        let calls = Cell::new(0);
1783        let table = read_proc_table(|_, size| {
1784            calls.set(calls.get() + 1);
1785            if calls.get() == 1 {
1786                *size = 2 * KINFO_PROC_SIZE;
1787                0
1788            } else {
1789                -1
1790            }
1791        });
1792        assert_eq!(calls.get(), 2);
1793        assert!(table.is_empty());
1794
1795        // answered: the table is cut to the size the kernel reports
1796        let calls = Cell::new(0);
1797        let table = read_proc_table(|buf, size| {
1798            calls.set(calls.get() + 1);
1799            if calls.get() == 1 {
1800                *size = 2 * KINFO_PROC_SIZE;
1801            } else {
1802                assert_eq!(*size, 66 * KINFO_PROC_SIZE, "room for 64 newcomers");
1803                // SAFETY: `buf` has `*size` writable bytes
1804                unsafe { buf.cast::<u8>().write(7) };
1805                *size = KINFO_PROC_SIZE;
1806            }
1807            0
1808        });
1809        assert_eq!(table.len(), KINFO_PROC_SIZE);
1810        assert_eq!(table[0], 7);
1811    }
1812}