Skip to main content

pitchfork_cli/
procs.rs

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