monitrs_core/model/memory.rs
1//! Memory and swap metrics.
2//!
3//! §8.4 and §26 both insist that Linux and macOS memory semantics are *not*
4//! equivalent. Rather than papering over the difference, every snapshot records
5//! which definition produced its headline numbers in [`MemorySemantics`], and
6//! the Inspect screen shows it.
7
8use crate::model::MetricState;
9use crate::units::{Percent, Rate};
10
11/// Which platform definition produced the headline memory numbers.
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize))]
14#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
15pub enum MemorySemantics {
16 /// `available` is `/proc/meminfo`'s `MemAvailable`, the kernel's own
17 /// estimate of allocatable memory without swapping. `used` is
18 /// `total - MemAvailable`, so page cache is *not* counted as application use.
19 LinuxMemAvailable,
20 /// `available` is derived from `host_statistics64` free plus inactive plus
21 /// purgeable pages. Wired and compressed pages are reported separately
22 /// because neither is reclaimable the way Linux page cache is.
23 MacosVmStatistics,
24 /// The cross-platform baseline reported by `sysinfo`, used when native
25 /// enrichment is unavailable. Coarser than either native definition.
26 SysinfoBaseline,
27}
28
29impl MemorySemantics {
30 /// The explanation rendered on the Inspect screen and in `docs/metrics.md`.
31 #[must_use]
32 pub const fn description(self) -> &'static str {
33 match self {
34 Self::LinuxMemAvailable => {
35 "used = total - MemAvailable; page cache and buffers are not counted as \
36 application use"
37 }
38 Self::MacosVmStatistics => {
39 "available = free + inactive + purgeable; wired and compressed pages are \
40 reported separately and are not reclaimable like Linux page cache"
41 }
42 Self::SysinfoBaseline => {
43 "cross-platform baseline; coarser than the native definition and not \
44 byte-for-byte comparable with it"
45 }
46 }
47 }
48}
49
50/// The secondary memory breakdown.
51///
52/// Every field is a [`MetricState`] because the two platforms expose disjoint
53/// subsets: `buffers` is Linux-only, `wired` and `compressed` are macOS-only.
54/// §8.4 forbids labelling all non-free memory as application use, so these are
55/// presented as detail rather than folded into `used`.
56#[derive(Clone, Copy, Debug, PartialEq)]
57#[cfg_attr(feature = "serde", derive(serde::Serialize))]
58pub struct MemoryDetail {
59 /// Page cache.
60 pub cached: MetricState<u64>,
61 /// Block-device buffers. Linux only.
62 pub buffers: MetricState<u64>,
63 /// Shared memory.
64 pub shared: MetricState<u64>,
65 /// Recently used pages.
66 pub active: MetricState<u64>,
67 /// Reclaimable pages.
68 pub inactive: MetricState<u64>,
69 /// Pages that cannot be paged out. macOS only.
70 pub wired: MetricState<u64>,
71 /// Pages held in the compressor. macOS only.
72 pub compressed: MetricState<u64>,
73 /// Pages awaiting writeback. Linux only.
74 pub dirty: MetricState<u64>,
75}
76
77impl MemoryDetail {
78 /// A breakdown with nothing measured, for the first frame.
79 pub const WARMING_UP: Self = Self {
80 cached: MetricState::WarmingUp,
81 buffers: MetricState::WarmingUp,
82 shared: MetricState::WarmingUp,
83 active: MetricState::WarmingUp,
84 inactive: MetricState::WarmingUp,
85 wired: MetricState::WarmingUp,
86 compressed: MetricState::WarmingUp,
87 dirty: MetricState::WarmingUp,
88 };
89}
90
91/// Swap capacity and activity.
92///
93/// `in_rate` and `out_rate` are the metrics that actually indicate memory
94/// distress: a large but idle swap file is unremarkable, while sustained
95/// swap-in on a small one is not (§11.2).
96#[derive(Clone, Copy, Debug, PartialEq)]
97#[cfg_attr(feature = "serde", derive(serde::Serialize))]
98pub struct SwapSnapshot {
99 /// Configured swap size. Zero means swap is disabled, which is a fact
100 /// rather than an unavailable metric.
101 pub total_bytes: u64,
102 /// Swap currently in use.
103 pub used: MetricState<u64>,
104 /// Share of swap in use.
105 pub usage: MetricState<Percent>,
106 /// Pages read back from swap per second.
107 pub in_rate: MetricState<Rate>,
108 /// Pages written to swap per second.
109 pub out_rate: MetricState<Rate>,
110}
111
112impl SwapSnapshot {
113 /// Whether swap is configured at all.
114 #[must_use]
115 pub const fn is_enabled(&self) -> bool {
116 self.total_bytes > 0
117 }
118
119 /// A snapshot for a system with swap disabled.
120 #[must_use]
121 pub const fn disabled() -> Self {
122 Self {
123 total_bytes: 0,
124 used: MetricState::Available(0),
125 usage: MetricState::Unsupported,
126 in_rate: MetricState::Unsupported,
127 out_rate: MetricState::Unsupported,
128 }
129 }
130}
131
132/// System memory state.
133#[derive(Clone, Copy, Debug, PartialEq)]
134#[cfg_attr(feature = "serde", derive(serde::Serialize))]
135pub struct MemorySnapshot {
136 /// Total physical memory. Always known.
137 pub total_bytes: u64,
138 /// Memory allocatable without reclaim pressure, per [`Self::semantics`].
139 pub available: MetricState<u64>,
140 /// `total_bytes - available`, per [`Self::semantics`].
141 pub used: MetricState<u64>,
142 /// Completely unused memory. Usually much smaller than `available`.
143 pub free: MetricState<u64>,
144 /// Share of memory in use.
145 pub usage: MetricState<Percent>,
146 /// The secondary breakdown.
147 pub detail: MemoryDetail,
148 /// Swap capacity and activity.
149 pub swap: SwapSnapshot,
150 /// Which definition produced `available` and `used`.
151 pub semantics: MemorySemantics,
152 /// The cgroup memory limit, when running under one, alongside the host
153 /// total in `total_bytes`.
154 ///
155 /// §9.2 requires container limits to be exposed *separately* from host
156 /// totals and both to be shown and labelled where observable.
157 pub cgroup_limit_bytes: MetricState<u64>,
158 /// The cgroup's *own* memory usage, when running under one.
159 ///
160 /// Inside a container, `used` is the host's figure: `/proc/meminfo` is not
161 /// namespaced, so a process in a 2 GiB group on a 64 GiB host sees the host's
162 /// 40 GiB and concludes it is nearly out of memory when it has used 300 MiB of its
163 /// own allowance. This is the group's figure, read from `memory.current` — the same
164 /// counter the kernel compares against `memory.max` when it decides to OOM-kill,
165 /// which is what makes it the number worth showing rather than a second opinion.
166 ///
167 /// It counts reclaimable page cache, so it sits above what the group would need
168 /// under pressure. That is a property of how the limit is *enforced*, not an
169 /// inaccuracy: the kernel reclaims that cache before killing anything.
170 ///
171 /// [`MetricState::Unsupported`] off Linux and outside a cgroup.
172 pub cgroup_used_bytes: MetricState<u64>,
173}
174
175impl MemorySnapshot {
176 /// A snapshot with only the total known, for the first frame.
177 #[must_use]
178 pub const fn warming_up(total_bytes: u64, semantics: MemorySemantics) -> Self {
179 Self {
180 total_bytes,
181 available: MetricState::WarmingUp,
182 used: MetricState::WarmingUp,
183 free: MetricState::WarmingUp,
184 usage: MetricState::WarmingUp,
185 detail: MemoryDetail::WARMING_UP,
186 swap: SwapSnapshot {
187 total_bytes: 0,
188 used: MetricState::WarmingUp,
189 usage: MetricState::WarmingUp,
190 in_rate: MetricState::WarmingUp,
191 out_rate: MetricState::WarmingUp,
192 },
193 semantics,
194 cgroup_limit_bytes: MetricState::WarmingUp,
195 cgroup_used_bytes: MetricState::WarmingUp,
196 }
197 }
198
199 /// The memory ceiling that actually applies to this process tree.
200 ///
201 /// Inside a container this is the cgroup limit, not the host total; §9.2
202 /// requires the distinction to be observable rather than silently folded
203 /// into one number.
204 ///
205 /// A *stale* reading counts here, which is the one place this crate deliberately
206 /// breaks [`MetricState::fresh`]'s "use fresh values for calculations" rule. A limit
207 /// is configuration, not a measurement: if the last successful read said 2 GiB and
208 /// this tick's read failed, the group is still limited to 2 GiB, and falling back to
209 /// the host's 64 would report 62 GiB of headroom that does not exist — wrong in the
210 /// direction that gets a process OOM-killed by surprise. Keeping the retained value
211 /// is wrong only if the limit changed in the last few seconds, and the row is marked
212 /// stale either way.
213 #[must_use]
214 pub fn effective_limit_bytes(&self) -> u64 {
215 match self.cgroup_limit_bytes.displayable() {
216 Some((&limit, _)) if limit > 0 && limit < self.total_bytes => limit,
217 _ => self.total_bytes,
218 }
219 }
220
221 /// Whether a cgroup limit, rather than the installed RAM, is the ceiling here.
222 ///
223 /// Stale counts, for the reason [`Self::effective_limit_bytes`] gives.
224 #[must_use]
225 pub fn is_memory_limited(&self) -> bool {
226 self.cgroup_limit_bytes
227 .displayable()
228 .is_some_and(|(&limit, _)| limit > 0 && limit < self.total_bytes)
229 }
230
231 /// Bytes in use against [`Self::effective_limit_bytes`].
232 ///
233 /// The cgroup's own usage where the platform reports it, the host's `used`
234 /// otherwise — so that the two halves of the ratio always come from the same
235 /// place. Pairing a host `used` with a container limit is the specific mistake this
236 /// exists to prevent: it reports 40 GiB of 2 GiB, or 2000%.
237 ///
238 /// The unavailability is passed through rather than replaced, so a caller can say
239 /// *why* there is no figure.
240 #[must_use]
241 pub fn effective_used_bytes(&self) -> MetricState<u64> {
242 match self.cgroup_used_bytes {
243 MetricState::Unsupported => self.used,
244 group => group,
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use core::time::Duration;
252
253 use super::*;
254
255 const GIB: u64 = 1024 * 1024 * 1024;
256 const MIB: u64 = 1024 * 1024;
257
258 /// A 64 GiB host with 40 GiB in use, inside a 2 GiB group using 300 MiB of it.
259 fn containerised() -> MemorySnapshot {
260 let mut memory = MemorySnapshot::warming_up(64 * GIB, MemorySemantics::LinuxMemAvailable);
261 memory.used = MetricState::Available(40 * GIB);
262 memory.cgroup_limit_bytes = MetricState::Available(2 * GIB);
263 memory.cgroup_used_bytes = MetricState::Available(300 * MIB);
264 memory
265 }
266
267 #[test]
268 fn inside_a_container_the_groups_own_charge_is_the_used_figure() {
269 // `/proc/meminfo` is not namespaced, so `used` is the host's 40 GiB. Pairing it
270 // with the group's 2 GiB limit reports 2000% — the arithmetic §9.2 exists to
271 // prevent — so both halves of the ratio come from the group.
272 let memory = containerised();
273 assert_eq!(memory.effective_used_bytes().fresh(), Some(&(300 * MIB)));
274 assert_eq!(memory.effective_limit_bytes(), 2 * GIB);
275 assert!(memory.is_memory_limited());
276 assert_eq!(
277 memory.used.fresh(),
278 Some(&(40 * GIB)),
279 "the host figure stays observable"
280 );
281 }
282
283 #[test]
284 fn without_a_cgroup_the_host_figures_are_the_effective_ones() {
285 let mut memory = containerised();
286 memory.cgroup_limit_bytes = MetricState::Unsupported;
287 memory.cgroup_used_bytes = MetricState::Unsupported;
288 assert_eq!(memory.effective_used_bytes().fresh(), Some(&(40 * GIB)));
289 assert_eq!(memory.effective_limit_bytes(), 64 * GIB);
290 assert!(!memory.is_memory_limited());
291 }
292
293 #[test]
294 fn a_stale_limit_still_bounds_the_machine() {
295 // The counterpart of `CpuSnapshot`'s rule, and the reason both use
296 // `displayable` rather than `fresh`: falling back to the host's 64 GiB here
297 // would advertise 62 GiB of headroom that the group does not have.
298 let mut memory = containerised();
299 memory.cgroup_limit_bytes = MetricState::Stale {
300 value: 2 * GIB,
301 age: Duration::from_secs(45),
302 };
303 assert_eq!(memory.effective_limit_bytes(), 2 * GIB);
304 assert!(memory.is_memory_limited());
305 }
306
307 #[test]
308 fn an_unreadable_group_charge_is_passed_through_rather_than_replaced() {
309 // Falling back to the host's `used` here would put a 40 GiB figure under a
310 // 2 GiB ceiling. The caller is told why there is no number instead (§4).
311 let mut memory = containerised();
312 memory.cgroup_used_bytes = MetricState::PermissionDenied;
313 assert!(matches!(
314 memory.effective_used_bytes(),
315 MetricState::PermissionDenied
316 ));
317 assert_eq!(
318 memory.effective_limit_bytes(),
319 2 * GIB,
320 "the limit is unaffected by the charge being unreadable"
321 );
322 }
323
324 #[test]
325 fn each_platform_semantics_explains_itself() {
326 for semantics in [
327 MemorySemantics::LinuxMemAvailable,
328 MemorySemantics::MacosVmStatistics,
329 MemorySemantics::SysinfoBaseline,
330 ] {
331 assert!(!semantics.description().is_empty());
332 }
333 // The Linux description must state that cache is not application use.
334 assert!(
335 MemorySemantics::LinuxMemAvailable
336 .description()
337 .contains("page cache")
338 );
339 }
340
341 #[test]
342 fn disabled_swap_is_a_fact_not_an_unavailable_metric() {
343 let swap = SwapSnapshot::disabled();
344 assert!(!swap.is_enabled());
345 assert_eq!(
346 swap.used.fresh(),
347 Some(&0),
348 "0 of 0 bytes used is a real measurement"
349 );
350 // ...but a percentage of zero capacity is genuinely undefined.
351 assert!(swap.usage.fresh().is_none());
352 }
353
354 #[test]
355 fn a_cgroup_limit_below_the_host_total_becomes_the_effective_ceiling() {
356 let mut memory =
357 MemorySnapshot::warming_up(32 * 1024 * 1024 * 1024, MemorySemantics::LinuxMemAvailable);
358 assert_eq!(memory.effective_limit_bytes(), 32 * 1024 * 1024 * 1024);
359
360 memory.cgroup_limit_bytes = MetricState::Available(2 * 1024 * 1024 * 1024);
361 assert_eq!(memory.effective_limit_bytes(), 2 * 1024 * 1024 * 1024);
362 }
363
364 #[test]
365 fn an_unlimited_cgroup_does_not_shrink_the_ceiling() {
366 let mut memory =
367 MemorySnapshot::warming_up(32 * 1024 * 1024 * 1024, MemorySemantics::LinuxMemAvailable);
368 // cgroup v2 writes an enormous sentinel for "max".
369 memory.cgroup_limit_bytes = MetricState::Available(u64::MAX);
370 assert_eq!(memory.effective_limit_bytes(), 32 * 1024 * 1024 * 1024);
371 memory.cgroup_limit_bytes = MetricState::Available(0);
372 assert_eq!(memory.effective_limit_bytes(), 32 * 1024 * 1024 * 1024);
373 }
374
375 #[test]
376 fn warming_up_preserves_the_requested_semantics() {
377 let memory = MemorySnapshot::warming_up(1024, MemorySemantics::MacosVmStatistics);
378 assert_eq!(memory.semantics, MemorySemantics::MacosVmStatistics);
379 assert_eq!(memory.total_bytes, 1024);
380 assert!(memory.available.is_warming_up());
381 }
382}