1use std::collections::{HashMap, HashSet, VecDeque};
31use std::time::Duration;
32
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
35pub struct TreeUsage {
36 pub rss_bytes: u64,
38 pub cpu_time: Duration,
40 pub processes: u32,
42}
43
44type Facts = (u64, Duration);
46
47#[must_use]
49pub fn sample_tree(root: u32) -> Option<TreeUsage> {
50 sample_trees(&[root]).remove(&root)
51}
52
53#[must_use]
56pub fn sample_trees(roots: &[u32]) -> HashMap<u32, TreeUsage> {
57 if roots.is_empty() {
58 return HashMap::new();
59 }
60 sys::sample_trees(roots)
61}
62
63fn trees(
67 roots: &[u32],
68 parents: &[(u32, u32)],
69 mut facts: impl FnMut(u32) -> Option<Facts>,
70 mut is_child: impl FnMut(u32, u32) -> bool,
71) -> HashMap<u32, TreeUsage> {
72 let mut children: HashMap<u32, Vec<u32>> = HashMap::new();
73 for (pid, parent) in parents {
74 if pid != parent {
75 children.entry(*parent).or_default().push(*pid);
76 }
77 }
78 let alive: HashSet<u32> = parents.iter().map(|(pid, _)| *pid).collect();
79 let mut found = HashMap::new();
80 for root in roots.iter().copied().filter(|root| alive.contains(root)) {
81 let mut usage = TreeUsage::default();
82 let mut seen = HashSet::from([root]);
83 let mut queue = VecDeque::from([root]);
84 while let Some(pid) = queue.pop_front() {
85 let (rss, cpu) = facts(pid).unwrap_or_default();
86 usage.rss_bytes = usage.rss_bytes.saturating_add(rss);
87 usage.cpu_time = usage.cpu_time.saturating_add(cpu);
88 usage.processes = usage.processes.saturating_add(1);
89 for child in children.get(&pid).into_iter().flatten() {
90 if !seen.contains(child) && is_child(*child, pid) {
91 seen.insert(*child);
92 queue.push_back(*child);
93 }
94 }
95 }
96 found.insert(root, usage);
97 }
98 found
99}
100
101#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
106fn parse_proc_stat(line: &str) -> Option<(u32, u64, u64)> {
107 let fields: Vec<&str> = crate::process::proc_stat_after_comm(line)?
108 .split_whitespace()
109 .collect();
110 let field = |number: usize| fields.get(number - 3).copied();
113 let parent = field(4)?.parse().ok()?;
114 let ticks = [14, 15, 16, 17]
115 .into_iter()
116 .map(|number| field(number)?.parse::<i64>().ok())
117 .try_fold(0u64, |sum, value| {
118 Some(sum.saturating_add(u64::try_from(value?).unwrap_or(0)))
119 })?;
120 let pages = u64::try_from(field(24)?.parse::<i64>().ok()?).unwrap_or(0);
121 Some((parent, ticks, pages))
122}
123
124#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
128fn proc_stat_exited(line: &str) -> bool {
129 crate::process::proc_stat_after_comm(line)
130 .and_then(|fields| fields.split_whitespace().next())
131 .is_some_and(|state| matches!(state, "Z" | "X" | "x"))
132}
133
134#[cfg(target_os = "linux")]
135mod sys {
136 use std::collections::HashMap;
137 use std::time::Duration;
138
139 use super::{Facts, TreeUsage, parse_proc_stat, proc_stat_exited, trees};
140
141 pub(super) fn sample_trees(roots: &[u32]) -> HashMap<u32, TreeUsage> {
142 let ticks_per_second = u64::try_from(unsafe { libc::sysconf(libc::_SC_CLK_TCK) })
145 .ok()
146 .filter(|ticks| *ticks > 0)
147 .unwrap_or(100);
148 let page = u64::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) })
150 .ok()
151 .filter(|page| *page > 0)
152 .unwrap_or(4096);
153 let mut parents = Vec::new();
154 let mut facts: HashMap<u32, Facts> = HashMap::new();
155 let Ok(entries) = std::fs::read_dir("/proc") else {
156 return HashMap::new();
157 };
158 for entry in entries.flatten() {
159 let Some(pid) = entry
160 .file_name()
161 .to_str()
162 .and_then(|n| n.parse::<u32>().ok())
163 else {
164 continue;
165 };
166 let Ok(line) = std::fs::read_to_string(entry.path().join("stat")) else {
168 continue;
169 };
170 if roots.contains(&pid) && proc_stat_exited(&line) {
174 continue;
175 }
176 if let Some((parent, ticks, pages)) = parse_proc_stat(&line) {
177 parents.push((pid, parent));
178 facts.insert(
179 pid,
180 (
181 pages.saturating_mul(page),
182 Duration::from_nanos(
183 ticks.saturating_mul(1_000_000_000) / ticks_per_second,
184 ),
185 ),
186 );
187 }
188 }
189 trees(roots, &parents, |pid| facts.get(&pid).copied(), |_, _| true)
190 }
191}
192
193#[cfg(target_os = "macos")]
194mod sys {
195 use std::collections::HashMap;
196 use std::time::Duration;
197
198 use super::{TreeUsage, trees};
199
200 fn info<T>(pid: i32, flavor: i32) -> Option<T> {
201 let size = i32::try_from(std::mem::size_of::<T>()).ok()?;
202 let mut value = std::mem::MaybeUninit::<T>::zeroed();
203 let written =
206 unsafe { libc::proc_pidinfo(pid, flavor, 0, value.as_mut_ptr().cast(), size) };
207 (written == size).then(|| unsafe { value.assume_init() })
210 }
211
212 #[allow(
215 deprecated,
216 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
217 )]
218 fn ticks_to_nanos(ticks: u64) -> u64 {
219 static BASE: std::sync::OnceLock<(u32, u32)> = std::sync::OnceLock::new();
220 let (numer, denom) = *BASE.get_or_init(|| {
221 let mut base = libc::mach_timebase_info { numer: 0, denom: 0 };
222 let ok = unsafe { libc::mach_timebase_info(&raw mut base) } == 0;
224 if ok && base.denom != 0 {
225 (base.numer, base.denom)
226 } else {
227 (1, 1)
228 }
229 });
230 u64::try_from(u128::from(ticks) * u128::from(numer) / u128::from(denom)).unwrap_or(u64::MAX)
231 }
232
233 fn all_pids() -> Vec<i32> {
234 let count = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
236 let Ok(count) = usize::try_from(count) else {
237 return Vec::new();
238 };
239 let mut pids = vec![0i32; count + 64];
241 let bytes = i32::try_from(pids.len() * std::mem::size_of::<i32>()).unwrap_or(i32::MAX);
242 let filled = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast(), bytes) };
244 pids.truncate(usize::try_from(filled).unwrap_or(0).min(pids.len()));
245 pids
246 }
247
248 pub(super) fn sample_trees(roots: &[u32]) -> HashMap<u32, TreeUsage> {
249 let parents: Vec<(u32, u32)> = all_pids()
250 .into_iter()
251 .filter(|pid| *pid > 0)
252 .filter_map(|pid| {
253 let bsd: libc::proc_bsdshortinfo = info(pid, libc::PROC_PIDT_SHORTBSDINFO)?;
254 Some((u32::try_from(pid).ok()?, bsd.pbsi_ppid))
255 })
256 .collect();
257 trees(
258 roots,
259 &parents,
260 |pid| {
261 let task: libc::proc_taskinfo =
262 info(i32::try_from(pid).ok()?, libc::PROC_PIDTASKINFO)?;
263 Some((
264 task.pti_resident_size,
265 Duration::from_nanos(ticks_to_nanos(
266 task.pti_total_user.saturating_add(task.pti_total_system),
267 )),
268 ))
269 },
270 |_, _| true,
271 )
272 }
273}
274
275#[cfg(windows)]
276mod sys {
277 use std::collections::HashMap;
278 use std::time::Duration;
279
280 use windows::Win32::Foundation::{CloseHandle, FILETIME};
281 use windows::Win32::System::Diagnostics::ToolHelp::{
282 CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW,
283 TH32CS_SNAPPROCESS,
284 };
285 use windows::Win32::System::ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS};
286 use windows::Win32::System::Threading::{
287 GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
288 };
289
290 use super::{Facts, TreeUsage, trees};
291
292 fn parents() -> Vec<(u32, u32)> {
293 let Ok(snapshot) = (unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }) else {
296 return Vec::new();
297 };
298 let mut entry = PROCESSENTRY32W {
299 dwSize: u32::try_from(std::mem::size_of::<PROCESSENTRY32W>()).unwrap_or(0),
300 ..PROCESSENTRY32W::default()
301 };
302 let mut pairs = Vec::new();
303 let mut more = unsafe { Process32FirstW(snapshot, &raw mut entry) }.is_ok();
306 while more {
307 pairs.push((entry.th32ProcessID, entry.th32ParentProcessID));
308 more = unsafe { Process32NextW(snapshot, &raw mut entry) }.is_ok();
310 }
311 let _ = unsafe { CloseHandle(snapshot) };
313 pairs
314 }
315
316 fn hundreds(time: FILETIME) -> u64 {
318 (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime)
319 }
320
321 fn filetime(time: FILETIME) -> Duration {
322 Duration::from_nanos(hundreds(time).saturating_mul(100))
323 }
324
325 fn facts(pid: u32) -> Option<(Facts, Option<u64>)> {
328 let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }.ok()?;
331 let mut memory = PROCESS_MEMORY_COUNTERS {
332 cb: u32::try_from(std::mem::size_of::<PROCESS_MEMORY_COUNTERS>()).unwrap_or(0),
333 ..PROCESS_MEMORY_COUNTERS::default()
334 };
335 let rss = if unsafe { GetProcessMemoryInfo(handle, &raw mut memory, memory.cb) }.is_ok() {
338 u64::try_from(memory.WorkingSetSize).unwrap_or(u64::MAX)
339 } else {
340 0
341 };
342 let (mut created, mut exited, mut kernel, mut user) = (
343 FILETIME::default(),
344 FILETIME::default(),
345 FILETIME::default(),
346 FILETIME::default(),
347 );
348 let timed = unsafe {
350 GetProcessTimes(
351 handle,
352 &raw mut created,
353 &raw mut exited,
354 &raw mut kernel,
355 &raw mut user,
356 )
357 };
358 let (cpu, born) = if timed.is_ok() {
359 (
360 filetime(kernel).saturating_add(filetime(user)),
361 Some(hundreds(created)),
362 )
363 } else {
364 (Duration::ZERO, None)
365 };
366 let _ = unsafe { CloseHandle(handle) };
368 Some(((rss, cpu), born))
369 }
370
371 pub(super) fn sample_trees(roots: &[u32]) -> HashMap<u32, TreeUsage> {
372 let known = std::cell::RefCell::new(HashMap::<u32, Option<(Facts, Option<u64>)>>::new());
373 let look = |pid: u32| *known.borrow_mut().entry(pid).or_insert_with(|| facts(pid));
374 trees(
375 roots,
376 &parents(),
377 |pid| look(pid).map(|(facts, _)| facts),
378 |child, parent| match (look(child), look(parent)) {
381 (Some((_, Some(child))), Some((_, Some(parent)))) => child >= parent,
382 _ => true,
383 },
384 )
385 }
386}
387
388#[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
389mod sys {
390 use std::collections::HashMap;
391
392 use super::TreeUsage;
393
394 pub(super) fn sample_trees(_: &[u32]) -> HashMap<u32, TreeUsage> {
395 HashMap::new()
396 }
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[test]
404 fn a_stat_line_parses_whatever_the_command_name_holds() {
405 let line = "4242 (Runner.Worker (x) 1) S 4200 4242 4200 0 -1 4194560 100 0 0 0 \
406 250 50 7 3 20 0 12 0 123456 1048576000 25600 18446744073709551615";
407 assert_eq!(parse_proc_stat(line), Some((4200, 310, 25600)));
408 assert_eq!(parse_proc_stat("garbage"), None);
409 assert_eq!(parse_proc_stat("1 (init) S"), None);
410 }
411
412 #[test]
413 fn a_tree_sums_its_descendants_only_and_survives_a_cycle() {
414 let parents = [
415 (1, 0),
416 (10, 1),
417 (11, 10),
418 (12, 11),
419 (20, 1),
420 (30, 12),
421 (12, 30),
422 ];
423 let facts: HashMap<u32, Facts> = [
424 (1, (1, 1)),
425 (10, (100, 10)),
426 (11, (20, 2)),
427 (12, (30, 3)),
428 (20, (999, 99)),
429 (30, (5, 1)),
430 ]
431 .into_iter()
432 .map(|(pid, (rss, secs))| (pid, (rss, Duration::from_secs(secs))))
433 .collect();
434 let found = trees(
435 &[10, 99],
436 &parents,
437 |pid| facts.get(&pid).copied(),
438 |_, _| true,
439 );
440 assert_eq!(
441 found,
442 HashMap::from([(
443 10,
444 TreeUsage {
445 rss_bytes: 155,
446 cpu_time: Duration::from_secs(16),
447 processes: 4,
448 }
449 )]),
450 "the gone root 99 has no entry"
451 );
452 let unreadable = trees(&[10], &parents, |_| None, |_, _| true);
454 assert_eq!(unreadable[&10].processes, 4);
455 let cut = trees(
457 &[10],
458 &parents,
459 |pid| facts.get(&pid).copied(),
460 |child, _| child != 11,
461 );
462 assert_eq!(cut[&10].processes, 1);
463 }
464
465 #[test]
466 fn a_stat_line_says_whether_the_process_exited() {
467 let line = |state: &str| {
468 format!("7 (run.sh) {state} 1 7 7 0 -1 0 0 0 0 0 5 6 7 8 20 0 1 0 100 4096 42 0")
469 };
470 assert!(!proc_stat_exited(&line("S")));
471 assert!(!proc_stat_exited(&line("R")));
472 assert!(proc_stat_exited(&line("Z")));
473 assert!(proc_stat_exited(&line("X")));
474 assert!(!proc_stat_exited("garbage"));
475 }
476
477 #[test]
481 fn a_process_that_exited_and_was_not_reaped_is_gone() {
482 #[cfg(unix)]
483 let mut child = std::process::Command::new("sh")
484 .args(["-c", "exit 0"])
485 .spawn()
486 .expect("sh starts");
487 #[cfg(windows)]
488 let mut child = std::process::Command::new("cmd")
489 .args(["/C", "exit 0"])
490 .spawn()
491 .expect("cmd starts");
492 let root = child.id();
493 let mut gone = false;
494 for _ in 0..50 {
495 std::thread::sleep(Duration::from_millis(100));
496 if sample_tree(root).is_none() {
497 gone = true;
498 break;
499 }
500 }
501 let reaped = child.wait();
502 assert!(reaped.is_ok(), "it was still ours to reap: {reaped:?}");
503 assert!(gone, "an exited process still reads as running");
504 }
505
506 #[cfg(unix)]
508 #[test]
509 fn a_spawned_process_tree_is_measured_on_this_os() {
510 let mut child = std::process::Command::new("sh")
513 .args([
514 "-c",
515 "sleep 30 & i=0; while [ $i -lt 200000 ]; do i=$((i+1)); done; wait",
516 ])
517 .spawn()
518 .expect("sh starts");
519 let root = child.id();
520 let mut best = None;
521 for _ in 0..50 {
522 std::thread::sleep(Duration::from_millis(100));
523 if let Some(usage) = sample_tree(root)
524 && usage.processes >= 2
525 && usage.cpu_time > Duration::ZERO
526 {
527 best = Some(usage);
528 break;
529 }
530 }
531 let _ = child.kill();
532 let _ = child.wait();
533 let usage = best.expect("the tree was seen with its child and some CPU");
534 assert!(usage.rss_bytes > 0, "{usage:?}");
535 assert_eq!(sample_tree(root), None, "a reaped root is gone");
536 }
537
538 #[cfg(windows)]
539 #[test]
540 fn a_spawned_process_tree_is_measured_on_this_os() {
541 let mut child = std::process::Command::new("cmd")
542 .args(["/C", "ping -n 6 127.0.0.1 > NUL"])
543 .spawn()
544 .expect("cmd starts");
545 let root = child.id();
546 let mut best = None;
547 for _ in 0..50 {
548 std::thread::sleep(Duration::from_millis(100));
549 if let Some(usage) = sample_tree(root)
550 && usage.processes >= 2
551 {
552 best = Some(usage);
553 break;
554 }
555 }
556 let _ = child.kill();
557 let _ = child.wait();
558 let usage = best.expect("the tree was seen with its child");
559 assert!(usage.rss_bytes > 0, "{usage:?}");
560 }
561}