Skip to main content

monitrs_core/model/
snapshot.rs

1//! The immutable snapshot published to the UI.
2//!
3//! §10.4: collectors build a new snapshot and publish it as `Arc<SystemSnapshot>`
4//! so the UI never observes a partially updated set of metrics. Nothing in this
5//! type has interior mutability and nothing is updated in place.
6
7use core::time::Duration;
8use std::time::{Instant, SystemTime};
9
10use crate::model::{
11    CapabilitySnapshot, CollectorHealth, CpuSnapshot, DiskSnapshot, FilesystemSnapshot,
12    HostSnapshot, LoadSnapshot, MemorySemantics, MemorySnapshot, MetricState, NetworkSnapshot,
13    PressureSnapshot, ProcessIdentity, ProcessSnapshot, SensorSnapshot,
14};
15use crate::units::Percent;
16
17/// One complete, internally consistent observation of the system.
18#[derive(Clone, Debug)]
19pub struct SystemSnapshot {
20    /// Monotonically increasing sequence number.
21    ///
22    /// Used to detect coalescing and to order snapshots without consulting the
23    /// wall clock, which can move backwards (§8.1).
24    pub sequence: u64,
25    /// Monotonic capture time, for rate calculations and ordering (§8.1).
26    ///
27    /// Deliberately an [`Instant`], which is why this type is not `Serialize`:
28    /// export goes through a dedicated DTO that emits `wall_time` instead.
29    pub captured_at: Instant,
30    /// Wall-clock capture time, for display and export only.
31    pub wall_time: SystemTime,
32    /// The *actual* interval since the previous snapshot.
33    ///
34    /// §8.1 forbids assuming one second. Zero on the first snapshot.
35    pub elapsed: Duration,
36    /// System identity.
37    pub host: HostSnapshot,
38    /// CPU state.
39    pub cpu: CpuSnapshot,
40    /// Memory state.
41    pub memory: MemorySnapshot,
42    /// Load averages.
43    pub load: MetricState<LoadSnapshot>,
44    /// Every process visible to us.
45    pub processes: Vec<ProcessSnapshot>,
46    /// Block devices.
47    pub disks: Vec<DiskSnapshot>,
48    /// Mounted filesystems. Separate from `disks` by §7.3.
49    pub filesystems: Vec<FilesystemSnapshot>,
50    /// Network interfaces.
51    pub networks: Vec<NetworkSnapshot>,
52    /// Pressure Radar.
53    pub pressure: PressureSnapshot,
54    /// Temperature and battery.
55    pub sensors: SensorSnapshot,
56    /// What this platform can and cannot report.
57    pub capabilities: CapabilitySnapshot,
58    /// Collector timing and our own overhead.
59    pub health: CollectorHealth,
60}
61
62impl SystemSnapshot {
63    /// The first snapshot: identity is known, every measurement is warming up.
64    ///
65    /// §26: the first sample of delta-based data is *not* zero. This constructor
66    /// is what makes that the default rather than something each collector must
67    /// remember.
68    #[must_use]
69    pub fn warming_up(captured_at: Instant, wall_time: SystemTime, logical_cpus: u16) -> Self {
70        Self {
71            sequence: 0,
72            captured_at,
73            wall_time,
74            elapsed: Duration::ZERO,
75            host: HostSnapshot::warming_up(),
76            cpu: CpuSnapshot::warming_up(logical_cpus),
77            memory: MemorySnapshot::warming_up(0, MemorySemantics::SysinfoBaseline),
78            load: MetricState::WarmingUp,
79            processes: Vec::new(),
80            disks: Vec::new(),
81            filesystems: Vec::new(),
82            networks: Vec::new(),
83            pressure: PressureSnapshot::warming_up(),
84            sensors: SensorSnapshot::warming_up(),
85            capabilities: CapabilitySnapshot::default(),
86            health: CollectorHealth::default(),
87        }
88    }
89
90    /// Looks up a process by stable identity.
91    ///
92    /// Keyed on the full identity rather than the PID, so a reused PID returns
93    /// `None` instead of a different process (§26).
94    #[must_use]
95    pub fn process(&self, identity: ProcessIdentity) -> Option<&ProcessSnapshot> {
96        self.processes
97            .iter()
98            .find(|process| process.identity == identity)
99    }
100
101    /// Looks up whatever process currently holds `pid`, whichever it is.
102    ///
103    /// Only the signal-revalidation path should use this: it needs to discover
104    /// that a PID has been reused, which requires deliberately ignoring the
105    /// start key (§6.2).
106    #[must_use]
107    pub fn process_by_pid(&self, pid: u32) -> Option<&ProcessSnapshot> {
108        self.processes
109            .iter()
110            .find(|process| process.identity.pid == pid)
111    }
112
113    /// Total process count, for the `218 total` header in §5.5.
114    #[must_use]
115    pub fn process_count(&self) -> usize {
116        self.processes.len()
117    }
118
119    /// The sum of all per-process CPU percentages, core-normalized.
120    ///
121    /// Used as the denominator of the attribution coverage figure in §2.2. This
122    /// is deliberately *not* compared against system CPU as though the two were
123    /// interchangeable: they are measured differently, and the coverage figure is
124    /// presented as evidence rather than proof.
125    #[must_use]
126    pub fn total_process_cpu(&self) -> Option<Percent> {
127        let sum: f32 = self
128            .processes
129            .iter()
130            .filter_map(|process| process.cpu.fresh().map(|cpu| cpu.value()))
131            .sum();
132        Percent::new(sum)
133    }
134
135    /// How many processes are in a state §7.2 requires to stand out.
136    #[must_use]
137    pub fn notable_process_count(&self) -> usize {
138        self.processes
139            .iter()
140            .filter(|process| process.state.is_notable())
141            .count()
142    }
143
144    /// Whether this snapshot can produce valid rates.
145    ///
146    /// False for the first snapshot, whose `elapsed` is zero.
147    #[must_use]
148    pub fn has_valid_interval(&self) -> bool {
149        !self.elapsed.is_zero()
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::model::{ProcessIo, ProcessMemory, ProcessState};
157
158    fn process(pid: u32, start_key: u64, cpu: Option<f32>) -> ProcessSnapshot {
159        ProcessSnapshot {
160            identity: ProcessIdentity::new(pid, start_key),
161            parent_pid: Some(1),
162            name: "test".into(),
163            command: "test".into(),
164            exe: None,
165            user: MetricState::Unsupported,
166            state: ProcessState::Running,
167            cpu: cpu
168                .and_then(Percent::new)
169                .map_or(MetricState::WarmingUp, MetricState::Available),
170            memory: ProcessMemory::WARMING_UP,
171            io: ProcessIo::UNSUPPORTED,
172            threads: MetricState::Unsupported,
173            age: MetricState::Unsupported,
174            started_at: MetricState::Unsupported,
175            is_kernel_thread: false,
176        }
177    }
178
179    fn snapshot() -> SystemSnapshot {
180        SystemSnapshot::warming_up(Instant::now(), SystemTime::UNIX_EPOCH, 8)
181    }
182
183    #[test]
184    fn the_first_snapshot_measures_nothing_and_has_no_valid_interval() {
185        let snapshot = snapshot();
186        assert_eq!(snapshot.sequence, 0);
187        assert!(!snapshot.has_valid_interval());
188        assert!(snapshot.cpu.total.is_warming_up());
189        assert!(snapshot.load.is_warming_up());
190        assert_eq!(snapshot.process_count(), 0);
191        assert_eq!(snapshot.cpu.logical_count, 8);
192    }
193
194    #[test]
195    fn process_lookup_by_identity_rejects_a_reused_pid() {
196        let mut snapshot = snapshot();
197        snapshot
198            .processes
199            .push(process(31_842, 900_100, Some(287.0)));
200
201        let original = ProcessIdentity::new(31_842, 900_100);
202        let recycled = ProcessIdentity::new(31_842, 977_400);
203
204        assert!(snapshot.process(original).is_some());
205        assert!(
206            snapshot.process(recycled).is_none(),
207            "a reused PID must not resolve to the previous process"
208        );
209    }
210
211    #[test]
212    fn lookup_by_pid_deliberately_ignores_the_start_key_so_reuse_is_detectable() {
213        let mut snapshot = snapshot();
214        snapshot.processes.push(process(31_842, 977_400, None));
215
216        let found = snapshot.process_by_pid(31_842).expect("pid is present");
217        assert_eq!(found.identity.start_key, 977_400);
218        assert!(
219            found
220                .identity
221                .is_reuse_of(&ProcessIdentity::new(31_842, 900_100))
222        );
223    }
224
225    #[test]
226    fn total_process_cpu_sums_only_measured_values() {
227        let mut snapshot = snapshot();
228        snapshot.processes.push(process(1, 1, Some(287.0)));
229        snapshot.processes.push(process(2, 2, Some(54.0)));
230        snapshot.processes.push(process(3, 3, None));
231
232        let total = snapshot.total_process_cpu().expect("two measured values");
233        assert!((total.value() - 341.0).abs() < 0.01, "got {total}");
234    }
235
236    #[test]
237    fn total_process_cpu_of_an_empty_table_is_zero_not_undefined() {
238        // An empty table is a real state: a container may legitimately show
239        // nothing but our own process.
240        let total = snapshot().total_process_cpu().expect("empty sum is zero");
241        assert!((total.value() - 0.0).abs() < f32::EPSILON);
242    }
243
244    #[test]
245    fn notable_processes_are_counted_for_the_header() {
246        let mut snapshot = snapshot();
247        snapshot.processes.push(process(1, 1, None));
248        let mut zombie = process(2, 2, None);
249        zombie.state = ProcessState::Zombie;
250        snapshot.processes.push(zombie);
251        let mut blocked = process(3, 3, None);
252        blocked.state = ProcessState::UninterruptibleSleep;
253        snapshot.processes.push(blocked);
254
255        assert_eq!(snapshot.notable_process_count(), 2);
256    }
257}