Skip to main content

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}