Skip to main content

pitchfork_cli/
procs.rs

1use crate::Result;
2#[cfg(unix)]
3use crate::settings::settings;
4#[cfg(windows)]
5use crate::shell::HideConsoleWindow;
6use miette::IntoDiagnostic;
7use once_cell::sync::Lazy;
8use std::collections::HashMap;
9#[cfg(target_os = "linux")]
10use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
11use std::sync::Mutex;
12use std::time::{Duration, Instant};
13use sysinfo::ProcessesToUpdate;
14#[cfg(windows)]
15use windows_sys::Win32::Foundation::{CloseHandle, FILETIME, HANDLE};
16#[cfg(windows)]
17use windows_sys::Win32::System::Threading::{
18    GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
19};
20
21/// Map from parent PID to its child PIDs.
22type ParentToChildren = HashMap<u32, Vec<u32>>;
23
24/// Map from PID to process name and optional executable path.
25type ProcessNames = HashMap<u32, (String, Option<String>)>;
26
27/// How often a full process-table refresh may run. High-frequency callers
28/// (web API polling, TUI frames) share one full scan instead of each paying
29/// the ~40ms /proc walk on every call. Targeted `refresh_pids` calls are not
30/// throttled. sysinfo's `cpu_usage()` is a delta between refreshes, so a
31/// longer window also smooths the reported CPU%.
32const FULL_REFRESH_INTERVAL: Duration = Duration::from_secs(5);
33
34pub struct Procs {
35    system: Mutex<sysinfo::System>,
36    /// When the last full process-table refresh ran. `None` until the first
37    /// refresh forces the initial scan (the table starts empty).
38    last_full_refresh: Mutex<Option<Instant>>,
39}
40
41pub static PROCS: Lazy<Procs> = Lazy::new(Procs::new);
42
43impl Default for Procs {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl Procs {
50    pub fn new() -> Self {
51        // IMPORTANT: Do NOT call refresh_processes() or System::new_all() here.
52        //
53        // Both refresh the state of every process in the system, which takes
54        // ~500ms on a typical machine. Since PROCS is a Lazy static, the first
55        // access triggers this constructor — and `pitchfork cd` (which only
56        // needs to check if the supervisor PID is alive) would block for that
57        // duration on every directory change.
58        //
59        // See https://github.com/jdx/pitchfork/discussions/439
60        //
61        // Callers that need process info must call refresh_pids() (for specific
62        // PIDs) or refresh_processes() (for full-system stats) explicitly.
63        Self {
64            system: Mutex::new(sysinfo::System::new()),
65            last_full_refresh: Mutex::new(None),
66        }
67    }
68
69    fn lock_system(&self) -> std::sync::MutexGuard<'_, sysinfo::System> {
70        self.system.lock().unwrap_or_else(|poisoned| {
71            warn!("System mutex was poisoned, recovering");
72            poisoned.into_inner()
73        })
74    }
75
76    pub fn title(&self, pid: u32) -> Option<String> {
77        self.lock_system()
78            .process(sysinfo::Pid::from_u32(pid))
79            .map(|p| p.name().to_string_lossy().to_string())
80    }
81
82    /// Time the system booted, in seconds since the epoch.
83    ///
84    /// Constant for the lifetime of a boot, so recording it alongside a
85    /// daemon's PID makes it possible to tell later whether that record
86    /// belongs to the current boot. Note this is *not* comparable with
87    /// `start_time`, whose units are platform-specific.
88    pub fn boot_time(&self) -> u64 {
89        sysinfo::System::boot_time()
90    }
91
92    /// High-resolution kernel start token for the process.
93    ///
94    /// Combined with the PID this forms a stable identity for the lifetime of a
95    /// process. Unlike sysinfo's seconds-since-epoch value, this preserves the
96    /// native platform resolution so same-second PID reuse cannot compare equal.
97    pub fn start_time(&self, pid: u32) -> Option<u64> {
98        process_start_token(pid)
99    }
100
101    #[cfg(any(unix, windows))]
102    fn start_time_matches(&self, pid: u32, expected: u64) -> bool {
103        self.start_time(pid) == Some(expected)
104    }
105
106    pub fn is_running(&self, pid: u32) -> bool {
107        // Use kill(pid, 0) on Unix for an O(1) liveness check that does not
108        // depend on the process cache being populated. This avoids the need
109        // for a full process refresh just to check a single PID.
110        // ESRCH = process does not exist; EPERM = process exists but owned
111        // by another user (still "running" from our perspective).
112        #[cfg(unix)]
113        {
114            unsafe {
115                if libc::kill(pid as i32, 0) == 0 {
116                    return true;
117                }
118                std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
119            }
120        }
121        #[cfg(not(unix))]
122        {
123            self.refresh_pids(&[pid]);
124            self.lock_system()
125                .process(sysinfo::Pid::from_u32(pid))
126                .is_some()
127        }
128    }
129
130    /// Walk the /proc tree to find all descendant PIDs.
131    /// Kept for diagnostics/status display; no longer used in the kill path.
132    #[allow(dead_code)]
133    pub fn all_children(&self, pid: u32) -> Vec<u32> {
134        let system = self.lock_system();
135        let all = system.processes();
136        let mut children = vec![];
137        for (child_pid, process) in all {
138            let mut process = process;
139            while let Some(parent) = process.parent() {
140                if parent == sysinfo::Pid::from_u32(pid) {
141                    children.push(child_pid.as_u32());
142                    break;
143                }
144                match system.process(parent) {
145                    Some(p) => process = p,
146                    None => break,
147                }
148            }
149        }
150        children
151    }
152    /// Collect minimal process tree information in a single lock.
153    ///
154    /// Returns a map of parent PID → child PIDs and a map of PID → (name, exe).
155    /// This avoids repeated mutex locking when traversing deep trees.
156    pub fn collect_process_tree_info(&self) -> (ParentToChildren, ProcessNames) {
157        let system = self.lock_system();
158        let all = system.processes();
159        let mut parent_to_children: ParentToChildren = HashMap::new();
160        let mut process_info: ProcessNames = HashMap::new();
161
162        for (pid, proc) in all {
163            let pid_u32 = pid.as_u32();
164            process_info.insert(
165                pid_u32,
166                (
167                    proc.name().to_string_lossy().to_string(),
168                    proc.exe().map(|e| e.to_string_lossy().to_string()),
169                ),
170            );
171
172            if let Some(ppid) = proc.parent() {
173                parent_to_children
174                    .entry(ppid.as_u32())
175                    .or_default()
176                    .push(pid_u32);
177            }
178        }
179
180        (parent_to_children, process_info)
181    }
182    pub async fn kill_process_group_async(
183        &self,
184        pid: u32,
185        stop_signal: i32,
186        stop_timeout: Option<std::time::Duration>,
187    ) -> Result<bool> {
188        tokio::task::spawn_blocking(move || {
189            PROCS.kill_process_group(pid, stop_signal, stop_timeout, None)
190        })
191        .await
192        .into_diagnostic()?
193    }
194
195    /// Kill a process group only while its leader still has `expected_start_time`.
196    ///
197    /// Identity is refreshed inside the blocking kill operation, immediately
198    /// before signaling. Linux holds a pidfd and Windows holds an open process
199    /// handle across termination so the validated PID cannot be recycled.
200    /// Unix platforms without a durable process handle re-verify the start
201    /// time inside the blocking operation, right before the signal — the
202    /// same check-then-signal atomicity tokio affords in one closure. Pass
203    /// `None` to fail closed (refuse to signal anything).
204    pub async fn kill_process_group_if_start_time_matches_async(
205        &self,
206        pid: u32,
207        expected_start_time: Option<u64>,
208        stop_signal: i32,
209        stop_timeout: Option<std::time::Duration>,
210    ) -> Result<bool> {
211        let Some(expected_start_time) = expected_start_time else {
212            warn!(
213                "no recorded start time for pid {pid}; refusing to signal it (identity cannot be bound to a process generation)"
214            );
215            return Ok(false);
216        };
217        tokio::task::spawn_blocking(move || {
218            PROCS.kill_process_group(pid, stop_signal, stop_timeout, Some(expected_start_time))
219        })
220        .await
221        .into_diagnostic()?
222    }
223
224    /// Kill an entire process group with graceful shutdown strategy:
225    /// 1. Send the configured stop signal to the process group (-pgid) and
226    ///    wait up to the stop timeout for the WHOLE group to exit
227    /// 2. If any processes remain, send SIGKILL to the group and verify
228    ///
229    /// Since daemons are spawned with setsid(), the daemon PID == PGID,
230    /// so this atomically signals all descendant processes.
231    ///
232    /// The stop timeout is the graceful-shutdown budget for the entire group
233    /// (like systemd's TimeoutStopSec for a cgroup): members that are still
234    /// alive when it expires are SIGKILLed. Daemons whose teardown legitimately
235    /// takes longer (e.g. draining connections, stopping containers) should
236    /// raise `stop_signal.timeout` rather than rely on outliving the budget.
237    ///
238    /// Returns `Err` if the signal could not be sent (e.g. permission denied)
239    /// or if group members survived even SIGKILL (uninterruptible sleep).
240    #[cfg(unix)]
241    fn kill_process_group(
242        &self,
243        pid: u32,
244        stop_signal: i32,
245        stop_timeout: Option<std::time::Duration>,
246        expected_start_time: Option<u64>,
247    ) -> Result<bool> {
248        let pgid = pid as i32;
249        let signal_name = signal_name(stop_signal);
250
251        #[cfg(target_os = "linux")]
252        if let Some(expected) = expected_start_time {
253            return self.kill_process_group_with_pidfds(pid, expected, stop_signal, stop_timeout);
254        }
255
256        // A start-time check alone cannot prevent the numeric PID/PGID from
257        // being recycled before killpg. Linux closes that race with a pidfd.
258        // Other Unix platforms have no durable process handle, so they do the
259        // next best thing: re-read the start time fresh from the kernel here,
260        // inside the blocking operation and immediately before the signal, so
261        // the check-to-signal gap is just the adjacency of two syscalls in one
262        // thread — no await, no scheduler boundary. For that gap to matter, the
263        // kernel would have to wrap the entire sequential PID space (XNU
264        // allocates monotonically from lastpid and refuses IDs still in use as
265        // a proc, pgrp, or session) and hand the exact PGID to a new group
266        // leader between the two syscalls, which is not reachable in practice.
267        #[cfg(not(target_os = "linux"))]
268        if let Some(expected) = expected_start_time {
269            if !self.start_time_matches(pid, expected) {
270                debug!("process {pid} identity changed before killpg; refusing to signal it");
271                return Ok(false);
272            }
273        }
274
275        debug!("killing process group {pgid} with {signal_name}");
276
277        // Send the stop signal to the entire process group.
278        // killpg sends to all processes in the group atomically.
279        // We intentionally skip the zombie check here because the leader may be
280        // a zombie while children in the group are still running.
281        let ret = unsafe { libc::killpg(pgid, stop_signal) };
282        if ret == -1 {
283            let err = std::io::Error::last_os_error();
284            if err.raw_os_error() == Some(libc::ESRCH) {
285                debug!("process group {pgid} no longer exists");
286                return Ok(false);
287            }
288            if err.raw_os_error() == Some(libc::EPERM) {
289                return Err(miette::miette!(
290                    "failed to send {signal_name} to process group {pgid}: permission denied"
291                ));
292            }
293            warn!("failed to send {signal_name} to process group {pgid}: {err}");
294        }
295
296        // Wait for graceful shutdown: fast initial check then slower polling.
297        // Per-daemon timeout overrides the global setting.
298        //
299        // The wait must cover the ENTIRE group, not just the leader. The leader
300        // is often a thin shell (`sh -c ...`) that dies within milliseconds of
301        // the signal while its children are still shutting down gracefully
302        // (e.g. `docker compose up` waiting for its container to stop).
303        // Returning as soon as the leader dies lets a force-restart spawn a
304        // replacement that collides with the still-terminating old instance.
305        let stop_timeout = stop_timeout.unwrap_or_else(|| settings().supervisor_stop_timeout());
306        let fast_ms = 10u64;
307        let slow_ms = 50u64;
308        let total_ms = stop_timeout.as_millis().max(1) as u64;
309        let fast_count = ((total_ms / fast_ms) as usize).min(10);
310        let fast_total_ms = fast_ms * fast_count as u64;
311        let remaining_ms = total_ms.saturating_sub(fast_total_ms);
312        let slow_count = (remaining_ms / slow_ms) as usize;
313
314        let fast_checks =
315            std::iter::repeat_n(std::time::Duration::from_millis(fast_ms), fast_count);
316        let slow_checks =
317            std::iter::repeat_n(std::time::Duration::from_millis(slow_ms), slow_count);
318        let mut elapsed_ms = 0u64;
319
320        for sleep_duration in fast_checks.chain(slow_checks) {
321            std::thread::sleep(sleep_duration);
322            elapsed_ms += sleep_duration.as_millis() as u64;
323            if process_group_terminated(pgid) {
324                debug!("process group {pgid} terminated after {signal_name} ({elapsed_ms} ms)",);
325                return Ok(true);
326            }
327        }
328
329        // SIGKILL the entire process group as last resort
330        warn!(
331            "process group {pgid} did not respond to {signal_name} after {}ms, sending SIGKILL",
332            stop_timeout.as_millis()
333        );
334        let ret = unsafe { libc::killpg(pgid, libc::SIGKILL) };
335        if ret == -1 {
336            let err = std::io::Error::last_os_error();
337            if err.raw_os_error() != Some(libc::ESRCH) {
338                warn!("failed to send SIGKILL to process group {pgid}: {err}");
339            }
340        }
341
342        // Wait for SIGKILL to take effect on the whole group (bounded: SIGKILL
343        // cannot be caught, so members disappear as soon as the kernel reaps
344        // them — anything left after this is stuck in uninterruptible sleep).
345        for _ in 0..40 {
346            std::thread::sleep(std::time::Duration::from_millis(50));
347            if process_group_terminated(pgid) {
348                return Ok(true);
349            }
350        }
351        // Report failure so callers do not mark the daemon Stopped (and start
352        // a replacement) while a member is still alive.
353        Err(miette::miette!(
354            "process group {pgid} still has members after SIGKILL \
355             (possibly stuck in uninterruptible sleep)"
356        ))
357    }
358
359    /// Whether any signalable member of the daemon's process group is still
360    /// alive. The daemon PID == PGID because daemons are spawned with setsid().
361    pub fn process_group_alive(&self, pid: u32) -> bool {
362        #[cfg(unix)]
363        {
364            !process_group_terminated(pid as i32)
365        }
366        #[cfg(not(unix))]
367        {
368            self.is_running(pid)
369        }
370    }
371
372    #[cfg(target_os = "linux")]
373    fn kill_process_group_with_pidfds(
374        &self,
375        pid: u32,
376        expected_start_time: u64,
377        _stop_signal: i32,
378        stop_timeout: Option<std::time::Duration>,
379    ) -> Result<bool> {
380        let leader = match open_pidfd(pid) {
381            Ok(pidfd) => pidfd,
382            Err(err) => {
383                warn!("cannot securely identify process group {pid}: {err}");
384                return Ok(false);
385            }
386        };
387        if !self.start_time_matches(pid, expected_start_time) {
388            debug!("process group {pid} leader identity changed before signaling");
389            return Ok(false);
390        }
391
392        let mut members = vec![(pid, leader)];
393        if let Err(err) = stop_pidfds(&members) {
394            let _ = signal_pidfds(&members, libc::SIGCONT, "SIGCONT");
395            return Err(err);
396        }
397        if !pidfd_is_running(&members[0].1) {
398            debug!("process group {pid} leader exited before it could be frozen");
399            return Ok(false);
400        }
401
402        // Stop newly discovered members before rescanning. Once a scan adds
403        // nothing, every process capable of forking into this group is frozen,
404        // so the pinned set is complete and the PGID cannot be recycled.
405        loop {
406            let known_members = members.len();
407            let added = match extend_process_group_pidfds(pid as i32, &mut members) {
408                Ok(added) => added,
409                Err(err) => {
410                    let _ = signal_pidfds(&members, libc::SIGCONT, "SIGCONT");
411                    return Err(miette::miette!(
412                        "failed to scan pinned process group {pid}: {err}"
413                    ));
414                }
415            };
416            if added == 0 {
417                break;
418            }
419            if let Err(err) = stop_pidfds(&members[known_members..]) {
420                let _ = signal_pidfds(&members, libc::SIGCONT, "SIGCONT");
421                return Err(err);
422            }
423        }
424
425        warn!(
426            "force-terminating {} pinned orphan process(es) in group {pid}",
427            members.len()
428        );
429        if let Err(err) = signal_pidfds(&members, libc::SIGKILL, "SIGKILL") {
430            let _ = signal_pidfds(&members, libc::SIGCONT, "SIGCONT");
431            return Err(err);
432        }
433
434        let exit_timeout = stop_timeout.unwrap_or_else(|| settings().supervisor_stop_timeout());
435        let checks = exit_timeout.as_millis().max(1).div_ceil(50) as usize;
436        for _ in 0..checks {
437            if members.iter().all(|(_, pidfd)| !pidfd_is_running(pidfd)) {
438                return Ok(true);
439            }
440            std::thread::sleep(std::time::Duration::from_millis(50));
441        }
442        if members.iter().all(|(_, pidfd)| !pidfd_is_running(pidfd)) {
443            return Ok(true);
444        }
445
446        warn!("one or more pinned processes in orphan group {pid} remained alive after SIGKILL");
447        let _ = signal_pidfds(&members, libc::SIGCONT, "SIGCONT");
448        Ok(false)
449    }
450
451    #[cfg(not(unix))]
452    fn kill_process_group(
453        &self,
454        pid: u32,
455        _stop_signal: i32,
456        _stop_timeout: Option<std::time::Duration>,
457        expected_start_time: Option<u64>,
458    ) -> Result<bool> {
459        // Keep the Windows process object alive through taskkill so its
460        // numeric PID cannot be recycled after identity validation.
461        #[cfg(windows)]
462        let _identity_handle = if let Some(expected) = expected_start_time {
463            let handle = match open_process_handle(pid) {
464                Ok(handle) => handle,
465                Err(err) => {
466                    warn!("cannot securely identify process {pid}: {err}");
467                    return Ok(false);
468                }
469            };
470            if process_start_token_from_handle(handle.0) != Some(expected) {
471                debug!("process {pid} identity changed before taskkill");
472                return Ok(false);
473            }
474            Some(handle)
475        } else {
476            None
477        };
478
479        #[cfg(not(windows))]
480        if let Some(expected) = expected_start_time
481            && !self.start_time_matches(pid, expected)
482        {
483            debug!("process {pid} identity changed before termination");
484            return Ok(false);
485        }
486
487        self.kill(pid, 0, None)
488    }
489
490    pub async fn kill_async(
491        &self,
492        pid: u32,
493        stop_signal: i32,
494        stop_timeout: Option<std::time::Duration>,
495    ) -> Result<bool> {
496        tokio::task::spawn_blocking(move || PROCS.kill(pid, stop_signal, stop_timeout))
497            .await
498            .into_diagnostic()?
499    }
500
501    /// Kill a process with graceful shutdown strategy:
502    /// 1. Send the configured stop signal and wait up to ~3s (10ms intervals for first 100ms, then 50ms intervals)
503    /// 2. If still running, send SIGKILL to force termination
504    ///
505    /// This ensures fast-exiting processes don't wait unnecessarily,
506    /// while stubborn processes eventually get forcefully terminated.
507    ///
508    /// Returns `Err` if the signal could not be sent (e.g. permission denied
509    /// when targeting a process owned by another user/root).
510    fn kill(
511        &self,
512        pid: u32,
513        stop_signal: i32,
514        stop_timeout: Option<std::time::Duration>,
515    ) -> Result<bool> {
516        let sysinfo_pid = sysinfo::Pid::from_u32(pid);
517
518        debug!("killing process {pid}");
519
520        #[cfg(windows)]
521        {
522            let _ = (stop_signal, stop_timeout);
523            // Use taskkill /F /T to kill the entire process tree.
524            // sysinfo's process.kill() only kills the main process, leaving
525            // child processes (e.g. python3 spawned by sh -c) orphaned and
526            // still holding ports. The /T flag kills all descendant processes.
527            let output = std::process::Command::new("taskkill")
528                .args(["/F", "/T", "/PID"])
529                .arg(pid.to_string())
530                .hide_console_window()
531                .output();
532            let taskkill_succeeded = match output {
533                Ok(o) if o.status.success() => {
534                    debug!("taskkill /F /T /PID {pid} succeeded");
535                    true
536                }
537                Ok(o) => {
538                    debug!(
539                        "taskkill /F /T /PID {pid} exited with status {}: {}",
540                        o.status,
541                        String::from_utf8_lossy(&o.stderr).trim()
542                    );
543                    false
544                }
545                Err(e) => {
546                    debug!("failed to spawn taskkill for pid {pid}: {e}");
547                    false
548                }
549            };
550            // Brief sleep to let the OS signal the process handle, giving
551            // tokio's child.wait() in the monitor task a chance to detect
552            // the exit and fire on_stop/on_exit hooks.
553            std::thread::sleep(std::time::Duration::from_millis(200));
554            if !taskkill_succeeded && self.is_running(pid) {
555                return Err(miette::miette!(
556                    "taskkill failed and process {pid} is still running"
557                ));
558            }
559            Ok(true)
560        }
561
562        #[cfg(unix)]
563        {
564            let signal_name = signal_name(stop_signal);
565            // Send stop signal for graceful shutdown using libc::kill directly
566            // so we can distinguish EPERM (permission denied) from ESRCH
567            // (process already gone — possible in a narrow race window).
568            debug!("sending {signal_name} to process {pid}");
569            let ret = unsafe { libc::kill(pid as i32, stop_signal) };
570            if ret == -1 {
571                let err = std::io::Error::last_os_error();
572                if err.raw_os_error() == Some(libc::ESRCH) {
573                    debug!("process {pid} no longer exists");
574                    return Ok(false);
575                }
576                if err.raw_os_error() == Some(libc::EPERM) {
577                    return Err(miette::miette!(
578                        "failed to send {signal_name} to process {pid}: permission denied"
579                    ));
580                }
581                return Err(miette::miette!(
582                    "failed to send {signal_name} to process {pid}: {err}"
583                ));
584            }
585
586            // Fast check: 10ms intervals, then slower 50ms polling for stop_timeout.
587            // Per-daemon timeout overrides the global setting.
588            let stop_timeout = stop_timeout.unwrap_or_else(|| settings().supervisor_stop_timeout());
589            let fast_ms = 10u64;
590            let slow_ms = 50u64;
591            let total_ms = stop_timeout.as_millis().max(1) as u64;
592            let fast_count = ((total_ms / fast_ms) as usize).min(10);
593            let fast_total_ms = fast_ms * fast_count as u64;
594            let remaining_ms = total_ms.saturating_sub(fast_total_ms);
595            let slow_count = (remaining_ms / slow_ms) as usize;
596
597            for i in 0..fast_count {
598                std::thread::sleep(std::time::Duration::from_millis(fast_ms));
599                self.refresh_pids(&[pid]);
600                if self.is_terminated_or_zombie(sysinfo_pid) {
601                    debug!(
602                        "process {pid} terminated after {signal_name} ({} ms)",
603                        (i + 1) * fast_ms as usize
604                    );
605                    return Ok(true);
606                }
607            }
608
609            // Slower check: 50ms intervals for the remainder of stop_timeout
610            for i in 0..slow_count {
611                std::thread::sleep(std::time::Duration::from_millis(slow_ms));
612                self.refresh_pids(&[pid]);
613                if self.is_terminated_or_zombie(sysinfo_pid) {
614                    debug!(
615                        "process {pid} terminated after {signal_name} ({} ms)",
616                        fast_total_ms + (i + 1) as u64 * slow_ms
617                    );
618                    return Ok(true);
619                }
620            }
621
622            // SIGKILL as last resort after stop_timeout
623            warn!(
624                "process {pid} did not respond to {signal_name} after {}ms, sending SIGKILL",
625                stop_timeout.as_millis()
626            );
627            let ret = unsafe { libc::kill(pid as i32, libc::SIGKILL) };
628            if ret == -1 {
629                let err = std::io::Error::last_os_error();
630                if err.raw_os_error() != Some(libc::ESRCH) {
631                    warn!("failed to send SIGKILL to process {pid}: {err}");
632                }
633            }
634
635            // Brief wait for SIGKILL to take effect
636            std::thread::sleep(std::time::Duration::from_millis(100));
637            Ok(true)
638        }
639    }
640
641    /// Check if a process is terminated or is a zombie.
642    /// On Linux, zombie processes still have /proc/[pid] entries but are effectively dead.
643    /// This prevents unnecessary signal escalation for processes that have already exited.
644    #[cfg(unix)]
645    fn is_terminated_or_zombie(&self, sysinfo_pid: sysinfo::Pid) -> bool {
646        let system = self.lock_system();
647        match system.process(sysinfo_pid) {
648            None => true,
649            Some(process) => {
650                matches!(process.status(), sysinfo::ProcessStatus::Zombie)
651            }
652        }
653    }
654
655    pub(crate) fn refresh_processes(&self) {
656        let mut system = self.lock_system();
657        system.refresh_processes(ProcessesToUpdate::All, true);
658        // On Windows, refresh_processes() does not update CPU usage.
659        // sysinfo requires a separate refresh_cpu_usage() call to compute
660        // the CPU delta between two samples. The first call stores the
661        // baseline; subsequent calls return the actual percentage.
662        #[cfg(windows)]
663        system.refresh_cpu_usage();
664    }
665
666    /// Refresh only specific PIDs instead of all processes.
667    /// More efficient when you only need to check a small set of known PIDs.
668    pub(crate) fn refresh_pids(&self, pids: &[u32]) {
669        let sysinfo_pids: Vec<sysinfo::Pid> =
670            pids.iter().map(|p| sysinfo::Pid::from_u32(*p)).collect();
671        self.lock_system()
672            .refresh_processes(ProcessesToUpdate::Some(&sysinfo_pids), true);
673    }
674
675    /// Full process-table refresh, throttled to at most once per
676    /// [`FULL_REFRESH_INTERVAL`] **among the callers that use this gated
677    /// path** (web API polling, TUI frames, process-tree endpoints). Callers
678    /// that bypass the gate — e.g. `refresh_processes()` directly in the
679    /// active-port probe — do not update `last_full_refresh`, so a gated
680    /// caller right after may still pay a scan. The first call always
681    /// refreshes.
682    ///
683    /// Callers that need to observe *new* descendants (e.g. the active-port
684    /// probe right after a daemon spawns) must still call `refresh_processes`
685    /// directly, since a throttled refresh may miss children forked since the
686    /// last scan.
687    ///
688    /// Lock order: this method holds `last_full_refresh` while
689    /// `refresh_processes` takes `system`. Never acquire `system` first and
690    /// then call this method, or the reverse order would deadlock.
691    pub(crate) fn refresh_if_stale(&self) {
692        let mut last = self
693            .last_full_refresh
694            .lock()
695            .unwrap_or_else(|poisoned| poisoned.into_inner());
696        if last.is_none_or(|t| t.elapsed() >= FULL_REFRESH_INTERVAL) {
697            self.refresh_processes();
698            *last = Some(Instant::now());
699        }
700    }
701
702    /// Get aggregated stats for multiple process trees in a single pass.
703    ///
704    /// Builds the parent→children map once (O(N)) and then BFS-es from each
705    /// root PID (O(D_i) per daemon). Total cost is O(N + ΣD_i) instead of
706    /// O(D × N) when collecting stats for each daemon separately.
707    pub fn get_batch_group_stats(&self, pids: &[u32]) -> Vec<(u32, Option<ProcessStats>)> {
708        if pids.is_empty() {
709            return Vec::new();
710        }
711
712        let system = self.lock_system();
713        let processes = system.processes();
714
715        let now = std::time::SystemTime::now()
716            .duration_since(std::time::UNIX_EPOCH)
717            .map(|d| d.as_secs())
718            .unwrap_or(0);
719
720        // Build parent → children map once for all daemons
721        let mut children_map: std::collections::HashMap<sysinfo::Pid, Vec<sysinfo::Pid>> =
722            std::collections::HashMap::new();
723        for (child_pid, child) in processes {
724            // Skip Linux userland threads: they report the same memory as their parent process,
725            // so including them would cause massive double-counting.
726            if child.thread_kind().is_some() {
727                continue;
728            }
729            if let Some(ppid) = child.parent() {
730                children_map.entry(ppid).or_default().push(*child_pid);
731            }
732        }
733
734        pids.iter()
735            .map(|&pid| {
736                let root_pid = sysinfo::Pid::from_u32(pid);
737                let Some(root) = processes.get(&root_pid) else {
738                    return (pid, None);
739                };
740
741                let root_disk = root.disk_usage();
742                let mut stats = ProcessStats {
743                    cpu_percent: root.cpu_usage(),
744                    memory_bytes: root.memory(),
745                    uptime_secs: now.saturating_sub(root.start_time()),
746                    disk_read_bytes: root_disk.read_bytes,
747                    disk_write_bytes: root_disk.written_bytes,
748                };
749
750                // BFS from root_pid to find all descendants
751                let mut queue = std::collections::VecDeque::new();
752                if let Some(direct_children) = children_map.get(&root_pid) {
753                    queue.extend(direct_children);
754                }
755                while let Some(child_pid) = queue.pop_front() {
756                    if let Some(child) = processes.get(&child_pid) {
757                        let disk = child.disk_usage();
758                        stats.cpu_percent += child.cpu_usage();
759                        stats.memory_bytes += child.memory();
760                        stats.disk_read_bytes += disk.read_bytes;
761                        stats.disk_write_bytes += disk.written_bytes;
762                    }
763                    if let Some(grandchildren) = children_map.get(&child_pid) {
764                        queue.extend(grandchildren);
765                    }
766                }
767
768                (pid, Some(stats))
769            })
770            .collect()
771    }
772    /// Refresh the process tree, then call [`Self::get_batch_group_stats`].
773    ///
774    /// Always performs a fresh full refresh. This is the correct entry point
775    /// for callers that need a new sample rather than a cached one — notably
776    /// the resource-limit checks, where counting the same cached CPU sample
777    /// twice as two violations would silently weaken enforcement.
778    ///
779    /// Returns a PID → [`ProcessStats`] map so callers do not have to repeat
780    /// the same `filter_map`/`collect` boilerplate.
781    pub fn refresh_and_get_batch_stats(&self, pids: &[u32]) -> HashMap<u32, ProcessStats> {
782        self.refresh_processes();
783        self.get_batch_group_stats(pids)
784            .into_iter()
785            .filter_map(|(pid, stats)| stats.map(|s| (pid, s)))
786            .collect()
787    }
788
789    /// Like [`Self::refresh_and_get_batch_stats`], but the full refresh is
790    /// throttled to at most once per [`FULL_REFRESH_INTERVAL`]. Concurrent or
791    /// frequent callers (web API polling, TUI frames) share a single scan and
792    /// read the cached process table in between. Do not use this for resource
793    /// enforcement: a cached CPU sample must not be counted as a new
794    /// violation.
795    pub fn refresh_and_get_batch_stats_if_stale(&self, pids: &[u32]) -> HashMap<u32, ProcessStats> {
796        self.refresh_if_stale();
797        self.get_batch_group_stats(pids)
798            .into_iter()
799            .filter_map(|(pid, stats)| stats.map(|s| (pid, s)))
800            .collect()
801    }
802
803    /// Get process-tree stats for multiple root PIDs, omitting roots that no longer exist.
804    pub fn get_batch_tree_stats_map(&self, pids: &[u32]) -> HashMap<u32, ProcessStats> {
805        self.get_batch_group_stats(pids)
806            .into_iter()
807            .filter_map(|(pid, stats)| stats.map(|stats| (pid, stats)))
808            .collect()
809    }
810
811    /// Get process-tree stats (cpu%, memory bytes, uptime secs, disk I/O) for a given root PID.
812    pub fn get_stats(&self, pid: u32) -> Option<ProcessStats> {
813        self.get_batch_group_stats(&[pid])
814            .into_iter()
815            .next()
816            .and_then(|(_, stats)| stats)
817    }
818
819    /// Get extended process information for a given PID
820    pub fn get_extended_stats(&self, pid: u32) -> Option<ExtendedProcessStats> {
821        let system = self.lock_system();
822        let processes = system.processes();
823        let root_pid = sysinfo::Pid::from_u32(pid);
824        let p = processes.get(&root_pid)?;
825
826        let now = std::time::SystemTime::now()
827            .duration_since(std::time::UNIX_EPOCH)
828            .map(|d| d.as_secs())
829            .unwrap_or(0);
830
831        let root_disk = p.disk_usage();
832        let mut aggregate_stats = ProcessStats {
833            cpu_percent: p.cpu_usage(),
834            memory_bytes: p.memory(),
835            uptime_secs: now.saturating_sub(p.start_time()),
836            disk_read_bytes: root_disk.read_bytes,
837            disk_write_bytes: root_disk.written_bytes,
838        };
839
840        let mut children_map: HashMap<sysinfo::Pid, Vec<sysinfo::Pid>> = HashMap::new();
841        for (child_pid, child) in processes {
842            if let Some(ppid) = child.parent() {
843                children_map.entry(ppid).or_default().push(*child_pid);
844            }
845        }
846
847        let mut queue = std::collections::VecDeque::new();
848        if let Some(direct_children) = children_map.get(&root_pid) {
849            queue.extend(direct_children);
850        }
851        while let Some(child_pid) = queue.pop_front() {
852            if let Some(child) = processes.get(&child_pid) {
853                let disk = child.disk_usage();
854                aggregate_stats.cpu_percent += child.cpu_usage();
855                aggregate_stats.memory_bytes += child.memory();
856                aggregate_stats.disk_read_bytes += disk.read_bytes;
857                aggregate_stats.disk_write_bytes += disk.written_bytes;
858            }
859            if let Some(grandchildren) = children_map.get(&child_pid) {
860                queue.extend(grandchildren);
861            }
862        }
863
864        Some(ExtendedProcessStats {
865            name: p.name().to_string_lossy().to_string(),
866            status: format!("{:?}", p.status()),
867            cpu_percent: aggregate_stats.cpu_percent,
868            memory_bytes: aggregate_stats.memory_bytes,
869            virtual_memory_bytes: p.virtual_memory(),
870            uptime_secs: aggregate_stats.uptime_secs,
871            thread_count: p.tasks().map(|t| t.len()).unwrap_or(0),
872        })
873    }
874}
875
876#[cfg(target_os = "linux")]
877fn process_start_token(pid: u32) -> Option<u64> {
878    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
879    let command_end = stat.rfind(')')?;
880    // Fields after the command start at field 3 (state); starttime is field 22.
881    stat.get(command_end + 1..)?
882        .split_whitespace()
883        .nth(19)?
884        .parse()
885        .ok()
886}
887
888#[cfg(target_os = "macos")]
889fn process_start_token(pid: u32) -> Option<u64> {
890    let mut info = std::mem::MaybeUninit::<libc::proc_bsdinfo>::zeroed();
891    let size = std::mem::size_of::<libc::proc_bsdinfo>() as i32;
892    let read = unsafe {
893        libc::proc_pidinfo(
894            pid as i32,
895            libc::PROC_PIDTBSDINFO,
896            0,
897            info.as_mut_ptr().cast(),
898            size,
899        )
900    };
901    if read != size {
902        return None;
903    }
904    let info = unsafe { info.assume_init() };
905    info.pbi_start_tvsec
906        .checked_mul(1_000_000)?
907        .checked_add(info.pbi_start_tvusec)
908}
909
910#[cfg(windows)]
911fn process_start_token(pid: u32) -> Option<u64> {
912    let handle = open_process_handle(pid).ok()?;
913    process_start_token_from_handle(handle.0)
914}
915
916#[cfg(windows)]
917fn process_start_token_from_handle(handle: HANDLE) -> Option<u64> {
918    let mut creation = FILETIME {
919        dwLowDateTime: 0,
920        dwHighDateTime: 0,
921    };
922    let mut exit = creation;
923    let mut kernel = creation;
924    let mut user = creation;
925    let ok = unsafe { GetProcessTimes(handle, &mut creation, &mut exit, &mut kernel, &mut user) };
926    if ok == 0 {
927        return None;
928    }
929
930    Some((u64::from(creation.dwHighDateTime) << 32) | u64::from(creation.dwLowDateTime))
931}
932
933#[cfg(windows)]
934struct ProcessHandle(HANDLE);
935
936#[cfg(windows)]
937impl Drop for ProcessHandle {
938    fn drop(&mut self) {
939        unsafe {
940            CloseHandle(self.0);
941        }
942    }
943}
944
945#[cfg(windows)]
946fn open_process_handle(pid: u32) -> std::io::Result<ProcessHandle> {
947    let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
948    if handle.is_null() {
949        return Err(std::io::Error::last_os_error());
950    }
951    Ok(ProcessHandle(handle))
952}
953
954#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
955fn process_start_token(pid: u32) -> Option<u64> {
956    let mut system = sysinfo::System::new();
957    let sysinfo_pid = sysinfo::Pid::from_u32(pid);
958    system.refresh_processes(ProcessesToUpdate::Some(&[sysinfo_pid]), true);
959    system
960        .process(sysinfo_pid)
961        .map(|process| process.start_time())
962}
963
964#[cfg(target_os = "linux")]
965fn open_pidfd(pid: u32) -> std::io::Result<OwnedFd> {
966    let fd = unsafe { libc::syscall(libc::SYS_pidfd_open, pid, 0) };
967    if fd < 0 {
968        return Err(std::io::Error::last_os_error());
969    }
970    Ok(unsafe { OwnedFd::from_raw_fd(fd as i32) })
971}
972
973#[cfg(target_os = "linux")]
974fn pidfd_is_running(pidfd: &OwnedFd) -> bool {
975    match try_pidfd_is_running(pidfd) {
976        Ok(running) => running,
977        Err(err) => {
978            warn!("failed to poll pidfd {}: {err}", pidfd.as_raw_fd());
979            true
980        }
981    }
982}
983
984#[cfg(target_os = "linux")]
985fn try_pidfd_is_running(pidfd: &OwnedFd) -> std::io::Result<bool> {
986    let mut pollfd = libc::pollfd {
987        fd: pidfd.as_raw_fd(),
988        events: libc::POLLIN,
989        revents: 0,
990    };
991    let result = unsafe { libc::poll(&mut pollfd, 1, 0) };
992    if result < 0 {
993        return Err(std::io::Error::last_os_error());
994    }
995    Ok(result == 0)
996}
997
998#[cfg(target_os = "linux")]
999fn signal_pidfds(members: &[(u32, OwnedFd)], signal: i32, signal_name: &str) -> Result<()> {
1000    for (pid, pidfd) in members {
1001        if !pidfd_is_running(pidfd) {
1002            continue;
1003        }
1004        let result = unsafe {
1005            libc::syscall(
1006                libc::SYS_pidfd_send_signal,
1007                pidfd.as_raw_fd(),
1008                signal,
1009                std::ptr::null::<libc::siginfo_t>(),
1010                0,
1011            )
1012        };
1013        if result == -1 {
1014            let err = std::io::Error::last_os_error();
1015            if err.raw_os_error() == Some(libc::ESRCH) {
1016                continue;
1017            }
1018            return Err(miette::miette!(
1019                "failed to send {signal_name} to pinned process {pid}: {err}"
1020            ));
1021        }
1022    }
1023    Ok(())
1024}
1025
1026#[cfg(target_os = "linux")]
1027fn stop_pidfds(members: &[(u32, OwnedFd)]) -> Result<()> {
1028    signal_pidfds(members, libc::SIGSTOP, "SIGSTOP")?;
1029    for _ in 0..200 {
1030        if members.iter().all(|(pid, pidfd)| {
1031            !pidfd_is_running(pidfd) || matches!(linux_process_state(*pid), Some('T' | 't'))
1032        }) {
1033            return Ok(());
1034        }
1035        std::thread::sleep(std::time::Duration::from_millis(5));
1036    }
1037    Err(miette::miette!(
1038        "timed out while freezing orphan process group"
1039    ))
1040}
1041
1042#[cfg(target_os = "linux")]
1043fn extend_process_group_pidfds(
1044    pgid: i32,
1045    members: &mut Vec<(u32, OwnedFd)>,
1046) -> std::io::Result<usize> {
1047    let entries = std::fs::read_dir("/proc")?;
1048    let mut added = 0;
1049    for entry in entries {
1050        let entry = entry?;
1051        let Some(pid) = entry
1052            .file_name()
1053            .to_str()
1054            .and_then(|name| name.parse::<u32>().ok())
1055        else {
1056            continue;
1057        };
1058        let Some(observed_identity) = linux_process_identity(pid) else {
1059            continue;
1060        };
1061        if observed_identity.0 != pgid {
1062            continue;
1063        }
1064        let mut already_pinned = false;
1065        for (known_pid, pidfd) in members.iter() {
1066            if *known_pid == pid && try_pidfd_is_running(pidfd)? {
1067                already_pinned = true;
1068                break;
1069            }
1070        }
1071        if already_pinned {
1072            continue;
1073        }
1074
1075        let pidfd = match open_pidfd(pid) {
1076            Ok(pidfd) => pidfd,
1077            Err(err) if err.raw_os_error() == Some(libc::ESRCH) => continue,
1078            Err(err) => return Err(err),
1079        };
1080        if linux_process_identity(pid) != Some(observed_identity) {
1081            return Err(std::io::Error::other(format!(
1082                "process {pid} identity changed while pinning group {pgid}"
1083            )));
1084        }
1085        members.push((pid, pidfd));
1086        added += 1;
1087    }
1088    Ok(added)
1089}
1090
1091#[cfg(target_os = "linux")]
1092fn linux_process_identity(pid: u32) -> Option<(i32, u64)> {
1093    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
1094    let command_end = stat.rfind(')')?;
1095    let fields: Vec<_> = stat.get(command_end + 1..)?.split_whitespace().collect();
1096    // Fields after the command start at field 3. pgrp is field 5 and the
1097    // scheduler-tick start token is field 22.
1098    Some((fields.get(2)?.parse().ok()?, fields.get(19)?.parse().ok()?))
1099}
1100
1101#[cfg(target_os = "linux")]
1102fn linux_process_state(pid: u32) -> Option<char> {
1103    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
1104    let command_end = stat.rfind(')')?;
1105    stat.get(command_end + 1..)?
1106        .split_whitespace()
1107        .next()?
1108        .chars()
1109        .next()
1110}
1111
1112#[derive(Debug, Clone, Copy)]
1113pub struct ProcessStats {
1114    pub cpu_percent: f32,
1115    pub memory_bytes: u64,
1116    pub uptime_secs: u64,
1117    pub disk_read_bytes: u64,
1118    pub disk_write_bytes: u64,
1119}
1120
1121impl ProcessStats {
1122    pub fn memory_display(&self) -> String {
1123        format_bytes(self.memory_bytes)
1124    }
1125
1126    pub fn cpu_display(&self) -> String {
1127        format!("{:.1}%", self.cpu_percent)
1128    }
1129
1130    pub fn uptime_display(&self) -> String {
1131        format_duration(self.uptime_secs)
1132    }
1133
1134    pub fn disk_read_display(&self) -> String {
1135        format_bytes_per_sec(self.disk_read_bytes)
1136    }
1137
1138    pub fn disk_write_display(&self) -> String {
1139        format_bytes_per_sec(self.disk_write_bytes)
1140    }
1141}
1142
1143#[derive(Debug, Clone)]
1144pub struct ExtendedProcessStats {
1145    pub name: String,
1146    pub status: String,
1147    pub cpu_percent: f32,
1148    pub memory_bytes: u64,
1149    pub virtual_memory_bytes: u64,
1150    pub uptime_secs: u64,
1151    pub thread_count: usize,
1152}
1153
1154fn format_bytes(bytes: u64) -> String {
1155    humanbyte::to_string(bytes, humanbyte::Format::IEC)
1156}
1157
1158fn format_duration(secs: u64) -> String {
1159    if secs < 60 {
1160        format!("{secs}s")
1161    } else if secs < 3600 {
1162        format!("{}m {}s", secs / 60, secs % 60)
1163    } else if secs < 86400 {
1164        let hours = secs / 3600;
1165        let mins = (secs % 3600) / 60;
1166        format!("{hours}h {mins}m")
1167    } else {
1168        let days = secs / 86400;
1169        let hours = (secs % 86400) / 3600;
1170        format!("{days}d {hours}h")
1171    }
1172}
1173
1174fn format_bytes_per_sec(bytes: u64) -> String {
1175    format!("{}/s", humanbyte::to_string(bytes, humanbyte::Format::IEC))
1176}
1177
1178/// Check whether a process group has no remaining members that we could
1179/// still be waiting on.
1180///
1181/// `killpg(pgid, 0)` probes for members without delivering a signal:
1182/// - success: at least one member remains that we may signal — keep waiting.
1183/// - ESRCH: the group is empty.
1184/// - EPERM: members remain, but none that we may signal (e.g. a root-owned
1185///   helper spawned inside the group). Our stop signal and SIGKILL never
1186///   reached them, so waiting longer cannot make progress — treat the group
1187///   as terminated, matching the previous leader-only behavior.
1188///
1189/// Unreaped zombies still count as members, but they are reaped promptly
1190/// (the leader by the supervisor's monitoring task via `child.wait()`,
1191/// orphans by init or the supervisor's PID-1 reaper), so the group empties
1192/// as soon as every member has actually exited.
1193#[cfg(unix)]
1194fn process_group_terminated(pgid: i32) -> bool {
1195    unsafe { libc::killpg(pgid, 0) != 0 }
1196}
1197
1198#[cfg(unix)]
1199fn signal_name(sig: i32) -> &'static str {
1200    match sig {
1201        libc::SIGHUP => "SIGHUP",
1202        libc::SIGINT => "SIGINT",
1203        libc::SIGQUIT => "SIGQUIT",
1204        libc::SIGTERM => "SIGTERM",
1205        libc::SIGUSR1 => "SIGUSR1",
1206        libc::SIGUSR2 => "SIGUSR2",
1207        libc::SIGKILL => "SIGKILL",
1208        _ => "UNKNOWN",
1209    }
1210}
1211
1212#[cfg(test)]
1213mod format_tests {
1214    use super::*;
1215
1216    #[test]
1217    fn process_start_time_check_rejects_mismatch() {
1218        let procs = Procs::new();
1219        let pid = std::process::id();
1220        procs.refresh_pids(&[pid]);
1221        let actual = procs
1222            .start_time(pid)
1223            .expect("current process should have a start time");
1224
1225        assert_ne!(procs.start_time(pid), Some(actual.saturating_add(1)));
1226    }
1227
1228    #[test]
1229    fn test_format_bytes() {
1230        assert_eq!(format_bytes(512), "512 B");
1231        assert_eq!(format_bytes(1024), "1.0 KiB");
1232        assert_eq!(format_bytes(1536), "1.5 KiB");
1233        assert_eq!(format_bytes(50 * 1024 * 1024), "50.0 MiB");
1234        assert_eq!(format_bytes(3 * 1024 * 1024 * 1024), "3.0 GiB");
1235        // rolls over past GiB instead of showing e.g. "1100.0GB"
1236        assert_eq!(format_bytes(1100 * 1024 * 1024 * 1024), "1.1 TiB");
1237    }
1238
1239    #[test]
1240    fn test_format_bytes_per_sec() {
1241        assert_eq!(format_bytes_per_sec(512), "512 B/s");
1242        assert_eq!(format_bytes_per_sec(1536), "1.5 KiB/s");
1243        assert_eq!(format_bytes_per_sec(2 * 1024 * 1024), "2.0 MiB/s");
1244    }
1245}
1246
1247#[cfg(all(test, unix))]
1248mod tests {
1249    use super::*;
1250    use std::os::unix::process::CommandExt;
1251    use std::process::{Child, Command, Stdio};
1252    use std::time::{Duration, Instant};
1253
1254    struct ChildGuard(Child);
1255
1256    impl Drop for ChildGuard {
1257        fn drop(&mut self) {
1258            let pid = self.0.id() as i32;
1259            // The test process is started in its own session, so PID == PGID.
1260            let _ = unsafe { libc::killpg(pid, libc::SIGKILL) };
1261            let _ = self.0.wait();
1262        }
1263    }
1264
1265    #[tokio::test]
1266    async fn orphan_identity_checked_group_kill_rejects_mismatch() {
1267        let mut command = Command::new("sleep");
1268        command
1269            .arg("30")
1270            .stdin(Stdio::null())
1271            .stdout(Stdio::null())
1272            .stderr(Stdio::null());
1273        unsafe {
1274            command.pre_exec(|| {
1275                if libc::setsid() == -1 {
1276                    return Err(std::io::Error::last_os_error());
1277                }
1278                Ok(())
1279            });
1280        }
1281
1282        let child = command.spawn().expect("failed to spawn test process");
1283        let pid = child.id();
1284        let _child = ChildGuard(child);
1285
1286        PROCS.refresh_pids(&[pid]);
1287        let actual_start_time = PROCS
1288            .start_time(pid)
1289            .expect("test process should have a start time");
1290
1291        let killed = PROCS
1292            .kill_process_group_if_start_time_matches_async(
1293                pid,
1294                Some(actual_start_time.saturating_add(1)),
1295                libc::SIGTERM,
1296                Some(Duration::from_millis(100)),
1297            )
1298            .await
1299            .expect("identity-checked kill should not error");
1300
1301        assert!(!killed);
1302        assert!(PROCS.is_running(pid), "mismatched process must survive");
1303    }
1304
1305    /// On Unix platforms without a durable process handle the generation
1306    /// check runs inside the blocking operation, immediately before the
1307    /// signal: a matching generation is signalled, a mismatched one is
1308    /// refused at the last moment.
1309    #[cfg(all(unix, not(target_os = "linux")))]
1310    #[tokio::test]
1311    async fn identity_checked_group_kill_reverifies_inside_blocking_op() {
1312        let mut command = Command::new("sleep");
1313        command
1314            .arg("30")
1315            .stdin(Stdio::null())
1316            .stdout(Stdio::null())
1317            .stderr(Stdio::null());
1318        unsafe {
1319            command.pre_exec(|| {
1320                if libc::setsid() == -1 {
1321                    return Err(std::io::Error::last_os_error());
1322                }
1323                Ok(())
1324            });
1325        }
1326
1327        let child = command.spawn().expect("failed to spawn test process");
1328        let pid = child.id();
1329        let _child = ChildGuard(child);
1330
1331        PROCS.refresh_pids(&[pid]);
1332        let actual_start_time = PROCS
1333            .start_time(pid)
1334            .expect("test process should have a start time");
1335
1336        let killed = PROCS
1337            .kill_process_group_if_start_time_matches_async(
1338                pid,
1339                Some(actual_start_time),
1340                libc::SIGTERM,
1341                Some(Duration::from_millis(100)),
1342            )
1343            .await
1344            .expect("identity-checked kill should not error");
1345
1346        assert!(killed, "matching generation must be signalled");
1347        assert!(
1348            !PROCS.is_running(pid),
1349            "signalled process group must be gone"
1350        );
1351    }
1352
1353    #[test]
1354    fn get_stats_includes_descendant_rss() {
1355        let mut command = Command::new("sh");
1356        command
1357            .args(["-c", "sleep 30 & wait"])
1358            .stdin(Stdio::null())
1359            .stdout(Stdio::null())
1360            .stderr(Stdio::null());
1361        unsafe {
1362            command.pre_exec(|| {
1363                if libc::setsid() == -1 {
1364                    return Err(std::io::Error::last_os_error());
1365                }
1366                Ok(())
1367            });
1368        }
1369
1370        let parent = command.spawn().expect("failed to spawn process tree");
1371        let parent_pid = parent.id();
1372        let _parent = ChildGuard(parent);
1373
1374        let procs = Procs::new();
1375        let deadline = Instant::now() + Duration::from_secs(5);
1376        let mut child_pids = Vec::new();
1377        while Instant::now() < deadline {
1378            procs.refresh_processes();
1379            child_pids = procs.all_children(parent_pid);
1380            if !child_pids.is_empty() {
1381                break;
1382            }
1383            std::thread::sleep(Duration::from_millis(50));
1384        }
1385        assert!(
1386            !child_pids.is_empty(),
1387            "test process tree did not appear under parent pid {parent_pid}"
1388        );
1389
1390        procs.refresh_processes();
1391        child_pids = procs.all_children(parent_pid);
1392        assert!(
1393            !child_pids.is_empty(),
1394            "test process tree disappeared under parent pid {parent_pid}"
1395        );
1396        let root_pid = sysinfo::Pid::from_u32(parent_pid);
1397        let direct_memory = {
1398            let system = procs.lock_system();
1399            system
1400                .process(root_pid)
1401                .expect("parent process should exist")
1402                .memory()
1403        };
1404        let descendant_memory = {
1405            let system = procs.lock_system();
1406            child_pids
1407                .iter()
1408                .filter_map(|pid| system.process(sysinfo::Pid::from_u32(*pid)))
1409                .map(|process| process.memory())
1410                .sum::<u64>()
1411        };
1412        assert!(
1413            descendant_memory > 0,
1414            "descendants {child_pids:?} should have nonzero RSS"
1415        );
1416
1417        let stats = procs
1418            .get_stats(parent_pid)
1419            .expect("parent process should have aggregate stats");
1420
1421        assert_eq!(
1422            stats.memory_bytes,
1423            direct_memory + descendant_memory,
1424            "get_stats should include descendant RSS for parent pid {parent_pid}; \
1425             descendants: {child_pids:?}, direct RSS: {direct_memory}, \
1426             descendant RSS: {descendant_memory}, reported RSS: {}",
1427            stats.memory_bytes
1428        );
1429    }
1430
1431    #[test]
1432    fn full_refresh_is_throttled_by_ttl() {
1433        let procs = Procs::new();
1434
1435        // First call always refreshes: the table starts empty.
1436        assert!(
1437            procs.last_full_refresh.lock().unwrap().is_none(),
1438            "fresh Procs should have no recorded refresh"
1439        );
1440        procs.refresh_if_stale();
1441        let first = procs.last_full_refresh.lock().unwrap().unwrap();
1442        assert!(first.elapsed() < FULL_REFRESH_INTERVAL);
1443
1444        // A second call within the TTL window must NOT refresh again: the
1445        // recorded timestamp must stay identical (a refresh would advance it).
1446        procs.refresh_if_stale();
1447        let second = procs.last_full_refresh.lock().unwrap().unwrap();
1448        assert_eq!(
1449            second, first,
1450            "refresh_if_stale within TTL must skip the refresh and keep the timestamp"
1451        );
1452
1453        // Simulate an expired TTL: force the recorded timestamp into the past,
1454        // then confirm the next call refreshes and advances the timestamp.
1455        let expired = Instant::now()
1456            .checked_sub(FULL_REFRESH_INTERVAL + Duration::from_secs(1))
1457            .expect("system has been up long enough to backdate by 6s");
1458        *procs.last_full_refresh.lock().unwrap() = Some(expired);
1459        procs.refresh_if_stale();
1460        let third = procs.last_full_refresh.lock().unwrap().unwrap();
1461        assert!(
1462            third > expired,
1463            "refresh_if_stale after expired TTL must refresh and advance the timestamp"
1464        );
1465        assert!(
1466            third.elapsed() < FULL_REFRESH_INTERVAL,
1467            "fresh timestamp after expired-TTL refresh should be recent"
1468        );
1469    }
1470}