Skip to main content

monitrs_core/diagnostics/
signals.rs

1//! Turning one snapshot into one candidate state per radar signal (§2.3).
2//!
3//! Everything here is *instantaneous*: it looks at the current sample only and
4//! answers "what does this reading say right now". Sustaining that answer over
5//! time is [`super::Hysteresis`]'s job, and combining the two is
6//! [`super::PressureEngine`]'s. Keeping them apart is what makes both testable:
7//! a reading has no memory, and a tracker has no idea what a percentage means.
8//!
9//! Two rules run through every function in this file:
10//!
11//! * **An unavailable input produces an unavailable reading**, never `normal`.
12//!   §2.3 requires an explicit unavailable state, and a system whose pressure
13//!   cannot be measured must not look healthy.
14//! * **The raw metric is always reported when it was measured**, even when no
15//!   state can be derived from it. That is the `? NET unknown 18M/s` row in §5.5:
16//!   the throughput is real, only the utilization is unknowable without a link
17//!   speed (§7.4).
18
19use crate::model::{
20    InterfaceKind, MeasuredValue, Measurement, MetricState, PressureId, PressureState, PsiResource,
21    PsiSnapshot, SystemSnapshot, UnavailableReason,
22};
23use crate::units::{Percent, Rate};
24
25use super::Thresholds;
26
27/// One signal's instantaneous evaluation.
28#[derive(Clone, Copy, Debug, PartialEq)]
29pub struct SignalReading {
30    /// The candidate state, or why none could be derived.
31    pub state: MetricState<PressureState>,
32    /// Normalized `0..=100` closeness to critical, for bar length and sorting.
33    pub severity: MetricState<Percent>,
34    /// The raw metric the state was derived from (§2.3).
35    ///
36    /// May be present even when `state` is unavailable.
37    pub raw: Option<Measurement>,
38    /// The human-readable rule that produced `state` (§2.3).
39    pub rule: &'static str,
40}
41
42impl SignalReading {
43    /// A reading derived from a measured value and its two thresholds.
44    ///
45    /// `watch` and `critical` are expressed as a *pressure magnitude*: higher is
46    /// always worse. Inverted metrics such as available memory are converted by
47    /// their caller, which keeps one comparison direction in one place.
48    #[must_use]
49    fn measured(
50        rule: &'static str,
51        raw: Measurement,
52        value: f64,
53        watch: f64,
54        critical: f64,
55    ) -> Self {
56        let state = if value >= critical {
57            PressureState::Critical
58        } else if value >= watch {
59            PressureState::Watch
60        } else {
61            PressureState::Normal
62        };
63        Self {
64            state: MetricState::Available(state),
65            severity: normalized_severity(value, watch, critical),
66            raw: Some(raw),
67            rule,
68        }
69    }
70
71    /// A reading whose input was not available, optionally still carrying the raw
72    /// metric that *was* measured.
73    #[must_use]
74    fn unavailable(
75        rule: &'static str,
76        reason: MetricState<PressureState>,
77        raw: Option<Measurement>,
78    ) -> Self {
79        Self {
80            state: reason,
81            severity: propagate(&reason),
82            raw,
83            rule,
84        }
85    }
86}
87
88/// How close a value is to critical, as `0..=100` (§2.3).
89///
90/// Half the scale is spent below the watch threshold and half between watch and
91/// critical, so two signals in the same state can still be ordered by how bad they
92/// are. Returns an unavailable state only if the arithmetic could not produce a
93/// valid percentage, which `Percent::new` decides rather than this function.
94fn normalized_severity(value: f64, watch: f64, critical: f64) -> MetricState<Percent> {
95    let value = value.max(0.0);
96    let scaled = if value >= critical {
97        100.0
98    } else if value >= watch {
99        let span = critical - watch;
100        if span > 0.0 {
101            50.0 + 50.0 * (value - watch) / span
102        } else {
103            100.0
104        }
105    } else if watch > 0.0 {
106        50.0 * value / watch
107    } else {
108        // A watch threshold of zero makes every non-negative value "at watch";
109        // reporting the midpoint keeps the bar honest rather than empty.
110        50.0
111    };
112    // The arithmetic runs in f64 so wide byte rates keep their precision;
113    // narrowing the bounded `0..=100` result is intentional, and `Percent::new`
114    // rejects anything the narrowing could not represent.
115    #[allow(clippy::cast_possible_truncation)]
116    let scaled = scaled as f32;
117    Percent::new(scaled).map_or(
118        MetricState::TemporarilyUnavailable(UnavailableReason::ParseFailed),
119        MetricState::Available,
120    )
121}
122
123/// Re-expresses one metric's unavailability as the unavailability of a value
124/// derived from it.
125///
126/// A `Stale` or `Available` input becomes
127/// [`UnavailableReason::NeedsSecondSample`]: a derived state must not be presented
128/// as current when the reading behind it is not (§4, §26).
129fn propagate<T, U>(state: &MetricState<T>) -> MetricState<U> {
130    match state {
131        MetricState::Available(_) | MetricState::Stale { .. } => {
132            MetricState::TemporarilyUnavailable(UnavailableReason::NeedsSecondSample)
133        }
134        MetricState::WarmingUp => MetricState::WarmingUp,
135        MetricState::PermissionDenied => MetricState::PermissionDenied,
136        MetricState::Unsupported => MetricState::Unsupported,
137        MetricState::TemporarilyUnavailable(reason) => MetricState::TemporarilyUnavailable(*reason),
138    }
139}
140
141/// How informative an unavailable state is about a *group* of readings.
142///
143/// Mirrors the ranking [`crate::history`] uses for aggregate metrics: a permission
144/// problem is actionable, a typed transient reason names what happened, and
145/// "unsupported" says the least.
146const fn rank<T>(state: &MetricState<T>) -> u8 {
147    match state {
148        MetricState::PermissionDenied => 4,
149        MetricState::TemporarilyUnavailable(_) => 3,
150        MetricState::Stale { .. } => 2,
151        MetricState::WarmingUp => 1,
152        MetricState::Unsupported | MetricState::Available(_) => 0,
153    }
154}
155
156/// Keeps whichever of two unavailable states better explains the group.
157fn most_informative<T>(
158    current: Option<MetricState<T>>,
159    candidate: MetricState<T>,
160) -> MetricState<T> {
161    match current {
162        Some(current) if rank(&current) >= rank(&candidate) => current,
163        _ => candidate,
164    }
165}
166
167/// The rule text shown for each signal (§2.3).
168///
169/// Names the configuration keys rather than their current values, because the text
170/// is `&'static str` in [`crate::model::PressureSignal`] and because §12 asks that
171/// the user be pointed at the exact key.
172#[must_use]
173pub const fn rule_text(id: PressureId) -> &'static str {
174    match id {
175        PressureId::Cpu => {
176            "cpu busy at or above diagnostics.cpu_watch_percent (watch) or \
177             cpu_critical_percent (critical), sustained"
178        }
179        PressureId::Memory => {
180            "available memory at or below diagnostics.memory_watch_available_percent (watch) or \
181             memory_critical_available_percent (critical), sustained"
182        }
183        PressureId::Disk => {
184            "busiest device busy at or above diagnostics.disk_busy_watch_percent (watch) or \
185             disk_busy_critical_percent (critical), sustained; requires a device busy figure"
186        }
187        PressureId::Network => {
188            "link utilization at or above diagnostics.network_watch_percent (watch) or \
189             network_critical_percent (critical), sustained; requires a known link speed"
190        }
191        PressureId::Swap => {
192            "swap in plus out at or above diagnostics.swap_watch_bytes_per_second (watch) or \
193             swap_critical_bytes_per_second (critical), sustained"
194        }
195        PressureId::Load => {
196            "load1 per logical cpu at or above diagnostics.load_watch_per_cpu (watch) or \
197             load_critical_per_cpu (critical), sustained"
198        }
199        PressureId::PsiCpu => {
200            "psi cpu some avg10 at or above diagnostics.psi_watch_percent (watch) or \
201             psi_critical_percent (critical), sustained"
202        }
203        PressureId::PsiMemory => {
204            "psi memory some avg10 at or above diagnostics.psi_watch_percent (watch) or \
205             psi_critical_percent (critical), sustained"
206        }
207        PressureId::PsiIo => {
208            "psi io some avg10 at or above diagnostics.psi_watch_percent (watch) or \
209             psi_critical_percent (critical), sustained"
210        }
211    }
212}
213
214/// Evaluates one signal against the current sample.
215#[must_use]
216pub fn read(id: PressureId, snapshot: &SystemSnapshot, thresholds: &Thresholds) -> SignalReading {
217    match id {
218        PressureId::Cpu => cpu(snapshot, thresholds),
219        PressureId::Memory => memory(snapshot, thresholds),
220        PressureId::Disk => disk(snapshot, thresholds),
221        PressureId::Network => network(snapshot, thresholds),
222        PressureId::Swap => swap(snapshot, thresholds),
223        PressureId::Load => load(snapshot, thresholds),
224        PressureId::PsiCpu => psi(snapshot, thresholds, PressureId::PsiCpu),
225        PressureId::PsiMemory => psi(snapshot, thresholds, PressureId::PsiMemory),
226        PressureId::PsiIo => psi(snapshot, thresholds, PressureId::PsiIo),
227    }
228}
229
230/// Aggregate CPU utilization (§8.3).
231fn cpu(snapshot: &SystemSnapshot, thresholds: &Thresholds) -> SignalReading {
232    let rule = rule_text(PressureId::Cpu);
233    let Some(usage) = snapshot.cpu.total.fresh() else {
234        return SignalReading::unavailable(rule, propagate(&snapshot.cpu.total), None);
235    };
236    SignalReading::measured(
237        rule,
238        Measurement::new("cpu busy", MeasuredValue::Percent(usage.busy)),
239        f64::from(usage.busy.value()),
240        f64::from(thresholds.cpu_watch_percent),
241        f64::from(thresholds.cpu_critical_percent),
242    )
243}
244
245/// Memory availability against the ceiling that actually applies (§9.2).
246fn memory(snapshot: &SystemSnapshot, thresholds: &Thresholds) -> SignalReading {
247    let rule = rule_text(PressureId::Memory);
248    let Some(&available) = snapshot.memory.available.fresh() else {
249        return SignalReading::unavailable(rule, propagate(&snapshot.memory.available), None);
250    };
251    let limit = snapshot.memory.effective_limit_bytes();
252    let Some(share) = Percent::ratio(available, limit) else {
253        // No known ceiling means no defined share; §4 forbids inventing one.
254        return SignalReading::unavailable(
255            rule,
256            MetricState::TemporarilyUnavailable(UnavailableReason::ParseFailed),
257            Some(Measurement::new(
258                "available",
259                MeasuredValue::Bytes(available),
260            )),
261        );
262    };
263    // Inverted metric: less available is worse, so the magnitude is scarcity.
264    let scarcity = f64::from((100.0 - share.value()).max(0.0));
265    SignalReading::measured(
266        rule,
267        Measurement::new("available", MeasuredValue::Percent(share)),
268        scarcity,
269        f64::from(thresholds.memory_watch_used_percent()),
270        f64::from(thresholds.memory_critical_used_percent()),
271    )
272}
273
274/// The busiest block device, where a busy figure is semantically correct (§7.3).
275fn disk(snapshot: &SystemSnapshot, thresholds: &Thresholds) -> SignalReading {
276    let rule = rule_text(PressureId::Disk);
277    let mut busiest: Option<Percent> = None;
278    let mut fallback: Option<MetricState<PressureState>> = None;
279
280    for device in &snapshot.disks {
281        match device.busy.fresh() {
282            Some(busy) => {
283                if busiest.is_none_or(|current| busy.value() > current.value()) {
284                    busiest = Some(*busy);
285                }
286            }
287            None => fallback = Some(most_informative(fallback, propagate(&device.busy))),
288        }
289    }
290
291    let Some(busy) = busiest else {
292        // An empty device list is unsupported: there was nothing to measure.
293        return SignalReading::unavailable(
294            rule,
295            fallback.unwrap_or(MetricState::Unsupported),
296            None,
297        );
298    };
299    SignalReading::measured(
300        rule,
301        Measurement::new("device busy", MeasuredValue::Percent(busy)),
302        f64::from(busy.value()),
303        f64::from(thresholds.disk_busy_watch_percent),
304        f64::from(thresholds.disk_busy_critical_percent),
305    )
306}
307
308/// Link saturation, which only exists when the link speed is known (§7.4).
309fn network(snapshot: &SystemSnapshot, thresholds: &Thresholds) -> SignalReading {
310    let rule = rule_text(PressureId::Network);
311    let mut busiest: Option<Percent> = None;
312    let mut throughput: Option<f64> = None;
313    let mut fallback: Option<MetricState<PressureState>> = None;
314
315    for interface in snapshot
316        .networks
317        .iter()
318        .filter(|interface| interface.kind != InterfaceKind::Loopback)
319    {
320        // The raw throughput is reported even when utilization is unknowable, so
321        // the radar can show `? NET unknown 18M/s` rather than nothing (§5.5).
322        for direction in [&interface.rx, &interface.tx] {
323            if let Some(rate) = direction.fresh()
324                && throughput.is_none_or(|current| rate.per_second() > current)
325            {
326                throughput = Some(rate.per_second());
327            }
328        }
329        let utilization = interface.utilization();
330        match utilization.fresh() {
331            Some(percent) => {
332                if busiest.is_none_or(|current| percent.value() > current.value()) {
333                    busiest = Some(*percent);
334                }
335            }
336            None => fallback = Some(most_informative(fallback, propagate(&utilization))),
337        }
338    }
339
340    let raw = throughput
341        .and_then(Rate::new)
342        .map(|rate| Measurement::new("throughput", MeasuredValue::ByteRate(rate)));
343
344    let Some(utilization) = busiest else {
345        return SignalReading::unavailable(rule, fallback.unwrap_or(MetricState::Unsupported), raw);
346    };
347    SignalReading::measured(
348        rule,
349        raw.unwrap_or_else(|| Measurement::new("utilization", MeasuredValue::Percent(utilization))),
350        f64::from(utilization.value()),
351        f64::from(thresholds.network_watch_percent),
352        f64::from(thresholds.network_critical_percent),
353    )
354}
355
356/// Swap activity, which is the metric that indicates distress (§11.2).
357fn swap(snapshot: &SystemSnapshot, thresholds: &Thresholds) -> SignalReading {
358    let rule = rule_text(PressureId::Swap);
359    let swap = &snapshot.memory.swap;
360    if !swap.is_enabled() {
361        // With no swap configured there is no swap activity to measure. Reporting
362        // `normal` would claim a measurement that was never made (§2.3).
363        return SignalReading::unavailable(rule, MetricState::Unsupported, None);
364    }
365    let (Some(in_rate), Some(out_rate)) = (swap.in_rate.fresh(), swap.out_rate.fresh()) else {
366        let reason = if swap.in_rate.fresh().is_none() {
367            propagate(&swap.in_rate)
368        } else {
369            propagate(&swap.out_rate)
370        };
371        return SignalReading::unavailable(rule, reason, None);
372    };
373    let total = in_rate.per_second() + out_rate.per_second();
374    let Some(rate) = Rate::new(total) else {
375        return SignalReading::unavailable(
376            rule,
377            MetricState::TemporarilyUnavailable(UnavailableReason::ParseFailed),
378            None,
379        );
380    };
381    SignalReading::measured(
382        rule,
383        Measurement::new("swap in+out", MeasuredValue::ByteRate(rate)),
384        total,
385        thresholds.swap_watch_bytes_per_second,
386        thresholds.swap_critical_bytes_per_second,
387    )
388}
389
390/// Run-queue pressure, expressed per logical CPU so it is comparable (§11.2).
391fn load(snapshot: &SystemSnapshot, thresholds: &Thresholds) -> SignalReading {
392    let rule = rule_text(PressureId::Load);
393    let Some(load) = snapshot.load.fresh() else {
394        return SignalReading::unavailable(rule, propagate(&snapshot.load), None);
395    };
396    let raw = Measurement::new("load1", MeasuredValue::Load(load.one));
397    let Some(per_cpu) = load.per_cpu(snapshot.cpu.logical_count) else {
398        // Without a CPU count the figure cannot be normalized, and an
399        // un-normalized load average is not comparable to any threshold.
400        return SignalReading::unavailable(rule, MetricState::Unsupported, Some(raw));
401    };
402    SignalReading::measured(
403        rule,
404        raw,
405        f64::from(per_cpu),
406        f64::from(thresholds.load_watch_per_cpu),
407        f64::from(thresholds.load_critical_per_cpu),
408    )
409}
410
411/// One Linux PSI resource (§9.2).
412///
413/// Uses the `some avg10` figure: it is available for every resource on every
414/// kernel that has PSI at all, and it is already a ten-second moving average, so
415/// one read describes a window rather than an instant.
416fn psi(snapshot: &SystemSnapshot, thresholds: &Thresholds, id: PressureId) -> SignalReading {
417    let rule = rule_text(id);
418    let Some(psi) = snapshot.pressure.psi.fresh() else {
419        return SignalReading::unavailable(rule, propagate(&snapshot.pressure.psi), None);
420    };
421    let resource = psi_resource(psi, id);
422    let label = match id {
423        PressureId::PsiMemory => "psi memory some avg10",
424        PressureId::PsiIo => "psi io some avg10",
425        _ => "psi cpu some avg10",
426    };
427    SignalReading::measured(
428        rule,
429        Measurement::new(label, MeasuredValue::Percent(resource.some_avg10)),
430        f64::from(resource.some_avg10.value()),
431        f64::from(thresholds.psi_watch_percent),
432        f64::from(thresholds.psi_critical_percent),
433    )
434}
435
436/// Selects the PSI resource a signal id refers to.
437///
438/// Non-PSI ids resolve to the CPU resource; [`read`] never routes them here, and a
439/// panicking branch is forbidden in production code (§14.3).
440pub(super) const fn psi_resource(psi: &PsiSnapshot, id: PressureId) -> &PsiResource {
441    match id {
442        PressureId::PsiMemory => &psi.memory,
443        PressureId::PsiIo => &psi.io,
444        _ => &psi.cpu,
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::diagnostics::fixtures::{
452        percent, psi_snapshot, rate, set_cpu, set_disk_busy, set_load, set_memory, set_network,
453        set_psi, set_swap, snapshot,
454    };
455
456    fn thresholds() -> Thresholds {
457        Thresholds::default().sanitized()
458    }
459
460    fn state(reading: &SignalReading) -> Option<PressureState> {
461        reading.state.fresh().copied()
462    }
463
464    #[test]
465    fn every_signal_carries_the_rule_that_derived_it() {
466        for id in PressureId::DISPLAY_ORDER {
467            let reading = read(id, &snapshot(), &thresholds());
468            assert!(!reading.rule.is_empty(), "{id:?} has no rule text");
469            assert!(reading.rule.is_ascii(), "{id:?} rule text is not ASCII");
470            assert!(
471                reading.rule.contains("diagnostics."),
472                "{id:?} rule text must name the configuration key"
473            );
474        }
475    }
476
477    #[test]
478    fn a_warming_up_snapshot_derives_no_state_for_any_signal() {
479        for id in PressureId::DISPLAY_ORDER {
480            let reading = read(id, &snapshot(), &thresholds());
481            assert!(
482                reading.state.fresh().is_none(),
483                "{id:?} claimed a state from an unmeasured system"
484            );
485            assert!(reading.severity.fresh().is_none());
486        }
487    }
488
489    #[test]
490    fn cpu_escalates_through_watch_to_critical() {
491        let cases = [
492            (10.0, PressureState::Normal),
493            (79.9, PressureState::Normal),
494            (80.0, PressureState::Watch),
495            (94.9, PressureState::Watch),
496            (95.0, PressureState::Critical),
497            (100.0, PressureState::Critical),
498        ];
499        for (busy, expected) in cases {
500            let mut snapshot = snapshot();
501            set_cpu(&mut snapshot, busy);
502            let reading = read(PressureId::Cpu, &snapshot, &thresholds());
503            assert_eq!(state(&reading), Some(expected), "{busy}% busy");
504        }
505    }
506
507    #[test]
508    fn normalized_severity_orders_two_signals_in_the_same_state() {
509        let mut mild = snapshot();
510        set_cpu(&mut mild, 82.0);
511        let mut severe = snapshot();
512        set_cpu(&mut severe, 94.0);
513
514        let mild = read(PressureId::Cpu, &mild, &thresholds());
515        let severe = read(PressureId::Cpu, &severe, &thresholds());
516        assert_eq!(state(&mild), Some(PressureState::Watch));
517        assert_eq!(state(&severe), Some(PressureState::Watch));
518        assert!(
519            severe.severity.fresh().map(|p| p.value()) > mild.severity.fresh().map(|p| p.value()),
520            "§2.3 wants a normalized severity, not just a state"
521        );
522    }
523
524    #[test]
525    fn severity_saturates_at_one_hundred_and_never_exceeds_it() {
526        let mut snapshot = snapshot();
527        set_cpu(&mut snapshot, 100.0);
528        let reading = read(PressureId::Cpu, &snapshot, &thresholds());
529        let severity = reading.severity.fresh().expect("measured").value();
530        assert!((severity - 100.0).abs() < f32::EPSILON, "got {severity}");
531    }
532
533    #[test]
534    fn an_unavailable_cpu_reading_is_unavailable_not_normal() {
535        let mut snapshot = snapshot();
536        snapshot.cpu.total = MetricState::PermissionDenied;
537        let reading = read(PressureId::Cpu, &snapshot, &thresholds());
538        assert_eq!(reading.state, MetricState::PermissionDenied);
539        assert_eq!(reading.severity, MetricState::PermissionDenied);
540        assert!(reading.raw.is_none());
541    }
542
543    #[test]
544    fn a_stale_reading_does_not_become_a_current_state() {
545        let mut snapshot = snapshot();
546        set_cpu(&mut snapshot, 99.0);
547        snapshot.cpu.total = snapshot
548            .cpu
549            .total
550            .into_stale(core::time::Duration::from_secs(4));
551        let reading = read(PressureId::Cpu, &snapshot, &thresholds());
552        assert_eq!(
553            reading.state,
554            MetricState::TemporarilyUnavailable(UnavailableReason::NeedsSecondSample)
555        );
556    }
557
558    #[test]
559    fn memory_pressure_grows_as_available_memory_shrinks() {
560        let total = 32 * 1024 * 1024 * 1024;
561        let cases = [
562            (50, PressureState::Normal),
563            (16, PressureState::Normal),
564            (15, PressureState::Watch),
565            (6, PressureState::Watch),
566            (5, PressureState::Critical),
567            (1, PressureState::Critical),
568        ];
569        for (available_percent, expected) in cases {
570            let mut snapshot = snapshot();
571            let available = total / 100 * available_percent;
572            set_memory(&mut snapshot, total, available);
573            let reading = read(PressureId::Memory, &snapshot, &thresholds());
574            assert_eq!(state(&reading), Some(expected), "{available_percent}% free");
575        }
576    }
577
578    #[test]
579    fn memory_pressure_is_measured_against_a_cgroup_limit_when_there_is_one() {
580        let host_total = 32 * 1024 * 1024 * 1024;
581        let mut snapshot = snapshot();
582        // 1 GiB available out of a 2 GiB container limit is critical, even though
583        // it is a rounding error of the host total (§9.2).
584        set_memory(&mut snapshot, host_total, 100 * 1024 * 1024);
585        snapshot.memory.cgroup_limit_bytes = MetricState::Available(2 * 1024 * 1024 * 1024);
586        let reading = read(PressureId::Memory, &snapshot, &thresholds());
587        assert_eq!(state(&reading), Some(PressureState::Critical));
588    }
589
590    #[test]
591    fn disk_pressure_follows_the_busiest_device_and_is_unsupported_without_one() {
592        let mut snapshot = snapshot();
593        assert_eq!(
594            read(PressureId::Disk, &snapshot, &thresholds()).state,
595            MetricState::Unsupported,
596            "no devices means nothing was measured"
597        );
598
599        set_disk_busy(&mut snapshot, "nvme0n1", 12.0);
600        set_disk_busy(&mut snapshot, "nvme1n1", 97.0);
601        let reading = read(PressureId::Disk, &snapshot, &thresholds());
602        assert_eq!(state(&reading), Some(PressureState::Critical));
603        assert_eq!(
604            reading.raw.map(|raw| raw.label),
605            Some("device busy"),
606            "§2.3 requires the raw metric"
607        );
608    }
609
610    #[test]
611    fn a_device_that_cannot_report_busy_keeps_the_signal_unsupported() {
612        // macOS: a queue-depth approximation would be misleading, so §7.3 leaves
613        // the metric unsupported rather than guessing.
614        let mut snapshot = snapshot();
615        set_disk_busy(&mut snapshot, "disk0", 50.0);
616        if let Some(device) = snapshot.disks.first_mut() {
617            device.busy = MetricState::Unsupported;
618        }
619        assert_eq!(
620            read(PressureId::Disk, &snapshot, &thresholds()).state,
621            MetricState::Unsupported
622        );
623    }
624
625    #[test]
626    fn network_reports_throughput_but_no_state_without_a_link_speed() {
627        let mut snapshot = snapshot();
628        set_network(&mut snapshot, "en0", 18_200_000.0, 2_300_000.0, None);
629        let reading = read(PressureId::Network, &snapshot, &thresholds());
630
631        assert_eq!(
632            reading.state,
633            MetricState::TemporarilyUnavailable(UnavailableReason::LinkSpeedUnknown),
634            "§7.4 forbids a utilization percentage without known capacity"
635        );
636        let raw = reading.raw.expect("the throughput itself is measured");
637        assert_eq!(raw.label, "throughput");
638        assert_eq!(
639            raw.value,
640            MeasuredValue::ByteRate(rate(18_200_000.0)),
641            "the busiest direction is the raw metric"
642        );
643    }
644
645    #[test]
646    fn network_derives_a_state_once_the_link_speed_is_known() {
647        let mut snapshot = snapshot();
648        // 95 MB/s on a gigabit link is roughly 76% of capacity.
649        set_network(&mut snapshot, "en0", 95_000_000.0, 1_000.0, Some(1_000));
650        let reading = read(PressureId::Network, &snapshot, &thresholds());
651        assert_eq!(state(&reading), Some(PressureState::Watch));
652    }
653
654    #[test]
655    fn loopback_traffic_does_not_create_network_pressure() {
656        let mut snapshot = snapshot();
657        set_network(
658            &mut snapshot,
659            "lo0",
660            9_000_000_000.0,
661            9_000_000_000.0,
662            Some(10),
663        );
664        if let Some(interface) = snapshot.networks.first_mut() {
665            interface.kind = InterfaceKind::Loopback;
666        }
667        assert_eq!(
668            read(PressureId::Network, &snapshot, &thresholds()).state,
669            MetricState::Unsupported,
670            "local traffic is not link saturation (§7.4)"
671        );
672    }
673
674    #[test]
675    fn swap_is_unsupported_when_no_swap_is_configured() {
676        let snapshot = snapshot();
677        assert!(!snapshot.memory.swap.is_enabled());
678        let reading = read(PressureId::Swap, &snapshot, &thresholds());
679        assert_eq!(
680            reading.state,
681            MetricState::Unsupported,
682            "no swap device means no swap measurement, not a healthy one"
683        );
684    }
685
686    #[test]
687    fn swap_activity_escalates_on_combined_throughput() {
688        let mut snapshot = snapshot();
689        set_swap(
690            &mut snapshot,
691            8 * 1024 * 1024 * 1024,
692            1024,
693            600_000.0,
694            600_000.0,
695        );
696        let reading = read(PressureId::Swap, &snapshot, &thresholds());
697        assert_eq!(
698            state(&reading),
699            Some(PressureState::Watch),
700            "in and out are summed: neither alone reaches 1 MiB/s"
701        );
702
703        set_swap(
704            &mut snapshot,
705            8 * 1024 * 1024 * 1024,
706            1024,
707            20_000_000.0,
708            0.0,
709        );
710        assert_eq!(
711            state(&read(PressureId::Swap, &snapshot, &thresholds())),
712            Some(PressureState::Critical)
713        );
714    }
715
716    #[test]
717    fn swap_activity_is_unavailable_when_the_platform_withholds_the_rates() {
718        let mut snapshot = snapshot();
719        set_swap(&mut snapshot, 8 * 1024 * 1024 * 1024, 1024, 0.0, 0.0);
720        snapshot.memory.swap.in_rate = MetricState::Unsupported;
721        assert_eq!(
722            read(PressureId::Swap, &snapshot, &thresholds()).state,
723            MetricState::Unsupported
724        );
725    }
726
727    #[test]
728    fn load_is_normalized_per_logical_cpu() {
729        let mut snapshot = snapshot();
730        assert_eq!(snapshot.cpu.logical_count, 8);
731        set_load(&mut snapshot, 7.9);
732        assert_eq!(
733            state(&read(PressureId::Load, &snapshot, &thresholds())),
734            Some(PressureState::Normal),
735            "7.9 on eight CPUs is below one per CPU"
736        );
737
738        set_load(&mut snapshot, 11.4);
739        assert_eq!(
740            state(&read(PressureId::Load, &snapshot, &thresholds())),
741            Some(PressureState::Watch)
742        );
743
744        set_load(&mut snapshot, 24.0);
745        assert_eq!(
746            state(&read(PressureId::Load, &snapshot, &thresholds())),
747            Some(PressureState::Critical)
748        );
749    }
750
751    #[test]
752    fn load_without_a_cpu_count_reports_the_raw_figure_and_no_state() {
753        let mut snapshot = snapshot();
754        snapshot.cpu.logical_count = 0;
755        set_load(&mut snapshot, 4.0);
756        let reading = read(PressureId::Load, &snapshot, &thresholds());
757        assert_eq!(reading.state, MetricState::Unsupported);
758        assert_eq!(
759            reading.raw.map(|raw| raw.value),
760            Some(MeasuredValue::Load(4.0))
761        );
762    }
763
764    #[test]
765    fn psi_signals_are_unsupported_off_linux() {
766        let snapshot = snapshot();
767        for id in [PressureId::PsiCpu, PressureId::PsiMemory, PressureId::PsiIo] {
768            let reading = read(id, &snapshot, &thresholds());
769            assert!(
770                reading.state.fresh().is_none(),
771                "{id:?} must not be derived without PSI data"
772            );
773        }
774    }
775
776    #[test]
777    fn each_psi_signal_reads_its_own_resource() {
778        let mut snapshot = snapshot();
779        set_psi(&mut snapshot, 1.0, 45.0, 12.0);
780        assert_eq!(
781            state(&read(PressureId::PsiCpu, &snapshot, &thresholds())),
782            Some(PressureState::Normal)
783        );
784        assert_eq!(
785            state(&read(PressureId::PsiMemory, &snapshot, &thresholds())),
786            Some(PressureState::Critical)
787        );
788        assert_eq!(
789            state(&read(PressureId::PsiIo, &snapshot, &thresholds())),
790            Some(PressureState::Watch)
791        );
792    }
793
794    #[test]
795    fn psi_resource_selection_covers_all_three_resources() {
796        let psi = psi_snapshot(1.0, 2.0, 3.0);
797        assert_eq!(
798            psi_resource(&psi, PressureId::PsiCpu).some_avg10,
799            percent(1.0)
800        );
801        assert_eq!(
802            psi_resource(&psi, PressureId::PsiMemory).some_avg10,
803            percent(2.0)
804        );
805        assert_eq!(
806            psi_resource(&psi, PressureId::PsiIo).some_avg10,
807            percent(3.0)
808        );
809    }
810
811    #[test]
812    fn a_denied_reading_outranks_an_unsupported_one_when_devices_disagree() {
813        let mut snapshot = snapshot();
814        set_disk_busy(&mut snapshot, "a", 10.0);
815        set_disk_busy(&mut snapshot, "b", 10.0);
816        if let Some(device) = snapshot.disks.first_mut() {
817            device.busy = MetricState::Unsupported;
818        }
819        if let Some(device) = snapshot.disks.get_mut(1) {
820            device.busy = MetricState::PermissionDenied;
821        }
822        assert_eq!(
823            read(PressureId::Disk, &snapshot, &thresholds()).state,
824            MetricState::PermissionDenied,
825            "the actionable explanation wins"
826        );
827    }
828}