Skip to main content

monitord/
cgroup.rs

1//! # cgroup module
2//!
3//! Per-service cgroup accounting read straight from cgroupfs instead of D-Bus
4//! or varlink (issue #221, unblocking the zero-D-Bus goal in #37).
5//!
6//! Seven `ServiceStats` fields describe a service's cgroup, and every one of
7//! them is a plain cgroup v2 file the kernel already exposes:
8//!
9//! | `ServiceStats` field | cgroupfs source |
10//! |---|---|
11//! | `cpuusage_nsec` | `cpu.stat` → `usage_usec` × 1000 |
12//! | `memory_current` | `memory.current` |
13//! | `memory_available` | `min(memory.max, memory.high) − memory.current`, minimized over the slice walk (see below) |
14//! | `tasks_current` | `pids.current` |
15//! | `processes` | recursive `cgroup.procs` walk + main/control PID fold-in |
16//! | `ioread_bytes` | `io.stat` → `rbytes` summed over devices |
17//! | `ioread_operations` | `io.stat` → `rios` summed over devices |
18//!
19//! systemd itself reads these files directly (`src/core/cgroup.c`:
20//! `unit_get_cpu_usage_raw`, `unit_get_memory_accounting`,
21//! `unit_get_memory_available`, `unit_get_tasks_current`,
22//! `unit_get_io_accounting_raw`; D-Bus getters in `src/core/dbus-unit.c`
23//! answer `u64::MAX` when the read fails). There is no accounting API
24//! underneath to depend on — the accounting flags only control whether the
25//! controller is enabled, which is itself visible as file presence. Dropping
26//! the dependency on per-unit accounting flags being enabled is an explicit
27//! goal of #221.
28//!
29//! ## `memory_available` semantics
30//!
31//! Replicates `unit_get_memory_available()`: walk the unit's cgroup directory
32//! up through each ancestor slice to the filesystem root; at every level that
33//! sets `memory.max` or `memory.high`, compute `min(max, high) − current` with
34//! that level's own `memory.current`, and take the minimum. The root level
35//! (the `-​-.slice` equivalent) is always constrained: its limit is host
36//! physical memory (`MemTotal` from `/proc/meminfo`) and its current is host
37//! memory pressure — which works out byte-exact to `MemAvailable` from
38//! `/proc/meminfo` (verified live: D-Bus `MemoryAvailable` for a limitless
39//! service equalled `MemAvailable` exactly). A unit with no limits anywhere
40//! therefore reports host `MemAvailable`, matching D-Bus; a unit with
41//! `MemoryMax=1G MemoryHigh=512M` reported `536870912 − 1368064 = 535502848`,
42//! byte-exact with D-Bus.
43//!
44//! ## Why hand-rolled instead of a cgroupfs crate
45//!
46//! The ecosystem crates are all synchronous: `cgroups-rs` (kata, the most
47//! downloaded), Meta's `cgroupfs` (the closest miss — fd-based `openat`
48//! reads), and youki's `libcgroups` are `std::fs` under the hood and would
49//! need `spawn_blocking` around the same file reads below, plus extra
50//! dependencies (`nix`, `serde`, `oci-spec`, …) and more binary size,
51//! against the embedded goal. None of them models `memory_available`
52//! either — that is a systemd concept, not a kernel file, and it is where
53//! most of this module's complexity lives. So: `tokio::fs` reads issued
54//! together with `tokio::join!`, zero new dependencies, cgroup v2 only.
55//!
56//! ## `fs_root` and containers
57//!
58//! Like `collect_unit_files_stats`, every path is prefixed with `fs_root` —
59//! empty for the host, `/proc/<leader>/root` for containers — so container
60//! unit cgroups are readable without entering the PID namespace (#211). The
61//! ancestor walk stops at the prefixed root (a container's systemd never sees
62//! host slices above its own root), and the root level always uses the
63//! *host* `/proc/meminfo`, matching what the container's PID 1 computes via
64//! `physical_memory()` / `procfs_memory_get_used()`.
65//!
66//! ## Failure semantics
67//!
68//! Every field is `Option`: `None` means the file was absent or unparsable,
69//! and callers map that to `u64::MAX` — the `[not set]` sentinel D-Bus uses.
70//! `processes` is a plain count (`0` when the cgroup is gone), matching what
71//! `GetProcesses` returns for a dead unit. Callers keep their existing
72//! fallback (D-Bus props on the D-Bus path, the `Unit.List` reply on the
73//! varlink path) for fields that come back `None`, which is also what covers
74//! cgroup v1 hosts: no v2 files → all `None` → fallback, no new failure
75//! mode. `memory_available` included: the host-`MemAvailable` root level
76//! only binds when the unit's own level was actually readable, so a missing
77//! cgroup reports `None` there like everywhere else instead of silently
78//! answering host memory.
79
80use std::collections::HashSet;
81use std::path::Path;
82
83use tracing::debug;
84
85/// Accounting values for one service's cgroup, read from cgroupfs.
86///
87/// `None` per field means "no data" (file absent or unparsable); callers map
88/// that to `u64::MAX`, the `[not set]` sentinel D-Bus reports for the same
89/// situation. `processes` counts instead of measuring, so it is `0` — not
90/// `None` — when the cgroup is gone, matching `GetProcesses` on a dead unit.
91#[derive(Clone, Debug, Default, PartialEq, Eq)]
92pub struct CgroupStats {
93    /// `cpu.stat` → `usage_usec` × 1000. No per-start baseline is subtracted:
94    /// D-Bus subtracts the counter value captured at unit start, which is
95    /// unknowable from the filesystem, so a reused cgroup reads marginally
96    /// higher. Fresh cgroups (the common case) match exactly.
97    pub cpu_usage_nsec: Option<u64>,
98    /// `memory.current` verbatim.
99    pub memory_current: Option<u64>,
100    /// `min(memory.max, memory.high) − memory.current`, minimized over the
101    /// unit's ancestor slices; the root level always binds at host
102    /// `MemAvailable`. See the module docs for the full algorithm.
103    pub memory_available: Option<u64>,
104    /// `pids.current` verbatim.
105    pub tasks_current: Option<u64>,
106    /// Recursive `cgroup.procs` walk with the main and control PIDs folded in
107    /// the way systemd's `GetProcesses` does (both can sit outside the
108    /// cgroup).
109    pub processes: u32,
110    /// `io.stat` → `rbytes` summed over all devices. `Some(0)` when the file
111    /// exists but is empty (controller enabled, no IO yet — D-Bus reports `0`
112    /// there too); `None` when the file is absent.
113    pub io_read_bytes: Option<u64>,
114    /// `io.stat` → `rios` summed over all devices, same presence semantics.
115    pub io_read_operations: Option<u64>,
116}
117
118/// Host-wide memory numbers, read once per collection cycle and shared by
119/// every service's `memory_available` fold: `(MemTotal, host used bytes)`.
120/// See `read_host_memory` for the semantics.
121pub type HostMemory = (u64, u64);
122
123/// Read every cgroup accounting file for one service in a single parallel
124/// batch.
125///
126/// `cgroup_path` is the unit's cgroup relative to the mount, e.g.
127/// `/system.slice/chrony.service` (the `ControlGroup` D-Bus property or
128/// `runtime.CGroup.Path` from `Unit.List`). `extra_pids` folds the main and
129/// control PIDs in the way systemd's `GetProcesses` does, since both can sit
130/// outside the cgroup (e.g. during `ExecStartPre`/`ExecReload`/`ExecStop`).
131/// `host_memory` is the cycle-shared `read_host_memory()` result; `None`
132/// (unreadable `/proc/meminfo`) leaves `memory_available` at `None`.
133///
134/// An empty `cgroup_path` (inactive unit with no cgroup) short-circuits to
135/// `CgroupStats::default()` without touching the filesystem: without this
136/// guard the walk would start at the mount root itself and count every PID
137/// on the box. Every field but `processes` (which is `0`) comes back `None`
138/// so the caller's IPC fallback takes over, like any other unreadable
139/// cgroup.
140///
141/// The unit-level file reads run concurrently via `tokio::join!`, and callers
142/// join this future with their in-flight D-Bus/varlink calls, so filesystem
143/// IO never serializes behind IPC.
144pub async fn read_service_cgroup(
145    fs_root: &str,
146    cgroup_path: &str,
147    extra_pids: &[u32],
148    host_memory: Option<HostMemory>,
149) -> CgroupStats {
150    if cgroup_path.is_empty() || cgroup_path == "/" {
151        return CgroupStats::default();
152    }
153    let dir = format!("{fs_root}/sys/fs/cgroup{cgroup_path}");
154    let dir = dir.as_str();
155
156    // The unit's own `memory.current` is read once here and handed to the
157    // ancestor walk below, which would otherwise re-read the same file for
158    // its level-0 triple.
159    let (cpu, memory_current, tasks, io_stat, processes, levels) = tokio::join!(
160        async { read_file(dir, "cpu.stat").await },
161        async { read_file(dir, "memory.current").await },
162        async { read_file(dir, "pids.current").await },
163        async { read_file(dir, "io.stat").await },
164        async { count_processes(dir, extra_pids).await },
165        async { read_ancestor_levels(fs_root, cgroup_path).await },
166    );
167
168    let cpu_usage_nsec = cpu.as_deref().and_then(parse_cpu_stat);
169    let memory_current = memory_current.as_deref().and_then(parse_u64);
170    let tasks_current = tasks.as_deref().and_then(parse_u64);
171    let (io_read_bytes, io_read_operations) = match io_stat.as_deref() {
172        Some(contents) => {
173            let (bytes, ops) = parse_io_stat(contents);
174            (Some(bytes), Some(ops))
175        }
176        None => (None, None),
177    };
178
179    // `memory_available` needs the ancestor chain as well (`memory.max` /
180    // `memory.high` at every level up to the mount root). The walk ran in
181    // the batch above; the fold only applies when the unit's own level was
182    // actually readable — otherwise there is no cgroup v2 here (v1 host,
183    // stopped unit) and the caller's IPC fallback takes over, like every
184    // other field, instead of silently reporting host `MemAvailable`.
185    let memory_available = match (levels.first(), host_memory) {
186        (Some(first), Some(root)) if first.current.is_some() => {
187            fold_memory_available(&levels, root, memory_current)
188        }
189        _ => None,
190    };
191
192    CgroupStats {
193        cpu_usage_nsec,
194        memory_current,
195        memory_available,
196        tasks_current,
197        processes,
198        io_read_bytes,
199        io_read_operations,
200    }
201}
202
203/// Read one cgroup attribute file, returning `None` when it is absent or
204/// unreadable. A missing file is routine (controller not enabled on this
205/// subtree), so it logs at debug, not warn.
206async fn read_file(dir: &str, file: &str) -> Option<String> {
207    match tokio::fs::read_to_string(format!("{dir}/{file}")).await {
208        Ok(contents) => Some(contents),
209        Err(err) => {
210            debug!("Unable to read {dir}/{file}: {err:?}");
211            None
212        }
213    }
214}
215
216/// Parse `cpu.stat`, extracting `usage_usec` and converting to nanoseconds.
217///
218/// `usage_usec` is the field systemd reads (`unit_get_cpu_usage_raw`); the
219/// sibling `user_usec`/`system_usec` lines are informational only.
220fn parse_cpu_stat(contents: &str) -> Option<u64> {
221    keyed_u64(contents, "usage_usec").and_then(|us| us.checked_mul(1000))
222}
223
224/// Parse a plain `u64` attribute file (`memory.current`, `pids.current`).
225/// The trailing newline the kernel appends is trimmed; anything else that
226/// does not parse is "no data" rather than an error.
227fn parse_u64(contents: &str) -> Option<u64> {
228    contents.trim().parse().ok()
229}
230
231/// Parse a memory limit file (`memory.max`, `memory.high`).
232///
233/// Returns `None` for both "max" (unlimited — no constraint from this level)
234/// and unparsable content; `Some(limit)` otherwise. Callers treat `None` as
235/// infinity when taking the `min(max, high)` per level.
236fn parse_memory_limit(contents: &str) -> Option<u64> {
237    let trimmed = contents.trim();
238    if trimmed == "max" {
239        return None;
240    }
241    trimmed.parse().ok()
242}
243
244/// Extract a `key value` field from space-separated cgroup keyed files
245/// (`cpu.stat` uses `usage_usec 123`).
246fn keyed_u64(contents: &str, key: &str) -> Option<u64> {
247    let prefix = format!("{key} ");
248    contents
249        .lines()
250        .find_map(|line| line.strip_prefix(&prefix))
251        .and_then(|value| value.trim().parse().ok())
252}
253
254/// Sum `rbytes=` / `rios=` across every device line of `io.stat`.
255///
256/// Lines look like `8:0 rbytes=2936832 wbytes=278528 rios=87 wios=68 ...`;
257/// systemd (`unit_get_io_accounting_raw`) skips the `major:minor` head and
258/// sums the named fields over all devices. A present-but-empty file sums to
259/// `0` (controller enabled, nothing accounted yet); an absent file never
260/// reaches here — that is the `None` case, decided by the caller.
261fn parse_io_stat(contents: &str) -> (u64, u64) {
262    let mut bytes = 0u64;
263    let mut operations = 0u64;
264    for line in contents.lines() {
265        // Skip the leading `major:minor` token; the rest are `key=value`.
266        for word in line.split_whitespace().skip(1) {
267            let (key, value) = match word.split_once('=') {
268                Some(pair) => pair,
269                None => continue,
270            };
271            let parsed: u64 = match value.parse() {
272                Ok(value) => value,
273                Err(_) => continue,
274            };
275            match key {
276                "rbytes" => bytes = bytes.saturating_add(parsed),
277                "rios" => operations = operations.saturating_add(parsed),
278                _ => {}
279            }
280        }
281    }
282    (bytes, operations)
283}
284
285/// One level of the `memory_available` ancestor walk: the level's limit (the
286/// `min(memory.max, memory.high)`, `None` when both are unlimited) and its
287/// `memory.current` (`None` when unreadable, in which case systemd propagates
288/// the previous — deeper — level's current as a lower bound).
289struct MemoryLevel {
290    limit: Option<u64>,
291    current: Option<u64>,
292}
293
294/// Pure fold over the ancestor levels plus the root, mirroring
295/// `unit_get_memory_available()`.
296///
297/// `levels` runs from the unit's own cgroup up to (excluding) the filesystem
298/// root; `root` is the always-constrained top (`MemTotal`, host used bytes).
299/// `unit_current` is the unit's own freshly-read `memory.current`, which
300/// takes precedence over whatever the walk stored for level 0 (same file,
301/// one read instead of two). Levels with no limit are skipped; a level
302/// whose current is unreadable reuses the deeper level's current, exactly
303/// like systemd's "previous current propagates as lower bound" — including
304/// level 0 with an unreadable current, which contributes `limit − 0`.
305/// Returns `None` only when nothing was readable at all — in practice the
306/// root level always binds, so this is `Some` whenever `/proc/meminfo`
307/// parses.
308fn fold_memory_available(
309    levels: &[MemoryLevel],
310    root: (u64, u64),
311    unit_current: Option<u64>,
312) -> Option<u64> {
313    let mut available = u64::MAX;
314    // systemd initialises `current = 0`, so a limit with no readable current
315    // anywhere constrains by the full limit rather than being skipped.
316    let mut current_fallback: Option<u64> = None;
317    for (index, level) in levels.iter().enumerate() {
318        // Level 0 prefers the join!-batch read over the walk's own copy.
319        let current = if index == 0 {
320            unit_current.or(level.current)
321        } else {
322            level.current
323        };
324        if let Some(current) = current {
325            current_fallback = Some(current);
326        }
327        let Some(limit) = level.limit else {
328            continue;
329        };
330        if let Some(current) = current_fallback {
331            available = available.min(limit.saturating_sub(current));
332            if available == 0 {
333                break;
334            }
335        } else {
336            available = available.min(limit);
337            if available == 0 {
338                break;
339            }
340        }
341    }
342    let (root_limit, root_current) = root;
343    available = available.min(root_limit.saturating_sub(root_current));
344    (available != u64::MAX).then_some(available)
345}
346
347/// Read the host root-slice level: `(MemTotal, host used bytes)`.
348///
349/// Async (`tokio::fs`) so it never blocks the runtime; read once per
350/// collection cycle and shared by every service (see `HostMemory`).
351///
352/// Mirrors systemd's root-slice handling exactly: the limit is
353/// `physical_memory()` (== `MemTotal`; verified `sysinfo.totalram` matches
354/// it byte-exact on this kernel) and the current is `procfs_memory_get_used()`
355/// (`src/basic/procfs-util.c`), which returns `MemTotal − MemAvailable`, so
356/// the root level contributes `MemTotal − used == MemAvailable` — verified
357/// live, D-Bus `MemoryAvailable` for a limitless service equalled
358/// `MemAvailable` byte-exact.
359///
360/// Both numbers come from the host `/proc/meminfo` even for container reads:
361/// a container's PID 1 computes the same two numbers from the same host
362/// `/proc`.
363/// Read `/proc/meminfo` once per collection cycle; see `HostMemory`.
364pub async fn read_host_memory() -> Option<HostMemory> {
365    let contents = tokio::fs::read_to_string("/proc/meminfo").await.ok()?;
366    let mut total = None;
367    let mut available = None;
368    for line in contents.lines() {
369        // Skip — don't bail on — lines systemd wouldn't recognise either:
370        // `procfs_memory_get()` does `else continue` here.
371        let Some((key, rest)) = line.split_once(':') else {
372            continue;
373        };
374        // Values are in kB: `MemTotal:       11984296 kB`.
375        let Ok(value) = rest.split_whitespace().next().unwrap_or("").parse::<u64>() else {
376            continue;
377        };
378        match key {
379            "MemTotal" => total = value.checked_mul(1024),
380            "MemAvailable" => available = value.checked_mul(1024),
381            _ => {}
382        }
383        if total.is_some() && available.is_some() {
384            break;
385        }
386    }
387    match (total, available) {
388        // `used = total − available`, exactly like `procfs_memory_get()`:
389        // the fold below subtracts it from the limit again, so the root
390        // level nets out to `MemAvailable`.
391        (Some(total), Some(available)) => Some((total, total.saturating_sub(available))),
392        _ => None,
393    }
394}
395
396/// One level's `memory.max` / `memory.high` / `memory.current` triple.
397///
398/// The level limit is `min(max, high)` where "max" or an absent file counts
399/// as infinity: `None` when both are unlimited, in which case the level
400/// constrains nothing (though its current still feeds the fallback for
401/// levels above — see `fold_memory_available`).
402async fn read_memory_level(dir: &str) -> MemoryLevel {
403    let (max, high, mem_current) = tokio::join!(
404        async { read_file(dir, "memory.max").await },
405        async { read_file(dir, "memory.high").await },
406        async { read_file(dir, "memory.current").await },
407    );
408    let limit = match (
409        max.as_deref().and_then(parse_memory_limit),
410        high.as_deref().and_then(parse_memory_limit),
411    ) {
412        (Some(max), Some(high)) => Some(max.min(high)),
413        (Some(max), None) => Some(max),
414        (None, Some(high)) => Some(high),
415        (None, None) => None,
416    };
417    MemoryLevel {
418        limit,
419        current: mem_current.as_deref().and_then(parse_u64),
420    }
421}
422
423/// Read the unit's ancestor-slice `memory.max` / `memory.high` /
424/// `memory.current` triples for the `memory_available` fold, up to (but not
425/// past) the `fs_root`-prefixed mount root (`/system.slice/foo` ->
426/// `/system.slice`).
427///
428/// The walk stops at the prefixed root rather than continuing into host
429/// parents: a container's systemd only sees its own slice chain. systemd
430/// never consults the mount root's own files either — `-​-.slice` is
431/// special-cased to physical memory — so the cycle-shared host root level
432/// substitutes for it, which also avoids expected-miss log noise for files
433/// that never exist there. Ancestor directories are derived by popping path
434/// components, so a `..` in a hostile `cgroup_path` cannot escape the
435/// mount — it just yields nonexistent directories whose files read as
436/// absent. Levels are read sequentially (shallow tree, tiny files); the
437/// whole walk joins the unit-level batch in `read_service_cgroup`.
438async fn read_ancestor_levels(fs_root: &str, cgroup_path: &str) -> Vec<MemoryLevel> {
439    let mount = format!("{fs_root}/sys/fs/cgroup");
440    let mut levels = Vec::new();
441    let mut relative: &Path = Path::new(cgroup_path);
442    loop {
443        if relative.as_os_str() == "/" || relative.as_os_str().is_empty() {
444            break;
445        }
446        let dir = format!("{mount}{}", relative.display());
447        levels.push(read_memory_level(&dir).await);
448        match relative.parent() {
449            Some(parent) => relative = parent,
450            // `parent()` of `/` is `None`; the next loop test ends the walk.
451            _ => break,
452        }
453    }
454    levels
455}
456
457/// Count processes in a unit's cgroup, including nested ones.
458///
459/// systemd's `GetProcesses` walks the whole subtree — a service that delegates
460/// its cgroup and puts workers in children would be undercounted by reading
461/// only its own `cgroup.procs`. `extra_pids` (main + control PID) are folded
462/// in the same way systemd does, since both can sit outside the cgroup.
463/// `dir` is the already-prefixed absolute cgroup directory.
464async fn count_processes(dir: &str, extra_pids: &[u32]) -> u32 {
465    let mut pids: HashSet<u32> = HashSet::new();
466    let mut directories = vec![dir.to_string()];
467    while let Some(directory) = directories.pop() {
468        match tokio::fs::read_to_string(format!("{directory}/cgroup.procs")).await {
469            Ok(contents) => {
470                pids.extend(contents.lines().filter_map(|line| line.parse::<u32>().ok()));
471            }
472            Err(err) => debug!("Unable to read {directory}/cgroup.procs: {err:?}"),
473        }
474        let Ok(mut entries) = tokio::fs::read_dir(&directory).await else {
475            continue;
476        };
477        while let Ok(Some(entry)) = entries.next_entry().await {
478            if entry.file_type().await.is_ok_and(|kind| kind.is_dir()) {
479                directories.push(entry.path().to_string_lossy().into_owned());
480            }
481        }
482    }
483
484    pids.extend(extra_pids.iter().copied());
485
486    pids.len() as u32
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use std::io::Write;
493
494    #[test]
495    fn test_parse_cpu_stat() {
496        let contents = "usage_usec 8864\nuser_usec 1772\nsystem_usec 7091\n";
497        assert_eq!(parse_cpu_stat(contents), Some(8_864_000));
498        // Missing key is "no data", not zero.
499        assert_eq!(parse_cpu_stat("user_usec 5\n"), None);
500        assert_eq!(parse_cpu_stat(""), None);
501        // Overflow saturates via checked_mul → None rather than wrapping.
502        assert_eq!(parse_cpu_stat("usage_usec 18446744073709551615\n"), None);
503    }
504
505    #[test]
506    fn test_parse_u64() {
507        // The kernel appends a trailing newline.
508        assert_eq!(parse_u64("1368064\n"), Some(1_368_064));
509        assert_eq!(parse_u64("garbage\n"), None);
510        assert_eq!(parse_u64(""), None);
511    }
512
513    #[test]
514    fn test_parse_memory_limit() {
515        assert_eq!(parse_memory_limit("1073741824\n"), Some(1_073_741_824));
516        // "max" means unlimited: no constraint, not zero.
517        assert_eq!(parse_memory_limit("max\n"), None);
518        assert_eq!(parse_memory_limit("garbage\n"), None);
519    }
520
521    #[test]
522    fn test_parse_io_stat() {
523        // Two devices, values summed — the shape systemd parses.
524        let contents = "8:0 rbytes=2936832 wbytes=278528 rios=87 wios=68 dbytes=0 dios=0\n\
525                        252:0 rbytes=337215488 wbytes=898760704 rios=6622 wios=105892 dbytes=0 dios=0\n";
526        assert_eq!(parse_io_stat(contents), (340_152_320, 6_709));
527        // Present-but-empty (controller enabled, no IO yet) sums to zero.
528        assert_eq!(parse_io_stat(""), (0, 0));
529        // Malformed tokens are skipped, not fatal.
530        assert_eq!(parse_io_stat("8:0 rbytes=abc rios=7\n"), (0, 7));
531    }
532
533    #[test]
534    fn test_fold_memory_available_with_unit_limits() {
535        // Mirrors the live test service: MemoryMax=1G MemoryHigh=512M,
536        // current 1368064, ancestors unlimited.
537        let levels = vec![
538            MemoryLevel {
539                limit: Some(536_870_912),
540                current: Some(1_368_064),
541            },
542            MemoryLevel {
543                limit: None,
544                current: Some(2_183_753_728),
545            },
546        ];
547        // Host root binds far above the unit limit, so the unit wins.
548        // (MemTotal, used = MemTotal − MemAvailable.)
549        let root = (12_271_919_104, 8_474_013_504);
550        assert_eq!(
551            fold_memory_available(&levels, root, Some(1_368_064)),
552            Some(536_870_912 - 1_368_064)
553        );
554    }
555
556    #[test]
557    fn test_fold_memory_available_no_limits_uses_host_available() {
558        // No limits anywhere: the root level nets out to MemAvailable
559        // (MemTotal − used), exactly what D-Bus reports for a limitless
560        // service.
561        let levels = vec![
562            MemoryLevel {
563                limit: None,
564                current: Some(4_227_072),
565            },
566            MemoryLevel {
567                limit: None,
568                current: Some(2_183_753_728),
569            },
570        ];
571        let root = (12_271_919_104, 3_761_983_488);
572        assert_eq!(
573            fold_memory_available(&levels, root, Some(4_227_072)),
574            Some(8_509_935_616)
575        );
576    }
577
578    #[test]
579    fn test_fold_memory_available_takes_minimum_over_slices() {
580        // A tighter ancestor slice wins over a looser unit limit.
581        let levels = vec![
582            MemoryLevel {
583                limit: Some(1_073_741_824),
584                current: Some(1_000_000),
585            },
586            MemoryLevel {
587                limit: Some(100_000_000),
588                current: Some(90_000_000),
589            },
590        ];
591        let root = (12_271_919_104, 3_761_983_488);
592        assert_eq!(
593            fold_memory_available(&levels, root, Some(1_000_000)),
594            Some(10_000_000)
595        );
596    }
597
598    #[test]
599    fn test_fold_memory_available_saturates_rather_than_underflows() {
600        // Over-limit cgroup: limit − current saturates at 0 (LESS_BY).
601        let levels = vec![MemoryLevel {
602            limit: Some(1_000),
603            current: Some(2_000),
604        }];
605        let root = (12_271_919_104, 3_761_983_488);
606        assert_eq!(fold_memory_available(&levels, root, Some(2_000)), Some(0));
607    }
608
609    /// Build a fake cgroup tree under a temp dir shaped like
610    /// `$root/sys/fs/cgroup/system.slice/<unit>/…` and read it back through
611    /// `read_service_cgroup`, proving the whole reader end to end without
612    /// systemd.
613    async fn fake_tree() -> (tempfile::TempDir, String) {
614        let tmp = tempfile::tempdir().expect("temp dir");
615        let root = tmp.path().to_str().unwrap().to_string();
616        let unit = format!("{root}/sys/fs/cgroup/system.slice/fake.service");
617        let child = format!("{unit}/worker");
618        std::fs::create_dir_all(&child).unwrap();
619        let write = |path: &str, contents: &str| {
620            let mut file = std::fs::File::create(path).unwrap();
621            file.write_all(contents.as_bytes()).unwrap();
622        };
623        write(
624            &format!("{unit}/cpu.stat"),
625            "usage_usec 8864\nuser_usec 1772\n",
626        );
627        write(&format!("{unit}/memory.current"), "1368064\n");
628        write(&format!("{unit}/memory.max"), "1073741824\n");
629        write(&format!("{unit}/memory.high"), "536870912\n");
630        write(&format!("{unit}/pids.current"), "2\n");
631        write(
632            &format!("{unit}/io.stat"),
633            "8:0 rbytes=100 wbytes=0 rios=3 wios=0\n",
634        );
635        write(&format!("{unit}/cgroup.procs"), "100\n101\n");
636        // Nested child, as with delegated workers.
637        write(&format!("{child}/cgroup.procs"), "102\n");
638        // Ancestor slice with no limits of its own.
639        let slice = format!("{root}/sys/fs/cgroup/system.slice");
640        write(&format!("{slice}/memory.max"), "max\n");
641        write(&format!("{slice}/memory.high"), "max\n");
642        write(&format!("{slice}/memory.current"), "999999\n");
643        (tmp, root)
644    }
645
646    #[test]
647    fn test_fold_memory_available_prefers_fresh_unit_current() {
648        // The join!-batch read (fresher) wins over the walk's level-0 copy.
649        let levels = vec![MemoryLevel {
650            limit: Some(536_870_912),
651            current: Some(1_000_000),
652        }];
653        let root = (12_271_919_104, 3_761_983_488);
654        assert_eq!(
655            fold_memory_available(&levels, root, Some(1_368_064)),
656            Some(536_870_912 - 1_368_064)
657        );
658        // …while a missing batch read falls back to the walk's copy.
659        assert_eq!(
660            fold_memory_available(&levels, root, None),
661            Some(536_870_912 - 1_000_000)
662        );
663    }
664
665    #[test]
666    fn test_fold_memory_available_unreadable_current_constrains_in_full() {
667        // systemd initialises `current = 0`: a limit with no readable
668        // current anywhere constrains by the full limit (LESS_BY(limit, 0)),
669        // rather than the level being skipped.
670        let levels = vec![MemoryLevel {
671            limit: Some(100_000_000),
672            current: None,
673        }];
674        // The host root (netting to 8_509_935_616) binds above the unit
675        // limit, so the unit's full limit wins.
676        let root = (12_271_919_104, 3_761_983_488);
677        assert_eq!(
678            fold_memory_available(&levels, root, None),
679            Some(100_000_000)
680        );
681    }
682
683    #[tokio::test]
684    async fn test_read_service_cgroup_from_fake_tree() {
685        let (_tmp, root) = fake_tree().await;
686        // `Some` host memory so the root level binds above the unit limit.
687        let host = read_host_memory().await;
688        assert!(host.is_some());
689        let stats = read_service_cgroup(&root, "/system.slice/fake.service", &[103], host).await;
690        assert_eq!(stats.cpu_usage_nsec, Some(8_864_000));
691        assert_eq!(stats.memory_current, Some(1_368_064));
692        assert_eq!(stats.tasks_current, Some(2));
693        assert_eq!(stats.io_read_bytes, Some(100));
694        assert_eq!(stats.io_read_operations, Some(3));
695        // cgroup.procs across the subtree (100, 101, 102) + folded-in main
696        // PID 103, which sits outside the cgroup.
697        assert_eq!(stats.processes, 4);
698        // Unit limit binds: 512M − current; the host root level is above it.
699        assert_eq!(stats.memory_available, Some(536_870_912 - 1_368_064));
700    }
701
702    #[tokio::test]
703    async fn test_read_service_cgroup_missing_dir() {
704        let tmp = tempfile::tempdir().expect("temp dir");
705        let root = tmp.path().to_str().unwrap().to_string();
706        std::fs::create_dir_all(format!("{root}/sys/fs/cgroup")).unwrap();
707        // No cgroup at all, and no host memory either: every gauge is "no
708        // data" — including memory_available, whose host-MemAvailable root
709        // level must not bind without a readable unit level (cgroup v1 host,
710        // stopped unit: the caller's IPC fallback takes over instead).
711        let stats = read_service_cgroup(&root, "/system.slice/gone.service", &[], None).await;
712        assert_eq!(stats, CgroupStats::default());
713    }
714
715    #[tokio::test]
716    async fn test_read_service_cgroup_empty_path_reads_nothing() {
717        let (_tmp, root) = fake_tree().await;
718        // An inactive unit has no cgroup: default (all-None, 0 processes)
719        // without touching the filesystem — in particular the walk must not
720        // start at the mount root and count every PID on the box.
721        let stats = read_service_cgroup(&root, "", &[], Some((1, 0))).await;
722        assert_eq!(stats, CgroupStats::default());
723        let stats = read_service_cgroup(&root, "/", &[], Some((1, 0))).await;
724        assert_eq!(stats, CgroupStats::default());
725    }
726
727    #[tokio::test]
728    async fn test_read_service_cgroup_folds_in_control_pid() {
729        let (_tmp, root) = fake_tree().await;
730        // cgroup.procs across the subtree (100, 101, 102) + main PID 103 +
731        // control PID 104, both sitting outside the cgroup — the way
732        // GetProcesses counts them during ExecReload/ExecStop.
733        let stats =
734            read_service_cgroup(&root, "/system.slice/fake.service", &[103, 104], None).await;
735        assert_eq!(stats.processes, 5);
736    }
737
738    #[tokio::test]
739    async fn test_read_service_cgroup_absent_io_file_is_none() {
740        let (_tmp, root) = fake_tree().await;
741        std::fs::remove_file(format!(
742            "{root}/sys/fs/cgroup/system.slice/fake.service/io.stat"
743        ))
744        .unwrap();
745        let stats = read_service_cgroup(&root, "/system.slice/fake.service", &[], None).await;
746        // Absent file (controller not enabled) is "no data", distinct from
747        // the present-but-empty zero.
748        assert_eq!(stats.io_read_bytes, None);
749        assert_eq!(stats.io_read_operations, None);
750        // …while the other fields still read fine.
751        assert_eq!(stats.cpu_usage_nsec, Some(8_864_000));
752    }
753}