Skip to main content

pitchfork_cli/
procs.rs

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