monitrs_core/process/subtree.rs
1//! Summing a process and its descendants (§2.4).
2//!
3//! A build, a container's entrypoint, a browser: the thing a user cares about is often
4//! not one process but a family of them, and no single row answers "how much is this
5//! costing me". This module answers it, and the whole difficulty is in being honest
6//! about the answer.
7//!
8//! # A sum over metrics that may not exist
9//!
10//! Twenty-three processes, three of which will not report their CPU because they belong
11//! to another user. What is the total?
12//!
13//! Reporting the sum of the twenty that did answer, as though it were the sum of
14//! twenty-three, is the failure §4 exists to prevent — it is a number that looks
15//! complete and is not. Reporting the whole thing as [`MetricState::PermissionDenied`]
16//! because one member refused is literally true and useless: the user wanted to know
17//! roughly what their build costs, and "unavailable" tells them nothing they could act
18//! on.
19//!
20//! So a sum carries its **coverage**: how many members contributed to it, out of how
21//! many there are. That is the same device §2.2 already uses for spike attribution —
22//! "78% of observed CPU accounted for by the retained top contributors" — and the same
23//! reasoning: a partial answer with its own limits stated is worth more than either a
24//! confident fiction or a refusal.
25//!
26//! A sum with *no* contributors is not zero. It is whatever the members said, collapsed:
27//! all-denied becomes [`MetricState::PermissionDenied`], all-warming-up becomes
28//! [`MetricState::WarmingUp`]. Zero is reserved for "measured, and it was zero".
29//!
30//! # What a subtree is, and when it stops being one
31//!
32//! Membership is by [`ProcessIdentity`], never by PID, so a recycled PID joins nothing
33//! (§26). The root is included in its own subtree: "this build" means the `cargo`
34//! process *and* the compilers it spawned.
35//!
36//! When the root exits, this returns [`None`]. It does not follow the surviving
37//! children: the kernel reparents them to init, and a set of processes whose common
38//! ancestor is gone is not the family the user asked to watch. Calling it one would be
39//! a fiction of exactly the kind this crate refuses elsewhere.
40//!
41//! # Cycles
42//!
43//! `/proc` can be read mid-`fork` and produce a parent chain that loops. The walk is
44//! bounded by the number of processes in the snapshot and each is visited once, so a
45//! cycle cannot make it hang — the same guarantee [`ProcessTree`] gives, arrived at the
46//! same way.
47//!
48//! [`ProcessTree`]: crate::process::ProcessTree
49
50use std::collections::{HashMap, HashSet};
51
52use crate::model::{MetricState, ProcessIdentity, ProcessSnapshot, SystemSnapshot};
53use crate::units::{Percent, Rate};
54
55/// How much of a sum's membership actually contributed to it.
56///
57/// `contributors` is never greater than `members`; a coverage where they are equal is a
58/// complete sum, and one where `contributors` is zero means nothing could be read at
59/// all — in which case the accompanying [`MetricState`] is unavailable rather than zero.
60#[derive(Clone, Copy, Debug, Eq, PartialEq)]
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
62pub struct Coverage {
63 /// Members whose value was readable and went into the sum.
64 pub contributors: usize,
65 /// Members the subtree holds.
66 pub members: usize,
67}
68
69impl Coverage {
70 /// Whether every member contributed, making the sum exact.
71 #[must_use]
72 pub const fn is_complete(&self) -> bool {
73 self.contributors >= self.members
74 }
75
76 /// Members that could not be read.
77 #[must_use]
78 pub const fn missing(&self) -> usize {
79 self.members.saturating_sub(self.contributors)
80 }
81
82 /// The share of members that contributed, for display.
83 ///
84 /// `None` for an empty membership rather than 100%: a share of nothing is not
85 /// complete, it is undefined, and §4 forbids inventing the difference.
86 #[must_use]
87 pub fn share(&self) -> Option<Percent> {
88 if self.members == 0 {
89 return None;
90 }
91 // Narrowing to f32 for a figure displayed with one decimal.
92 #[allow(clippy::cast_precision_loss)]
93 let share = (self.contributors as f32 / self.members as f32) * 100.0;
94 Percent::new(share)
95 }
96}
97
98/// One summed metric and the coverage of the sum.
99#[derive(Clone, Copy, Debug, PartialEq)]
100pub struct Summed<T> {
101 /// The sum of every member that could be read, or why none could be.
102 pub value: MetricState<T>,
103 /// How much of the membership the sum accounts for.
104 pub coverage: Coverage,
105}
106
107impl<T> Summed<T> {
108 /// Whether there is a number here that **understates** the subtree.
109 ///
110 /// This is the question a renderer actually has to answer, and it is not the same as
111 /// "is the coverage complete". A metric the platform does not report at all has no
112 /// contributors and therefore incomplete coverage — but there is no sum to
113 /// understate, and marking it partial would suggest a permission problem where the
114 /// truth is that per-process I/O does not exist on this OS. The [`MetricState`]
115 /// already says which of those it is; this says only "the figure you can see is
116 /// smaller than the truth".
117 #[must_use]
118 pub fn is_partial(&self) -> bool {
119 self.value.fresh().is_some() && !self.coverage.is_complete()
120 }
121}
122
123/// What a process and its descendants are using, together.
124#[derive(Clone, Debug, PartialEq)]
125pub struct SubtreeUsage {
126 /// The process the subtree is rooted at, still present in this snapshot.
127 pub root: ProcessIdentity,
128 /// Every member, root first, then descendants in breadth-first order.
129 ///
130 /// Breadth-first because a truncated list should show a build's direct children
131 /// before its grandchildren, and because the order is then stable under the
132 /// arbitrary order the OS enumerates processes in.
133 pub members: Vec<ProcessIdentity>,
134 /// Summed CPU. May exceed 100% — it is a sum of core-normalized shares (§8.3).
135 pub cpu: Summed<Percent>,
136 /// Summed resident memory.
137 ///
138 /// Shared pages are counted once per process that maps them, so this over-counts a
139 /// family that shares a lot — a browser especially. That is a property of RSS rather
140 /// than of this sum, and [`SubtreeUsage`] cannot fix it without a
141 /// proportional-set-size figure neither platform gives cheaply. Named in the docs
142 /// rather than silently presented as a memory total.
143 pub rss_bytes: Summed<u64>,
144 /// Summed read throughput.
145 pub read: Summed<Rate>,
146 /// Summed write throughput.
147 pub write: Summed<Rate>,
148 /// Parent links that were cut to break a cycle, for the Inspect screen.
149 pub cycles_broken: u32,
150}
151
152impl SubtreeUsage {
153 /// Sums `root` and its descendants in `snapshot`.
154 ///
155 /// [`None`] when `root` is not in the snapshot — it exited, or a PID was reused and
156 /// the identity no longer matches. The caller reports that as the subtree ending
157 /// rather than substituting the reparented children (see the module docs).
158 #[must_use]
159 pub fn of(snapshot: &SystemSnapshot, root: ProcessIdentity) -> Option<Self> {
160 Self::over(&snapshot.processes, root)
161 }
162
163 /// Sums over an arbitrary process list, which is what the tests and the reducer use.
164 #[must_use]
165 pub fn over(processes: &[ProcessSnapshot], root: ProcessIdentity) -> Option<Self> {
166 let by_identity: HashMap<ProcessIdentity, &ProcessSnapshot> = processes
167 .iter()
168 .map(|process| (process.identity, process))
169 .collect();
170 by_identity.get(&root)?;
171
172 // Children indexed by parent *PID*, because that is all a process carries — the
173 // parent's start key is not in the table. A PID collision here would mean the
174 // kernel reported two live processes with the same PID, which cannot happen.
175 let mut children: HashMap<u32, Vec<ProcessIdentity>> = HashMap::new();
176 for process in processes {
177 if let Some(parent) = process.parent_pid {
178 // A process whose parent is itself is the pathological `/proc` read that
179 // `ProcessTree` also guards against; dropping the link here is what stops
180 // the walk below from revisiting it.
181 if parent != process.identity.pid {
182 children.entry(parent).or_default().push(process.identity);
183 }
184 }
185 }
186 // Deterministic order regardless of how the OS enumerated the table (§7.2).
187 for siblings in children.values_mut() {
188 siblings.sort_unstable_by_key(|identity| (identity.pid, identity.start_key));
189 }
190
191 let mut members = Vec::new();
192 let mut seen: HashSet<ProcessIdentity> = HashSet::new();
193 let mut queue = std::collections::VecDeque::new();
194 let mut cycles_broken = 0u32;
195 queue.push_back(root);
196 seen.insert(root);
197 while let Some(identity) = queue.pop_front() {
198 members.push(identity);
199 for child in children.get(&identity.pid).into_iter().flatten() {
200 if seen.insert(*child) {
201 queue.push_back(*child);
202 } else {
203 // Already visited: the parent links form a cycle. Counted rather than
204 // followed, so the walk terminates and the Inspect screen can say the
205 // table was inconsistent.
206 cycles_broken = cycles_broken.saturating_add(1);
207 }
208 }
209 }
210
211 let rows: Vec<&ProcessSnapshot> = members
212 .iter()
213 .filter_map(|identity| by_identity.get(identity).copied())
214 .collect();
215
216 Some(Self {
217 root,
218 cpu: sum_percent(&rows, |process| process.cpu),
219 rss_bytes: sum_u64(&rows, |process| process.memory.rss_bytes),
220 read: sum_rate(&rows, |process| process.io.read),
221 write: sum_rate(&rows, |process| process.io.write),
222 members,
223 cycles_broken,
224 })
225 }
226
227 /// How many processes the subtree holds, including the root.
228 #[must_use]
229 pub fn len(&self) -> usize {
230 self.members.len()
231 }
232
233 /// Whether the subtree holds nothing, which cannot happen: the root is a member.
234 #[must_use]
235 pub fn is_empty(&self) -> bool {
236 self.members.is_empty()
237 }
238
239 /// Whether any visible figure understates the subtree.
240 ///
241 /// The flag a renderer needs next to the numbers: true means at least one sum is a
242 /// lower bound, and the per-metric [`Summed::coverage`] says which and by how many
243 /// members.
244 #[must_use]
245 pub fn has_partial_sums(&self) -> bool {
246 self.cpu.is_partial()
247 || self.rss_bytes.is_partial()
248 || self.read.is_partial()
249 || self.write.is_partial()
250 }
251}
252
253/// The state an all-unavailable sum collapses to.
254///
255/// The *first* unavailability in member order, so a subtree where everything was refused
256/// says `permission denied` rather than a generic absence. `WarmingUp` is the fallback
257/// for an empty membership, which the public API cannot produce — the root is always a
258/// member — but which keeps this total rather than panicking.
259fn collapse<T: Copy>(states: &[MetricState<T>]) -> MetricState<T> {
260 states
261 .iter()
262 .find(|state| state.fresh().is_none())
263 .map_or(MetricState::WarmingUp, |state| match state {
264 // A stale value contributed its number, so it is not a reason for the sum to
265 // be unavailable; it can only be reached here if the slice is all-stale,
266 // which `sum_*` never passes in.
267 MetricState::Available(_) | MetricState::Stale { .. } => MetricState::WarmingUp,
268 MetricState::WarmingUp => MetricState::WarmingUp,
269 MetricState::PermissionDenied => MetricState::PermissionDenied,
270 MetricState::Unsupported => MetricState::Unsupported,
271 MetricState::TemporarilyUnavailable(reason) => {
272 MetricState::TemporarilyUnavailable(*reason)
273 }
274 })
275}
276
277/// Sums the percentages a member reports, counting who contributed.
278///
279/// A stale value *is* counted: it was measured, the renderer marks the row as stale
280/// anyway, and excluding it would make a subtree's total drop every time one member's
281/// read failed once. §4 allows a retained value to be used as long as its age travels
282/// with it, and the coverage here is about readability rather than freshness.
283fn sum_percent(
284 rows: &[&ProcessSnapshot],
285 pick: impl Fn(&ProcessSnapshot) -> MetricState<Percent>,
286) -> Summed<Percent> {
287 let states: Vec<MetricState<Percent>> = rows.iter().map(|row| pick(row)).collect();
288 let mut total = 0.0f32;
289 let mut contributors = 0usize;
290 for state in &states {
291 if let Some((percent, _)) = state.displayable() {
292 total += percent.value();
293 contributors += 1;
294 }
295 }
296 Summed {
297 value: if contributors == 0 {
298 collapse(&states)
299 } else {
300 // A sum of core-normalized shares legitimately exceeds 100% (§8.3), which is
301 // why `Percent` is not clamped.
302 Percent::new(total).map_or(MetricState::WarmingUp, MetricState::Available)
303 },
304 coverage: Coverage {
305 contributors,
306 members: rows.len(),
307 },
308 }
309}
310
311/// Sums the byte counts a member reports.
312fn sum_u64(
313 rows: &[&ProcessSnapshot],
314 pick: impl Fn(&ProcessSnapshot) -> MetricState<u64>,
315) -> Summed<u64> {
316 let states: Vec<MetricState<u64>> = rows.iter().map(|row| pick(row)).collect();
317 let mut total = 0u64;
318 let mut contributors = 0usize;
319 for state in &states {
320 if let Some((bytes, _)) = state.displayable() {
321 total = total.saturating_add(*bytes);
322 contributors += 1;
323 }
324 }
325 Summed {
326 value: if contributors == 0 {
327 collapse(&states)
328 } else {
329 MetricState::Available(total)
330 },
331 coverage: Coverage {
332 contributors,
333 members: rows.len(),
334 },
335 }
336}
337
338/// Sums the rates a member reports.
339fn sum_rate(
340 rows: &[&ProcessSnapshot],
341 pick: impl Fn(&ProcessSnapshot) -> MetricState<Rate>,
342) -> Summed<Rate> {
343 let states: Vec<MetricState<Rate>> = rows.iter().map(|row| pick(row)).collect();
344 let mut total = 0.0f64;
345 let mut contributors = 0usize;
346 for state in &states {
347 if let Some((rate, _)) = state.displayable() {
348 total += rate.per_second();
349 contributors += 1;
350 }
351 }
352 Summed {
353 value: if contributors == 0 {
354 collapse(&states)
355 } else {
356 Rate::new(total).map_or(MetricState::WarmingUp, MetricState::Available)
357 },
358 coverage: Coverage {
359 contributors,
360 members: rows.len(),
361 },
362 }
363}
364
365#[cfg(test)]
366mod tests {
367 use super::*;
368 use crate::process::fixtures::process;
369
370 /// `cargo` with two compilers under it, one of which has a child of its own.
371 fn build_tree() -> Vec<ProcessSnapshot> {
372 vec![
373 process(1, 1).name("launchd").cpu(0.1).build(),
374 process(100, 100)
375 .name("cargo")
376 .parent(1)
377 .cpu(2.0)
378 .rss(64)
379 .build(),
380 process(101, 101)
381 .name("rustc")
382 .parent(100)
383 .cpu(120.0)
384 .rss(2048)
385 .build(),
386 process(102, 102)
387 .name("rustc")
388 .parent(100)
389 .cpu(98.0)
390 .rss(1024)
391 .build(),
392 process(103, 103)
393 .name("cc")
394 .parent(101)
395 .cpu(30.0)
396 .rss(256)
397 .build(),
398 // A sibling of the root, which must not be counted.
399 process(200, 200)
400 .name("zsh")
401 .parent(1)
402 .cpu(0.5)
403 .rss(8)
404 .build(),
405 ]
406 }
407
408 #[test]
409 fn a_subtree_sums_the_root_and_every_descendant_but_nothing_else() {
410 let usage = SubtreeUsage::over(&build_tree(), ProcessIdentity::new(100, 100))
411 .expect("the root is present");
412
413 assert_eq!(usage.len(), 4, "cargo, two rustc, one cc");
414 assert_eq!(
415 usage.members,
416 vec![
417 ProcessIdentity::new(100, 100),
418 ProcessIdentity::new(101, 101),
419 ProcessIdentity::new(102, 102),
420 ProcessIdentity::new(103, 103),
421 ],
422 "root first, then breadth-first"
423 );
424 // 2 + 120 + 98 + 30, and deliberately over 100%: a sum of core-normalized
425 // shares is not a share of the machine (§8.3).
426 assert_eq!(
427 usage.cpu.value.fresh().map(|percent| percent.value()),
428 Some(250.0)
429 );
430 assert_eq!(usage.rss_bytes.value.fresh(), Some(&3392));
431 assert!(
432 !usage.has_partial_sums(),
433 "every member reported CPU and RSS, so neither figure understates"
434 );
435 // The fixture's platform reports no per-process I/O at all, so those sums are
436 // `Unsupported` with no contributors — which is not a partial sum, because there
437 // is no number there to understate.
438 assert_eq!(usage.read.value, MetricState::Unsupported);
439 assert!(!usage.read.is_partial());
440 assert_eq!(usage.cycles_broken, 0);
441 }
442
443 #[test]
444 fn a_root_that_has_exited_is_none_rather_than_its_reparented_children() {
445 // The kernel gives the children to init, and a family whose common ancestor is
446 // gone is not the family the user asked to watch.
447 let usage = SubtreeUsage::over(&build_tree(), ProcessIdentity::new(100, 999));
448 assert!(
449 usage.is_none(),
450 "a start key that does not match is not the root"
451 );
452 assert!(SubtreeUsage::over(&build_tree(), ProcessIdentity::new(4242, 1)).is_none());
453 }
454
455 #[test]
456 fn a_leaf_subtree_is_just_itself() {
457 let usage =
458 SubtreeUsage::over(&build_tree(), ProcessIdentity::new(103, 103)).expect("present");
459 assert_eq!(usage.len(), 1);
460 assert_eq!(usage.cpu.value.fresh().map(|p| p.value()), Some(30.0));
461 assert!(!usage.has_partial_sums());
462 }
463
464 #[test]
465 fn a_member_that_refuses_its_metric_leaves_the_sum_incomplete_rather_than_wrong() {
466 let mut processes = build_tree();
467 // The second compiler belongs to somebody else.
468 if let Some(row) = processes.get_mut(3) {
469 row.cpu = MetricState::PermissionDenied;
470 }
471 let usage = SubtreeUsage::over(&processes, ProcessIdentity::new(100, 100)).expect("root");
472
473 assert_eq!(
474 usage.cpu.value.fresh().map(|p| p.value()),
475 Some(152.0),
476 "the readable members still sum"
477 );
478 assert!(usage.cpu.is_partial(), "and the figure says it understates");
479 assert_eq!(usage.cpu.coverage.missing(), 1);
480 assert_eq!(usage.cpu.coverage.contributors, 3);
481 assert_eq!(usage.cpu.coverage.members, 4);
482 // Partiality is per metric, not per subtree: a process may refuse its CPU and
483 // report its memory, and the memory total is then exact.
484 assert!(!usage.rss_bytes.is_partial());
485 assert!(usage.has_partial_sums());
486 }
487
488 #[test]
489 fn a_subtree_where_nothing_can_be_read_is_unavailable_and_never_zero() {
490 // §4/§26: this is the case a naive sum turns into `0`, which reads as "this
491 // build is using no CPU at all".
492 let mut processes = build_tree();
493 for row in &mut processes {
494 row.cpu = MetricState::PermissionDenied;
495 }
496 let usage = SubtreeUsage::over(&processes, ProcessIdentity::new(100, 100)).expect("root");
497
498 assert_eq!(usage.cpu.value, MetricState::PermissionDenied);
499 assert!(usage.cpu.value.fresh().is_none());
500 assert_eq!(usage.cpu.coverage.contributors, 0);
501 assert_eq!(usage.cpu.coverage.share(), Some(Percent::ZERO));
502 // And with no number on screen there is nothing to mark as understating: the
503 // state itself is the whole message.
504 assert!(!usage.cpu.is_partial());
505 }
506
507 #[test]
508 fn a_warming_up_subtree_says_so_rather_than_reporting_a_zero_total() {
509 let mut processes = build_tree();
510 for row in &mut processes {
511 row.cpu = MetricState::WarmingUp;
512 }
513 let usage = SubtreeUsage::over(&processes, ProcessIdentity::new(100, 100)).expect("root");
514 assert_eq!(usage.cpu.value, MetricState::WarmingUp);
515 }
516
517 #[test]
518 fn a_stale_member_still_contributes_because_it_was_measured() {
519 // Excluding it would make a subtree's total drop every time one member's read
520 // failed once, which looks like the build getting cheaper.
521 let mut processes = build_tree();
522 if let Some(row) = processes.get_mut(2) {
523 row.cpu = MetricState::Stale {
524 value: Percent::new(120.0).expect("valid"),
525 age: core::time::Duration::from_secs(2),
526 };
527 }
528 let usage = SubtreeUsage::over(&processes, ProcessIdentity::new(100, 100)).expect("root");
529 assert_eq!(usage.cpu.value.fresh().map(|p| p.value()), Some(250.0));
530 assert!(!usage.cpu.is_partial());
531 }
532
533 #[test]
534 fn a_parent_cycle_terminates_and_is_counted() {
535 // A `/proc` read caught mid-fork can produce a loop. The walk must end.
536 let processes = vec![
537 process(300, 300).name("a").parent(302).cpu(1.0).build(),
538 process(301, 301).name("b").parent(300).cpu(1.0).build(),
539 process(302, 302).name("c").parent(301).cpu(1.0).build(),
540 ];
541 let usage = SubtreeUsage::over(&processes, ProcessIdentity::new(300, 300)).expect("root");
542 assert_eq!(usage.len(), 3, "each process is visited once");
543 assert_eq!(usage.cycles_broken, 1, "and the loop is reported");
544 }
545
546 #[test]
547 fn a_self_parenting_process_is_its_own_subtree_and_no_cycle() {
548 // PID 1 reporting itself as its parent is the ordinary pathological case, and
549 // dropping the link is what keeps it from being reported as an inconsistency.
550 let mut processes = build_tree();
551 if let Some(row) = processes.get_mut(0) {
552 row.parent_pid = Some(1);
553 }
554 let usage = SubtreeUsage::over(&processes, ProcessIdentity::new(1, 1)).expect("root");
555 assert_eq!(
556 usage.cycles_broken, 0,
557 "a self-parent is dropped, not reported as a loop"
558 );
559 assert_eq!(
560 usage.len(),
561 6,
562 "PID 1's subtree is the whole table: cargo and zsh are its children, and \
563 cargo's compilers are its grandchildren"
564 );
565 }
566
567 #[test]
568 fn coverage_of_an_empty_membership_is_undefined_rather_than_complete() {
569 let coverage = Coverage {
570 contributors: 0,
571 members: 0,
572 };
573 assert_eq!(coverage.share(), None, "a share of nothing is not 100%");
574 assert_eq!(coverage.missing(), 0);
575 }
576}