Skip to main content

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}