Skip to main content

monitrs_core/rates/
cpu.rs

1//! CPU-time deltas turned into percentages, with the two conventions §8.3 fixes.
2//!
3//! The two trackers here differ in what they divide by, and that difference *is*
4//! the semantics:
5//!
6//! * [`SystemCpuTracker`] divides busy CPU time by *total* CPU time, which is
7//!   already a share of every logical CPU. The result is aggregate machine usage
8//!   in `0..=100` and is immune to a variable sample interval.
9//! * [`ProcessCpuTracker`] divides process CPU time by the *elapsed monotonic
10//!   interval*, giving "one core = 100%". A process on four cores reads 400%,
11//!   matching `top` and `htop`.
12
13use core::time::Duration;
14use std::time::Instant;
15
16use crate::model::{CpuNormalization, MetricState, UnavailableReason};
17use crate::rates::keyed::DeltaTracker;
18use crate::units::Percent;
19
20/// `part / whole` as a percentage.
21///
22/// Returns `None` when `whole` is zero — there is no defined utilization over a
23/// zero-length interval, and §4 forbids answering that with a number — or when a
24/// duration exceeds 584 years in nanoseconds, which is not a sampling delta and
25/// so is better reported as unavailable than silently truncated.
26fn percent_of_duration(part: Duration, whole: Duration) -> Option<Percent> {
27    let part = u64::try_from(part.as_nanos()).ok()?;
28    let whole = u64::try_from(whole.as_nanos()).ok()?;
29    Percent::ratio(part, whole)
30}
31
32/// Cumulative CPU time split into the part that counts as busy and the part that
33/// does not.
34///
35/// Both fields are [`Duration`] rather than raw ticks. `/proc/stat` reports
36/// `USER_HZ` jiffies and macOS reports Mach ticks, so converting at the collector
37/// boundary keeps this engine free of platform constants while staying integral
38/// as §10.4 requires.
39#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)]
40pub struct CpuTimeTotals {
41    /// Cumulative non-idle time, summed over every logical CPU.
42    pub busy: Duration,
43    /// Cumulative idle time, summed over every logical CPU.
44    ///
45    /// On Linux this should be `idle + iowait`: a CPU blocked on I/O is not
46    /// doing work, and counting `iowait` as busy is what makes some monitors
47    /// report a pegged CPU during a disk stall (§8.3).
48    pub idle: Duration,
49}
50
51impl CpuTimeTotals {
52    /// Builds totals from cumulative busy and idle CPU time.
53    #[must_use]
54    pub const fn new(busy: Duration, idle: Duration) -> Self {
55        Self { busy, idle }
56    }
57
58    /// All CPU time accounted for, busy and idle together.
59    ///
60    /// Saturating: a sum that overflows [`Duration`] is not real CPU time, and a
61    /// panic inside a sampling loop is never acceptable (§14.3).
62    #[must_use]
63    pub const fn total(self) -> Duration {
64        self.busy.saturating_add(self.idle)
65    }
66}
67
68/// Aggregate machine CPU utilization from cumulative CPU-time totals (§8.3).
69///
70/// The percentage is `busy_delta / (busy_delta + idle_delta)`. That divisor is
71/// CPU time rather than wall time, which makes the result self-normalizing: it
72/// is already a share of *all* logical CPUs, so it lands in `0..=100` without
73/// ever being told the CPU count, and a sample interval that drifts from 1 s to
74/// 4 s cannot distort it.
75///
76/// The reading [`Instant`] is retained even though the percentage does not use
77/// it, so that a keyed set of per-core trackers can prune cores that stopped
78/// reporting after a CPU hotplug event.
79#[derive(Clone, Copy, Debug, Default)]
80pub struct SystemCpuTracker {
81    last: Option<(CpuTimeTotals, Instant)>,
82}
83
84impl SystemCpuTracker {
85    /// Builds a tracker with no baseline.
86    #[must_use]
87    pub const fn new() -> Self {
88        Self { last: None }
89    }
90
91    /// Whether the next reading will be the first, and so warming up (§8.2).
92    #[must_use]
93    pub const fn is_warming_up(&self) -> bool {
94        self.last.is_none()
95    }
96
97    /// When the last reading was accepted, or `None` while warming up.
98    #[must_use]
99    pub const fn last_observed_at(&self) -> Option<Instant> {
100        match self.last {
101            Some((_, at)) => Some(at),
102            None => None,
103        }
104    }
105
106    /// Drops the baseline so the next reading warms up again.
107    pub fn forget_baseline(&mut self) {
108        self.last = None;
109    }
110
111    /// Folds one cumulative CPU-time reading in and publishes machine usage.
112    pub fn observe(&mut self, totals: CpuTimeTotals, at: Instant) -> MetricState<Percent> {
113        // `replace` re-baselines on every path, including the reset path, which
114        // is what makes the sample after a reset valid (§8.2).
115        let Some((previous, _)) = self.last.replace((totals, at)) else {
116            return MetricState::WarmingUp;
117        };
118        let (Some(busy), Some(idle)) = (
119            totals.busy.checked_sub(previous.busy),
120            totals.idle.checked_sub(previous.idle),
121        ) else {
122            // Cumulative CPU time only falls if the counter was reset — a live
123            // VM migration, or a re-read of a re-initialised source. §8.2
124            // forbids turning that into a number.
125            return MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset);
126        };
127        match percent_of_duration(busy, busy.saturating_add(idle)) {
128            // Jiffy-granularity rounding can put the ratio a hair above 100;
129            // §8.3 fixes the aggregate range at 0..=100.
130            Some(percent) => MetricState::Available(percent.clamped_to_100()),
131            // No CPU time passed at all. That is not "0% busy" — it is an
132            // interval too short (or a source too stalled) to measure (§8.2).
133            None => MetricState::WarmingUp,
134        }
135    }
136}
137
138impl DeltaTracker for SystemCpuTracker {
139    type Config = ();
140    type Reading = CpuTimeTotals;
141    type Value = Percent;
142
143    fn with_config(_config: Self::Config) -> Self {
144        Self::new()
145    }
146
147    fn observe_reading(&mut self, reading: Self::Reading, at: Instant) -> MetricState<Self::Value> {
148        self.observe(reading, at)
149    }
150
151    // Written out rather than delegating to the identically named inherent
152    // methods, which would be an ambiguous path.
153    fn last_observed_at(&self) -> Option<Instant> {
154        self.last.map(|(_, at)| at)
155    }
156
157    fn forget_baseline(&mut self) {
158        self.last = None;
159    }
160}
161
162/// Per-process CPU utilization from cumulative per-process CPU time (§8.3).
163///
164/// [`ProcessCpuTracker::observe`] returns the *core*-normalized value: one fully
165/// used core is 100%, so a process saturating four cores reads 400% and a
166/// single-threaded process on a 64-CPU machine still reads 100%. The other
167/// convention is applied on top by [`ProcessCpuTracker::observe_normalized`],
168/// which delegates to the frozen [`CpuNormalization::apply`] rather than
169/// re-deriving the arithmetic.
170///
171/// # Monotonic time
172///
173/// Unlike [`SystemCpuTracker`], this divides by the elapsed interval, so the
174/// interval's accuracy is the percentage's accuracy. Callers must pass the
175/// snapshot's monotonic `captured_at`; a wall-clock jump would otherwise scale
176/// every process on screen (§8.1). The interval is derived with
177/// [`Instant::saturating_duration_since`], so it can never be negative.
178///
179/// # Identity
180///
181/// One tracker follows one process. Callers must key trackers on
182/// [`crate::model::ProcessIdentity`] and not on a bare PID: a reused PID must
183/// start a fresh baseline rather than inherit the dead process's CPU time (§26).
184/// [`super::KeyedProcessCpuTrackers`] encodes that.
185#[derive(Clone, Copy, Debug, Default)]
186pub struct ProcessCpuTracker {
187    last: Option<(Duration, Instant)>,
188}
189
190impl ProcessCpuTracker {
191    /// Builds a tracker with no baseline.
192    #[must_use]
193    pub const fn new() -> Self {
194        Self { last: None }
195    }
196
197    /// Whether the next reading will be the first, and so warming up (§8.3).
198    #[must_use]
199    pub const fn is_warming_up(&self) -> bool {
200        self.last.is_none()
201    }
202
203    /// When the last reading was accepted, or `None` while warming up.
204    #[must_use]
205    pub const fn last_observed_at(&self) -> Option<Instant> {
206        match self.last {
207            Some((_, at)) => Some(at),
208            None => None,
209        }
210    }
211
212    /// Drops the baseline so the next reading warms up again.
213    pub fn forget_baseline(&mut self) {
214        self.last = None;
215    }
216
217    /// Folds one cumulative process CPU time in and publishes core-normalized
218    /// usage: one core = 100%, so the result may exceed 100% (§8.3).
219    pub fn observe(&mut self, cpu_time: Duration, at: Instant) -> MetricState<Percent> {
220        let Some((previous_cpu, previous_at)) = self.last.replace((cpu_time, at)) else {
221            return MetricState::WarmingUp;
222        };
223        let Some(cpu_delta) = cpu_time.checked_sub(previous_cpu) else {
224            // A single process's CPU time is monotonic, so a fall means the
225            // baseline belongs to a different process — the PID was reused
226            // behind the caller's back. Keying on identity prevents it; refusing
227            // to invent a percentage is the backstop (§26).
228            return MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset);
229        };
230        match percent_of_duration(cpu_delta, at.saturating_duration_since(previous_at)) {
231            // Deliberately not clamped: exceeding 100% is the correct answer for
232            // a multi-threaded process under core normalization (§8.3).
233            Some(percent) => MetricState::Available(percent),
234            None => MetricState::WarmingUp,
235        }
236    }
237
238    /// Folds one reading in and publishes usage in the requested convention.
239    ///
240    /// `logical_cpus` is only consulted for [`CpuNormalization::Machine`]. A zero
241    /// CPU count leaves machine normalization undefined, and §4 forbids
242    /// substituting a number for an undefined value, so that case reports
243    /// [`UnavailableReason::ReadFailed`] — the CPU count is what failed to read.
244    pub fn observe_normalized(
245        &mut self,
246        cpu_time: Duration,
247        at: Instant,
248        normalization: CpuNormalization,
249        logical_cpus: u16,
250    ) -> MetricState<Percent> {
251        let core_normalized = self.observe(cpu_time, at);
252        let Some(percent) = core_normalized.fresh().copied() else {
253            return core_normalized;
254        };
255        match normalization.apply(percent, logical_cpus) {
256            Some(scaled) => MetricState::Available(scaled),
257            None => MetricState::TemporarilyUnavailable(UnavailableReason::ReadFailed),
258        }
259    }
260}
261
262impl DeltaTracker for ProcessCpuTracker {
263    type Config = ();
264    type Reading = Duration;
265    type Value = Percent;
266
267    fn with_config(_config: Self::Config) -> Self {
268        Self::new()
269    }
270
271    fn observe_reading(&mut self, reading: Self::Reading, at: Instant) -> MetricState<Self::Value> {
272        self.observe(reading, at)
273    }
274
275    // Written out rather than delegating to the identically named inherent
276    // methods, which would be an ambiguous path.
277    fn last_observed_at(&self) -> Option<Instant> {
278        self.last.map(|(_, at)| at)
279    }
280
281    fn forget_baseline(&mut self) {
282        self.last = None;
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn origin() -> Instant {
291        Instant::now()
292    }
293
294    fn percent(state: &MetricState<Percent>) -> f32 {
295        state
296            .fresh()
297            .expect("expected a measured percentage")
298            .value()
299    }
300
301    fn secs(seconds: u64) -> Duration {
302        Duration::from_secs(seconds)
303    }
304
305    #[test]
306    fn totals_sum_busy_and_idle_without_overflowing() {
307        let totals = CpuTimeTotals::new(secs(3), secs(5));
308        assert_eq!(totals.total(), secs(8));
309        let extreme = CpuTimeTotals::new(Duration::MAX, secs(1));
310        assert_eq!(extreme.total(), Duration::MAX);
311    }
312
313    #[test]
314    fn a_first_system_sample_is_warming_up_and_not_zero_percent() {
315        let mut tracker = SystemCpuTracker::new();
316        assert!(tracker.is_warming_up());
317        let state = tracker.observe(CpuTimeTotals::new(secs(100), secs(900)), origin());
318        assert!(state.is_warming_up());
319        assert_eq!(state.fresh(), None);
320        assert!(!tracker.is_warming_up());
321    }
322
323    #[test]
324    fn system_cpu_is_the_busy_share_of_total_cpu_time() {
325        // One of eight CPUs fully busy for a second: 1 s busy, 7 s idle.
326        let t0 = origin();
327        let mut tracker = SystemCpuTracker::new();
328        tracker.observe(CpuTimeTotals::new(secs(0), secs(0)), t0);
329        let state = tracker.observe(CpuTimeTotals::new(secs(1), secs(7)), t0 + secs(1));
330        assert!((percent(&state) - 12.5).abs() < f32::EPSILON);
331    }
332
333    #[test]
334    fn system_cpu_is_an_aggregate_capped_at_one_hundred_percent() {
335        // Every CPU busy: §8.3 fixes the aggregate range at 0..=100, so an
336        // eight-CPU machine reads 100%, never 800%.
337        let t0 = origin();
338        let mut tracker = SystemCpuTracker::new();
339        tracker.observe(CpuTimeTotals::new(secs(0), secs(0)), t0);
340        let state = tracker.observe(CpuTimeTotals::new(secs(8), secs(0)), t0 + secs(1));
341        assert!((percent(&state) - 100.0).abs() < f32::EPSILON);
342    }
343
344    #[test]
345    fn system_cpu_does_not_depend_on_the_sample_interval_length() {
346        // The divisor is CPU time, so the same deltas over a 250 ms and a 4 s
347        // interval must agree. That is why §8.3 prefers delta CPU times.
348        let t0 = origin();
349        let before = CpuTimeTotals::new(secs(10), secs(70));
350        let after = CpuTimeTotals::new(secs(11), secs(77));
351
352        let mut quick = SystemCpuTracker::new();
353        quick.observe(before, t0);
354        let quick_state = quick.observe(after, t0 + Duration::from_millis(250));
355
356        let mut slow = SystemCpuTracker::new();
357        slow.observe(before, t0);
358        let slow_state = slow.observe(after, t0 + secs(4));
359
360        assert!((percent(&quick_state) - percent(&slow_state)).abs() < f32::EPSILON);
361    }
362
363    #[test]
364    fn a_stalled_system_counter_is_warming_up_not_zero_percent() {
365        let t0 = origin();
366        let totals = CpuTimeTotals::new(secs(10), secs(70));
367        let mut tracker = SystemCpuTracker::new();
368        tracker.observe(totals, t0);
369        let state = tracker.observe(totals, t0 + secs(1));
370        assert!(state.is_warming_up());
371        assert_ne!(state, MetricState::Available(Percent::ZERO));
372    }
373
374    #[test]
375    fn system_cpu_time_going_backwards_is_a_reset_and_recovers_next_sample() {
376        let t0 = origin();
377        let mut tracker = SystemCpuTracker::new();
378        tracker.observe(CpuTimeTotals::new(secs(100), secs(700)), t0);
379
380        let reset = tracker.observe(CpuTimeTotals::new(secs(2), secs(6)), t0 + secs(1));
381        assert_eq!(
382            reset,
383            MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset)
384        );
385
386        let recovered = tracker.observe(CpuTimeTotals::new(secs(3), secs(13)), t0 + secs(2));
387        assert!((percent(&recovered) - 12.5).abs() < f32::EPSILON);
388    }
389
390    #[test]
391    fn an_idle_only_reset_is_detected_as_well_as_a_busy_only_one() {
392        let t0 = origin();
393        let mut tracker = SystemCpuTracker::new();
394        tracker.observe(CpuTimeTotals::new(secs(100), secs(700)), t0);
395        let reset = tracker.observe(CpuTimeTotals::new(secs(101), secs(1)), t0 + secs(1));
396        assert_eq!(
397            reset,
398            MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset)
399        );
400    }
401
402    #[test]
403    fn a_first_process_sample_is_warming_up_and_not_zero_percent() {
404        let mut tracker = ProcessCpuTracker::new();
405        let state = tracker.observe(secs(42), origin());
406        assert!(state.is_warming_up());
407        assert_ne!(state, MetricState::Available(Percent::ZERO));
408    }
409
410    #[test]
411    fn a_fully_busy_single_core_reads_one_hundred_percent_on_an_eight_cpu_machine() {
412        // The regression this pins down: multiplying by the CPU count and
413        // reporting 800% for one saturated core.
414        let t0 = origin();
415        let mut tracker = ProcessCpuTracker::new();
416        tracker.observe(secs(0), t0);
417        let state = tracker.observe(secs(1), t0 + secs(1));
418        assert!((percent(&state) - 100.0).abs() < f32::EPSILON);
419
420        let machine =
421            CpuNormalization::Machine.apply(Percent::new(percent(&state)).expect("valid"), 8);
422        assert!((machine.expect("valid").value() - 12.5).abs() < f32::EPSILON);
423    }
424
425    #[test]
426    fn a_process_on_four_cores_reads_four_hundred_percent_under_core_normalization() {
427        let t0 = origin();
428        let mut tracker = ProcessCpuTracker::new();
429        tracker.observe(secs(0), t0);
430        let state = tracker.observe(secs(4), t0 + secs(1));
431        assert!((percent(&state) - 400.0).abs() < f32::EPSILON);
432    }
433
434    #[test]
435    fn both_normalizations_are_reachable_from_one_reading() {
436        let t0 = origin();
437        let mut core = ProcessCpuTracker::new();
438        core.observe(secs(0), t0);
439        let core_state = core.observe_normalized(secs(4), t0 + secs(1), CpuNormalization::Core, 8);
440        assert!((percent(&core_state) - 400.0).abs() < f32::EPSILON);
441
442        let mut machine = ProcessCpuTracker::new();
443        machine.observe(secs(0), t0);
444        let machine_state =
445            machine.observe_normalized(secs(4), t0 + secs(1), CpuNormalization::Machine, 8);
446        assert!((percent(&machine_state) - 50.0).abs() < f32::EPSILON);
447    }
448
449    #[test]
450    fn machine_normalization_without_a_cpu_count_is_unavailable_not_zero() {
451        let t0 = origin();
452        let mut tracker = ProcessCpuTracker::new();
453        tracker.observe(secs(0), t0);
454        let state = tracker.observe_normalized(secs(1), t0 + secs(1), CpuNormalization::Machine, 0);
455        assert_eq!(
456            state,
457            MetricState::TemporarilyUnavailable(UnavailableReason::ReadFailed)
458        );
459        assert_eq!(state.fresh(), None);
460    }
461
462    #[test]
463    fn normalization_preserves_an_unavailable_state_rather_than_scaling_it() {
464        let mut tracker = ProcessCpuTracker::new();
465        let first = tracker.observe_normalized(secs(5), origin(), CpuNormalization::Machine, 8);
466        assert!(first.is_warming_up());
467    }
468
469    #[test]
470    fn process_cpu_uses_the_actual_elapsed_interval() {
471        // 500 ms of CPU over 500 ms of wall time is a saturated core; the same
472        // CPU over 2 s is a quarter of one. Assuming a 1 s interval would report
473        // 50% for both (§8.1).
474        let t0 = origin();
475        let cpu = Duration::from_millis(500);
476
477        let mut quick = ProcessCpuTracker::new();
478        quick.observe(Duration::ZERO, t0);
479        let quick_state = quick.observe(cpu, t0 + Duration::from_millis(500));
480
481        let mut slow = ProcessCpuTracker::new();
482        slow.observe(Duration::ZERO, t0);
483        let slow_state = slow.observe(cpu, t0 + secs(2));
484
485        assert!((percent(&quick_state) - 100.0).abs() < f32::EPSILON);
486        assert!((percent(&slow_state) - 25.0).abs() < f32::EPSILON);
487    }
488
489    #[test]
490    fn a_process_that_used_no_cpu_reads_a_real_zero_percent() {
491        let t0 = origin();
492        let mut tracker = ProcessCpuTracker::new();
493        tracker.observe(secs(9), t0);
494        let state = tracker.observe(secs(9), t0 + secs(1));
495        assert_eq!(state, MetricState::Available(Percent::ZERO));
496    }
497
498    #[test]
499    fn zero_elapsed_process_sample_is_warming_up_not_a_division_by_zero() {
500        let t0 = origin();
501        let mut tracker = ProcessCpuTracker::new();
502        tracker.observe(secs(1), t0);
503        let state = tracker.observe(secs(2), t0);
504        assert!(state.is_warming_up());
505    }
506
507    #[test]
508    fn a_reversed_instant_cannot_make_a_process_percentage_negative_or_huge() {
509        let t0 = origin();
510        let mut tracker = ProcessCpuTracker::new();
511        tracker.observe(secs(0), t0 + secs(10));
512        let state = tracker.observe(secs(5), t0);
513        assert!(state.is_warming_up());
514    }
515
516    #[test]
517    fn process_cpu_time_going_backwards_is_a_reset_and_recovers_next_sample() {
518        let t0 = origin();
519        let mut tracker = ProcessCpuTracker::new();
520        tracker.observe(secs(30), t0);
521
522        let reset = tracker.observe(secs(1), t0 + secs(1));
523        assert_eq!(
524            reset,
525            MetricState::TemporarilyUnavailable(UnavailableReason::CounterReset)
526        );
527
528        let recovered = tracker.observe(secs(2), t0 + secs(2));
529        assert!((percent(&recovered) - 100.0).abs() < f32::EPSILON);
530    }
531
532    #[test]
533    fn forgetting_a_process_baseline_prevents_a_delta_across_the_gap() {
534        let t0 = origin();
535        let mut tracker = ProcessCpuTracker::new();
536        tracker.observe(secs(0), t0);
537        tracker.forget_baseline();
538        assert!(tracker.is_warming_up());
539        assert!(tracker.observe(secs(600), t0 + secs(1)).is_warming_up());
540    }
541
542    #[test]
543    fn trackers_report_when_they_last_saw_a_reading() {
544        let t0 = origin();
545        let at = t0 + secs(5);
546        let mut system = SystemCpuTracker::new();
547        assert_eq!(system.last_observed_at(), None);
548        system.observe(CpuTimeTotals::default(), at);
549        assert_eq!(system.last_observed_at(), Some(at));
550
551        let mut process = ProcessCpuTracker::new();
552        assert_eq!(process.last_observed_at(), None);
553        process.observe(Duration::ZERO, at);
554        assert_eq!(process.last_observed_at(), Some(at));
555    }
556}