use std::collections::{HashMap, HashSet};
use crate::model::{MetricState, ProcessIdentity, ProcessSnapshot, SystemSnapshot};
use crate::units::{Percent, Rate};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Coverage {
pub contributors: usize,
pub members: usize,
}
impl Coverage {
#[must_use]
pub const fn is_complete(&self) -> bool {
self.contributors >= self.members
}
#[must_use]
pub const fn missing(&self) -> usize {
self.members.saturating_sub(self.contributors)
}
#[must_use]
pub fn share(&self) -> Option<Percent> {
if self.members == 0 {
return None;
}
#[allow(clippy::cast_precision_loss)]
let share = (self.contributors as f32 / self.members as f32) * 100.0;
Percent::new(share)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Summed<T> {
pub value: MetricState<T>,
pub coverage: Coverage,
}
impl<T> Summed<T> {
#[must_use]
pub fn is_partial(&self) -> bool {
self.value.fresh().is_some() && !self.coverage.is_complete()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct SubtreeUsage {
pub root: ProcessIdentity,
pub members: Vec<ProcessIdentity>,
pub cpu: Summed<Percent>,
pub rss_bytes: Summed<u64>,
pub read: Summed<Rate>,
pub write: Summed<Rate>,
pub cycles_broken: u32,
}
impl SubtreeUsage {
#[must_use]
pub fn of(snapshot: &SystemSnapshot, root: ProcessIdentity) -> Option<Self> {
Self::over(&snapshot.processes, root)
}
#[must_use]
pub fn over(processes: &[ProcessSnapshot], root: ProcessIdentity) -> Option<Self> {
let by_identity: HashMap<ProcessIdentity, &ProcessSnapshot> = processes
.iter()
.map(|process| (process.identity, process))
.collect();
by_identity.get(&root)?;
let mut children: HashMap<u32, Vec<ProcessIdentity>> = HashMap::new();
for process in processes {
if let Some(parent) = process.parent_pid {
if parent != process.identity.pid {
children.entry(parent).or_default().push(process.identity);
}
}
}
for siblings in children.values_mut() {
siblings.sort_unstable_by_key(|identity| (identity.pid, identity.start_key));
}
let mut members = Vec::new();
let mut seen: HashSet<ProcessIdentity> = HashSet::new();
let mut queue = std::collections::VecDeque::new();
let mut cycles_broken = 0u32;
queue.push_back(root);
seen.insert(root);
while let Some(identity) = queue.pop_front() {
members.push(identity);
for child in children.get(&identity.pid).into_iter().flatten() {
if seen.insert(*child) {
queue.push_back(*child);
} else {
cycles_broken = cycles_broken.saturating_add(1);
}
}
}
let rows: Vec<&ProcessSnapshot> = members
.iter()
.filter_map(|identity| by_identity.get(identity).copied())
.collect();
Some(Self {
root,
cpu: sum_percent(&rows, |process| process.cpu),
rss_bytes: sum_u64(&rows, |process| process.memory.rss_bytes),
read: sum_rate(&rows, |process| process.io.read),
write: sum_rate(&rows, |process| process.io.write),
members,
cycles_broken,
})
}
#[must_use]
pub fn len(&self) -> usize {
self.members.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.members.is_empty()
}
#[must_use]
pub fn has_partial_sums(&self) -> bool {
self.cpu.is_partial()
|| self.rss_bytes.is_partial()
|| self.read.is_partial()
|| self.write.is_partial()
}
}
fn collapse<T: Copy>(states: &[MetricState<T>]) -> MetricState<T> {
states
.iter()
.find(|state| state.fresh().is_none())
.map_or(MetricState::WarmingUp, |state| match state {
MetricState::Available(_) | MetricState::Stale { .. } => MetricState::WarmingUp,
MetricState::WarmingUp => MetricState::WarmingUp,
MetricState::PermissionDenied => MetricState::PermissionDenied,
MetricState::Unsupported => MetricState::Unsupported,
MetricState::TemporarilyUnavailable(reason) => {
MetricState::TemporarilyUnavailable(*reason)
}
})
}
fn sum_percent(
rows: &[&ProcessSnapshot],
pick: impl Fn(&ProcessSnapshot) -> MetricState<Percent>,
) -> Summed<Percent> {
let states: Vec<MetricState<Percent>> = rows.iter().map(|row| pick(row)).collect();
let mut total = 0.0f32;
let mut contributors = 0usize;
for state in &states {
if let Some((percent, _)) = state.displayable() {
total += percent.value();
contributors += 1;
}
}
Summed {
value: if contributors == 0 {
collapse(&states)
} else {
Percent::new(total).map_or(MetricState::WarmingUp, MetricState::Available)
},
coverage: Coverage {
contributors,
members: rows.len(),
},
}
}
fn sum_u64(
rows: &[&ProcessSnapshot],
pick: impl Fn(&ProcessSnapshot) -> MetricState<u64>,
) -> Summed<u64> {
let states: Vec<MetricState<u64>> = rows.iter().map(|row| pick(row)).collect();
let mut total = 0u64;
let mut contributors = 0usize;
for state in &states {
if let Some((bytes, _)) = state.displayable() {
total = total.saturating_add(*bytes);
contributors += 1;
}
}
Summed {
value: if contributors == 0 {
collapse(&states)
} else {
MetricState::Available(total)
},
coverage: Coverage {
contributors,
members: rows.len(),
},
}
}
fn sum_rate(
rows: &[&ProcessSnapshot],
pick: impl Fn(&ProcessSnapshot) -> MetricState<Rate>,
) -> Summed<Rate> {
let states: Vec<MetricState<Rate>> = rows.iter().map(|row| pick(row)).collect();
let mut total = 0.0f64;
let mut contributors = 0usize;
for state in &states {
if let Some((rate, _)) = state.displayable() {
total += rate.per_second();
contributors += 1;
}
}
Summed {
value: if contributors == 0 {
collapse(&states)
} else {
Rate::new(total).map_or(MetricState::WarmingUp, MetricState::Available)
},
coverage: Coverage {
contributors,
members: rows.len(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::process::fixtures::process;
fn build_tree() -> Vec<ProcessSnapshot> {
vec![
process(1, 1).name("launchd").cpu(0.1).build(),
process(100, 100)
.name("cargo")
.parent(1)
.cpu(2.0)
.rss(64)
.build(),
process(101, 101)
.name("rustc")
.parent(100)
.cpu(120.0)
.rss(2048)
.build(),
process(102, 102)
.name("rustc")
.parent(100)
.cpu(98.0)
.rss(1024)
.build(),
process(103, 103)
.name("cc")
.parent(101)
.cpu(30.0)
.rss(256)
.build(),
process(200, 200)
.name("zsh")
.parent(1)
.cpu(0.5)
.rss(8)
.build(),
]
}
#[test]
fn a_subtree_sums_the_root_and_every_descendant_but_nothing_else() {
let usage = SubtreeUsage::over(&build_tree(), ProcessIdentity::new(100, 100))
.expect("the root is present");
assert_eq!(usage.len(), 4, "cargo, two rustc, one cc");
assert_eq!(
usage.members,
vec![
ProcessIdentity::new(100, 100),
ProcessIdentity::new(101, 101),
ProcessIdentity::new(102, 102),
ProcessIdentity::new(103, 103),
],
"root first, then breadth-first"
);
assert_eq!(
usage.cpu.value.fresh().map(|percent| percent.value()),
Some(250.0)
);
assert_eq!(usage.rss_bytes.value.fresh(), Some(&3392));
assert!(
!usage.has_partial_sums(),
"every member reported CPU and RSS, so neither figure understates"
);
assert_eq!(usage.read.value, MetricState::Unsupported);
assert!(!usage.read.is_partial());
assert_eq!(usage.cycles_broken, 0);
}
#[test]
fn a_root_that_has_exited_is_none_rather_than_its_reparented_children() {
let usage = SubtreeUsage::over(&build_tree(), ProcessIdentity::new(100, 999));
assert!(
usage.is_none(),
"a start key that does not match is not the root"
);
assert!(SubtreeUsage::over(&build_tree(), ProcessIdentity::new(4242, 1)).is_none());
}
#[test]
fn a_leaf_subtree_is_just_itself() {
let usage =
SubtreeUsage::over(&build_tree(), ProcessIdentity::new(103, 103)).expect("present");
assert_eq!(usage.len(), 1);
assert_eq!(usage.cpu.value.fresh().map(|p| p.value()), Some(30.0));
assert!(!usage.has_partial_sums());
}
#[test]
fn a_member_that_refuses_its_metric_leaves_the_sum_incomplete_rather_than_wrong() {
let mut processes = build_tree();
if let Some(row) = processes.get_mut(3) {
row.cpu = MetricState::PermissionDenied;
}
let usage = SubtreeUsage::over(&processes, ProcessIdentity::new(100, 100)).expect("root");
assert_eq!(
usage.cpu.value.fresh().map(|p| p.value()),
Some(152.0),
"the readable members still sum"
);
assert!(usage.cpu.is_partial(), "and the figure says it understates");
assert_eq!(usage.cpu.coverage.missing(), 1);
assert_eq!(usage.cpu.coverage.contributors, 3);
assert_eq!(usage.cpu.coverage.members, 4);
assert!(!usage.rss_bytes.is_partial());
assert!(usage.has_partial_sums());
}
#[test]
fn a_subtree_where_nothing_can_be_read_is_unavailable_and_never_zero() {
let mut processes = build_tree();
for row in &mut processes {
row.cpu = MetricState::PermissionDenied;
}
let usage = SubtreeUsage::over(&processes, ProcessIdentity::new(100, 100)).expect("root");
assert_eq!(usage.cpu.value, MetricState::PermissionDenied);
assert!(usage.cpu.value.fresh().is_none());
assert_eq!(usage.cpu.coverage.contributors, 0);
assert_eq!(usage.cpu.coverage.share(), Some(Percent::ZERO));
assert!(!usage.cpu.is_partial());
}
#[test]
fn a_warming_up_subtree_says_so_rather_than_reporting_a_zero_total() {
let mut processes = build_tree();
for row in &mut processes {
row.cpu = MetricState::WarmingUp;
}
let usage = SubtreeUsage::over(&processes, ProcessIdentity::new(100, 100)).expect("root");
assert_eq!(usage.cpu.value, MetricState::WarmingUp);
}
#[test]
fn a_stale_member_still_contributes_because_it_was_measured() {
let mut processes = build_tree();
if let Some(row) = processes.get_mut(2) {
row.cpu = MetricState::Stale {
value: Percent::new(120.0).expect("valid"),
age: core::time::Duration::from_secs(2),
};
}
let usage = SubtreeUsage::over(&processes, ProcessIdentity::new(100, 100)).expect("root");
assert_eq!(usage.cpu.value.fresh().map(|p| p.value()), Some(250.0));
assert!(!usage.cpu.is_partial());
}
#[test]
fn a_parent_cycle_terminates_and_is_counted() {
let processes = vec![
process(300, 300).name("a").parent(302).cpu(1.0).build(),
process(301, 301).name("b").parent(300).cpu(1.0).build(),
process(302, 302).name("c").parent(301).cpu(1.0).build(),
];
let usage = SubtreeUsage::over(&processes, ProcessIdentity::new(300, 300)).expect("root");
assert_eq!(usage.len(), 3, "each process is visited once");
assert_eq!(usage.cycles_broken, 1, "and the loop is reported");
}
#[test]
fn a_self_parenting_process_is_its_own_subtree_and_no_cycle() {
let mut processes = build_tree();
if let Some(row) = processes.get_mut(0) {
row.parent_pid = Some(1);
}
let usage = SubtreeUsage::over(&processes, ProcessIdentity::new(1, 1)).expect("root");
assert_eq!(
usage.cycles_broken, 0,
"a self-parent is dropped, not reported as a loop"
);
assert_eq!(
usage.len(),
6,
"PID 1's subtree is the whole table: cargo and zsh are its children, and \
cargo's compilers are its grandchildren"
);
}
#[test]
fn coverage_of_an_empty_membership_is_undefined_rather_than_complete() {
let coverage = Coverage {
contributors: 0,
members: 0,
};
assert_eq!(coverage.share(), None, "a share of nothing is not 100%");
assert_eq!(coverage.missing(), 0);
}
}