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}