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
20type ParentToChildren = HashMap<u32, Vec<u32>>;
22
23type 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 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 pub fn boot_time(&self) -> u64 {
77 sysinfo::System::boot_time()
78 }
79
80 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 #[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 #[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 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 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 #[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 #[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 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 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 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 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 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 #[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 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 let output = std::process::Command::new("taskkill")
460 .args(["/F", "/T", "/PID"])
461 .arg(pid.to_string())
462 .creation_flags(0x08000000) .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 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 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 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 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 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 std::thread::sleep(std::time::Duration::from_millis(100));
569 Ok(true)
570 }
571 }
572
573 #[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 #[cfg(windows)]
595 system.refresh_cpu_usage();
596 }
597
598 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 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 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 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 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 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 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 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 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 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 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 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 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}