agentd/supervisor/cgroup.rs
1// SPDX-License-Identifier: Apache-2.0
2//! cgroup v2 memory awareness (read-only). Assessment §4 M5.
3//!
4//! Best-effort, **never required**: a cloud-native unit reports the memory
5//! budget its scheduler handed it so OOM risk is observable (logged at startup,
6//! and exposed as a `/metrics` gauge). Reads the unified cgroup v2 interface
7//! files directly under `/sys/fs/cgroup`; in a container with a cgroup
8//! namespace (the target
9//! shape) that path is the unit's *own* cgroup, so the direct read is correct.
10//! On a bare host it reflects the root cgroup (whole-host) — still informative.
11//! Any missing file / cgroup v1 / parse failure degrades to `None`.
12//!
13//! ## Active enforcement (best-effort, opt-in, never required)
14//!
15//! On top of the reads, when the operator opts in (`--cgroup auto|<path>` /
16//! `AGENTD_CGROUP`) and the cgroup-v2 tree is writable, each supervised run is
17//! placed in its own child cgroup so teardown can write **`cgroup.kill`** — the
18//! kernel then SIGKILLs the *entire* subtree atomically, catching processes that
19//! escaped the process group (`setsid`) which `killpg` + `PR_SET_PDEATHSIG`
20//! would miss (assessment §2.3 risk #3, the worst leak). And [`under_memory_pressure`]
21//! lets the spawn-admission gates backpressure when the unit is at its
22//! `memory.high` soft limit. Every cgroup op is best-effort: if the tree isn't
23//! writable (no delegation, cgroup-v1, off-cgroup) the feature silently disables
24//! and the run falls back to the PDEATHSIG + kill-ladder path — agentd stays
25//! cgroup-*aware*, never cgroup-*requiring*.
26//!
27//! Note: hard resource *limits* on the child (`memory.max`/`pids.max`) need the
28//! parent to delegate controllers via `cgroup.subtree_control`, which fails
29//! (`EBUSY`) whenever the parent cgroup holds processes directly — common for a
30//! systemd unit without `Delegate=yes`. So limit-setting stays a deployment
31//! concern (size the pod's `resources.limits`); agentd adds the atomic teardown
32//! backstop and the soft-pressure backpressure, which need no delegation.
33
34use std::path::{Path, PathBuf};
35use std::sync::OnceLock;
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::time::Duration;
38
39const CGROUP_ROOT: &str = "/sys/fs/cgroup";
40
41/// Backpressure new spawns once usage reaches this percentage of `memory.high`.
42const MEMORY_HIGH_BACKPRESSURE_PCT: u64 = 95;
43
44/// The resolved parent cgroup directory under which per-run child cgroups are
45/// created, set once at startup by [`configure`]. `Some(None)` = configured but
46/// the tree isn't writable (feature disabled); unset / `Some(None)` both mean
47/// [`CgroupGuard::for_run`] yields `None` and runs fall back to PDEATHSIG.
48static PARENT: OnceLock<Option<PathBuf>> = OnceLock::new();
49
50/// Per-run child-cgroup name counter (unique within this process).
51static RUN_SEQ: AtomicU64 = AtomicU64::new(0);
52
53/// Hard resource limits to write onto each per-run leaf cgroup, set once at
54/// startup by [`configure`]. Empty (the default) → leaves get no limits, only the
55/// `cgroup.kill` teardown backstop. Applying limits needs the parent to delegate
56/// the `memory`/`pids` controllers (see [`enable_controllers`]); where it can't
57/// (e.g. `--cgroup auto` under a busy unit cgroup → `EBUSY`) the writes no-op and
58/// the run still gets atomic teardown.
59static LIMITS: OnceLock<Limits> = OnceLock::new();
60
61/// Normalised hard limits for a per-run leaf cgroup. Each field, when set, is the
62/// exact string written to the corresponding cgroup-v2 interface file.
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct Limits {
65 /// Value for `memory.max` (`"max"` or a byte count).
66 pub memory_max: Option<String>,
67 /// Value for `pids.max` (`"max"` or a count).
68 pub pids_max: Option<String>,
69}
70
71impl Limits {
72 /// Build from raw `--cgroup-memory-max` / `--cgroup-pids-max` specs; an
73 /// unparseable spec is dropped (the limit just isn't applied).
74 pub fn from_specs(memory_max: Option<&str>, pids_max: Option<&str>) -> Limits {
75 Limits {
76 memory_max: memory_max.and_then(normalize_bytes),
77 pids_max: pids_max.and_then(normalize_count),
78 }
79 }
80
81 /// No limits requested → skip controller delegation + per-leaf writes entirely.
82 pub fn is_empty(&self) -> bool {
83 self.memory_max.is_none() && self.pids_max.is_none()
84 }
85}
86
87/// Normalise a memory-size spec to the bytes string `memory.max` expects: `"max"`
88/// (unlimited), a plain byte count, or a `K`/`M`/`G`-suffixed (1024-based) size.
89/// `None` for anything unparseable.
90fn normalize_bytes(s: &str) -> Option<String> {
91 let s = s.trim();
92 if s.eq_ignore_ascii_case("max") {
93 return Some("max".to_string());
94 }
95 let (digits, mult): (&str, u64) = match s.chars().last() {
96 Some(c) if c.is_ascii_digit() => (s, 1),
97 Some('K' | 'k') => (&s[..s.len() - 1], 1024),
98 Some('M' | 'm') => (&s[..s.len() - 1], 1024 * 1024),
99 Some('G' | 'g') => (&s[..s.len() - 1], 1024 * 1024 * 1024),
100 _ => return None,
101 };
102 let n: u64 = digits.trim().parse().ok()?;
103 n.checked_mul(mult).map(|b| b.to_string())
104}
105
106/// Normalise a pid-count spec for `pids.max`: `"max"` or a non-negative integer.
107fn normalize_count(s: &str) -> Option<String> {
108 let s = s.trim();
109 if s.eq_ignore_ascii_case("max") {
110 return Some("max".to_string());
111 }
112 s.parse::<u64>().ok().map(|n| n.to_string())
113}
114
115/// A point-in-time view of the unit's cgroup v2 memory interface.
116#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
117pub struct MemorySnapshot {
118 /// Hard limit (`memory.max`); `None` = unlimited (`"max"`) or unavailable.
119 pub max: Option<u64>,
120 /// Current charged usage (`memory.current`), in bytes.
121 pub current: Option<u64>,
122 /// Soft throttle threshold (`memory.high`); `None` = unset or unavailable.
123 pub high: Option<u64>,
124}
125
126impl MemorySnapshot {
127 /// Whether any cgroup v2 memory file was readable (i.e. we are cgroup-aware).
128 pub fn detected(&self) -> bool {
129 self.max.is_some() || self.current.is_some() || self.high.is_some()
130 }
131}
132
133/// Read the current cgroup v2 memory snapshot (best-effort; never fails).
134pub fn snapshot() -> MemorySnapshot {
135 MemorySnapshot {
136 max: memory_max(),
137 current: memory_current(),
138 high: memory_high(),
139 }
140}
141
142/// `memory.max` — the hard limit; `None` when unlimited (`"max"`) or unreadable.
143pub fn memory_max() -> Option<u64> {
144 read_mem(&Path::new(CGROUP_ROOT).join("memory.max"))
145}
146
147/// `memory.current` — current charged usage in bytes.
148pub fn memory_current() -> Option<u64> {
149 read_mem(&Path::new(CGROUP_ROOT).join("memory.current"))
150}
151
152/// `memory.high` — the soft (throttling) limit; `None` when unset (`"max"`).
153pub fn memory_high() -> Option<u64> {
154 read_mem(&Path::new(CGROUP_ROOT).join("memory.high"))
155}
156
157/// Read + parse one cgroup memory file. `None` on any I/O error or `"max"`.
158fn read_mem(path: &Path) -> Option<u64> {
159 std::fs::read_to_string(path)
160 .ok()
161 .and_then(|s| parse_mem(&s))
162}
163
164/// Parse a cgroup v2 memory value: a byte count, or `"max"` (unlimited → `None`).
165fn parse_mem(s: &str) -> Option<u64> {
166 match s.trim() {
167 "max" => None,
168 t => t.parse::<u64>().ok(),
169 }
170}
171
172// ---------------------------------------------------------------------------
173// Active enforcement: child-cgroup placement + `cgroup.kill` teardown backstop.
174// ---------------------------------------------------------------------------
175
176/// What [`configure`] settled on: the resolved parent dir (if armed) and whether
177/// requested hard limits will actually be enforced (controllers delegated).
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct Configured {
180 /// The parent dir under which per-run leaves are created (feature is armed).
181 pub parent: PathBuf,
182 /// Limits that will be applied to each leaf (empty if none requested).
183 pub limits: Limits,
184 /// True when limits were requested but the controllers could **not** be
185 /// delegated (so the writes will no-op) — the caller should warn.
186 pub limits_unavailable: bool,
187}
188
189/// Resolve + probe the `--cgroup` spec ONCE at startup, arming per-run child
190/// cgroups. `spec` is `"auto"` (derive `<own-cgroup>/agentd` from
191/// `/proc/self/cgroup`) or an absolute path under `/sys/fs/cgroup`. Optional
192/// `memory_max`/`pids_max` specs request hard limits on each run's leaf; this
193/// best-effort delegates the controllers to the parent so the limits can take
194/// effect. Returns `None` when off / not writable (the feature stays dormant).
195/// Idempotent — the first call wins.
196pub fn configure(
197 spec: Option<&str>,
198 memory_max: Option<&str>,
199 pids_max: Option<&str>,
200) -> Option<Configured> {
201 let resolved = spec.and_then(resolve_parent).filter(|p| ensure_writable(p));
202 let limits = Limits::from_specs(memory_max, pids_max);
203 let mut limits_unavailable = false;
204 if let Some(p) = &resolved {
205 // Reclaim any `run-*` cgroups orphaned by prior crashed/abandoned runs (a
206 // wedged D-state task can outlive its guard's Drop), so a long-lived
207 // daemon can't slowly accumulate stale child cgroups across restarts.
208 sweep_stale(p);
209 // Delegate the controllers the requested limits need to the parent, so
210 // the per-run leaves get enforceable `memory.max`/`pids.max`. Fails
211 // (EBUSY) where the parent holds processes directly (e.g. `auto` under a
212 // busy unit cgroup) — limits then no-op, but teardown still works.
213 if !limits.is_empty() {
214 limits_unavailable = !enable_controllers(p, &limits);
215 }
216 }
217 // OnceLock::set fails only if already set (first call wins). Read back the
218 // stored values so the caller's log never disagrees with what governs runs.
219 let _ = PARENT.set(resolved);
220 let _ = LIMITS.set(limits);
221 let parent = PARENT.get().cloned().flatten()?;
222 Some(Configured {
223 parent,
224 limits: LIMITS.get().cloned().unwrap_or_default(),
225 limits_unavailable,
226 })
227}
228
229/// Delegate the `memory`/`pids` controllers (only those the requested limits
230/// need) to `parent` via its `cgroup.subtree_control`, so child leaves expose the
231/// matching interface files. Each controller is delegated with its OWN write: a
232/// `subtree_control` write is atomic, so a combined `+memory +pids` would fail
233/// wholesale when the parent can delegate only one (e.g. a `Delegate=pids` unit),
234/// needlessly dropping the achievable limit. Best-effort → returns whether
235/// **every requested** controller was delegated.
236fn enable_controllers(parent: &Path, limits: &Limits) -> bool {
237 let file = parent.join("cgroup.subtree_control");
238 let mut all_ok = true;
239 if limits.memory_max.is_some() {
240 all_ok &= write_cgroup(&file, "+memory");
241 }
242 if limits.pids_max.is_some() {
243 all_ok &= write_cgroup(&file, "+pids");
244 }
245 all_ok
246}
247
248/// Best-effort reclaim of stale per-run child cgroups under `parent`. Removes a
249/// `run-<pid>-*` cgroup only when its owning pid is **dead** (or is our own,
250/// freshly-reused pid) — so a concurrent sibling agentd sharing this parent is
251/// never torn down. `.probe-*` leftovers (from a crashed `ensure_writable`) are
252/// always reclaimed.
253fn sweep_stale(parent: &Path) {
254 let Ok(entries) = std::fs::read_dir(parent) else {
255 return;
256 };
257 let me = std::process::id();
258 for entry in entries.flatten() {
259 if stale_run(&entry.file_name().to_string_lossy(), me) {
260 let dir = entry.path();
261 let _ = std::fs::write(dir.join("cgroup.kill"), "1");
262 let _ = std::fs::remove_dir(&dir);
263 }
264 }
265}
266
267/// Whether a child-cgroup dir name denotes a reclaimable stale run: a
268/// `run-<pid>-*` whose pid is dead or our own (freshly-reused — we've made no run
269/// cgroups yet) pid, or a `.probe-*` leftover from a crashed `ensure_writable`. A
270/// live sibling's `run-*` (and any other name) is spared.
271fn stale_run(name: &str, me: u32) -> bool {
272 if let Some(rest) = name.strip_prefix("run-") {
273 match rest.split('-').next().and_then(|p| p.parse::<u32>().ok()) {
274 Some(pid) => pid == me || !pid_alive(pid),
275 None => false,
276 }
277 } else {
278 name.starts_with(".probe-")
279 }
280}
281
282/// Whether `pid` is a live process. `kill(pid, 0)` → `Ok`/`EPERM` = alive,
283/// `ESRCH` = dead. Conservative: an unrelated process that reused the pid reads
284/// as alive, so we skip (delay reclaim) rather than risk touching its cgroup.
285fn pid_alive(pid: u32) -> bool {
286 let rc = unsafe { libc::kill(pid as i32, 0) };
287 rc == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
288}
289
290/// Resolve a `--cgroup` spec to an absolute parent directory (no I/O beyond
291/// reading `/proc/self/cgroup` for `auto`). `None` for an unusable spec.
292fn resolve_parent(spec: &str) -> Option<PathBuf> {
293 match spec.trim() {
294 "" => None,
295 "auto" => own_cgroup_dir().map(|d| d.join("agentd")),
296 // An explicit path must sit under the cgroup-v2 mount, by path COMPONENT
297 // (so `/sys/fs/cgroup-sibling` can't slip past a byte-prefix), with no
298 // `..`. A guard-rail, not a security boundary — `--cgroup` is operator-
299 // supplied and the operator already controls the process; a symlink
300 // component could still redirect, which the trust model accepts.
301 p if Path::new(p).is_absolute()
302 && Path::new(p).starts_with(CGROUP_ROOT)
303 && !p.contains("..") =>
304 {
305 Some(PathBuf::from(p))
306 }
307 _ => None,
308 }
309}
310
311/// The unit's own cgroup directory, from the `0::<path>` line of
312/// `/proc/self/cgroup` (cgroup-v2 unified hierarchy). `None` off cgroup-v2.
313fn own_cgroup_dir() -> Option<PathBuf> {
314 let content = std::fs::read_to_string("/proc/self/cgroup").ok()?;
315 let rel = content.lines().find_map(|l| l.strip_prefix("0::"))?.trim();
316 Some(Path::new(CGROUP_ROOT).join(rel.trim_start_matches('/')))
317}
318
319/// Probe that we can actually create + remove a child cgroup under `parent`
320/// (so the feature only arms where it works). Creates `parent` if needed.
321fn ensure_writable(parent: &Path) -> bool {
322 if std::fs::create_dir_all(parent).is_err() {
323 return false;
324 }
325 let probe = parent.join(format!(".probe-{}", std::process::id()));
326 match std::fs::create_dir(&probe) {
327 Ok(()) => {
328 let _ = std::fs::remove_dir(&probe);
329 true
330 }
331 Err(_) => false,
332 }
333}
334
335/// Whether the unit is at/over the backpressure fraction of its `memory.high`
336/// soft limit — a signal for the spawn-admission gates to refuse new subagents
337/// rather than push the cgroup into reclaim/OOM. Reads live each call; `false`
338/// when no cgroup / no `memory.high` set (can't tell → don't block).
339pub fn under_memory_pressure() -> bool {
340 // Read only the two files the predicate needs (not the full snapshot's three).
341 over_threshold(memory_current(), memory_high())
342}
343
344/// Pure predicate behind [`under_memory_pressure`] (testable without a cgroup).
345fn over_threshold(current: Option<u64>, high: Option<u64>) -> bool {
346 match (current, high) {
347 (Some(cur), Some(high)) if high > 0 => {
348 cur.saturating_mul(100) >= high.saturating_mul(MEMORY_HIGH_BACKPRESSURE_PCT)
349 }
350 _ => false,
351 }
352}
353
354/// A per-run child cgroup. Placing the root subagent here puts its whole subtree
355/// in the cgroup (membership inherits across `fork`), so [`kill_all`] tears the
356/// entire subtree down atomically. RAII: `Drop` kills + removes the cgroup.
357///
358/// [`kill_all`]: CgroupGuard::kill_all
359pub struct CgroupGuard {
360 dir: PathBuf,
361}
362
363impl CgroupGuard {
364 /// Create the per-run child cgroup under the configured parent, applying the
365 /// configured hard limits, or `None` when the feature is off / creation fails
366 /// (best-effort, never an error).
367 pub fn for_run() -> Option<CgroupGuard> {
368 let parent = PARENT.get().and_then(|o| o.clone())?;
369 let name = format!(
370 "run-{}-{}",
371 std::process::id(),
372 RUN_SEQ.fetch_add(1, Ordering::Relaxed)
373 );
374 let guard = Self::create(&parent, &name)?;
375 if let Some(limits) = LIMITS.get() {
376 guard.apply_limits(limits);
377 }
378 Some(guard)
379 }
380
381 /// Create a child cgroup `parent/name` (best-effort). Shared by `for_run`
382 /// and tests (which resolve a parent directly, bypassing the global).
383 fn create(parent: &Path, name: &str) -> Option<CgroupGuard> {
384 let dir = parent.join(name);
385 std::fs::create_dir_all(&dir).ok()?;
386 Some(CgroupGuard { dir })
387 }
388
389 /// Write the configured hard limits onto this leaf's `memory.max`/`pids.max`.
390 /// Best-effort: a write no-ops where the controller wasn't delegated (the
391 /// interface file is absent / read-only), so the run keeps `cgroup.kill`
392 /// teardown without the limit. Returns `(memory_ok, pids_ok)` for logging.
393 pub fn apply_limits(&self, limits: &Limits) -> (bool, bool) {
394 let memory_ok = match &limits.memory_max {
395 Some(v) => write_cgroup(&self.dir.join("memory.max"), v),
396 None => false,
397 };
398 let pids_ok = match &limits.pids_max {
399 Some(v) => write_cgroup(&self.dir.join("pids.max"), v),
400 None => false,
401 };
402 (memory_ok, pids_ok)
403 }
404
405 /// Move `pid` (and, by inheritance, its future descendants) into this
406 /// cgroup by writing its `cgroup.procs`. Best-effort → returns success.
407 pub fn place(&self, pid: i32) -> bool {
408 write_cgroup(&self.dir.join("cgroup.procs"), &pid.to_string())
409 }
410
411 /// Atomically SIGKILL every process in the subtree via `cgroup.kill` — the
412 /// backstop beyond `killpg`/PDEATHSIG. Best-effort → returns success.
413 pub fn kill_all(&self) -> bool {
414 write_cgroup(&self.dir.join("cgroup.kill"), "1")
415 }
416
417 /// The cgroup directory (for logging).
418 pub fn path(&self) -> &Path {
419 &self.dir
420 }
421
422 /// The `oom_kill` count from this leaf's `memory.events` — how many of its
423 /// processes the kernel OOM-killed (i.e. hit `memory.max`). `None` when the
424 /// memory controller isn't active (no limit / not delegated). Lets the
425 /// supervisor report a `memory.max` kill plainly instead of as a generic exit.
426 pub fn oom_kills(&self) -> Option<u64> {
427 parse_oom_kills(&std::fs::read_to_string(self.dir.join("memory.events")).ok()?)
428 }
429
430 /// Remove the cgroup dir; succeeds only once every member process has been
431 /// reaped (the kernel frees the cgroup then).
432 fn try_remove(&self) -> bool {
433 std::fs::remove_dir(&self.dir).is_ok()
434 }
435}
436
437impl Drop for CgroupGuard {
438 fn drop(&mut self) {
439 // Final backstop: SIGKILL anything left, then remove the (now-freeing)
440 // cgroup with a few short retries while the kernel reaps its members. A
441 // lingering empty dir is harmless, so this never blocks for long.
442 self.kill_all();
443 for _ in 0..5 {
444 if self.try_remove() {
445 return;
446 }
447 std::thread::sleep(Duration::from_millis(10));
448 }
449 }
450}
451
452/// Write a value to a cgroup control file. Best-effort: any error (no such
453/// controller, EACCES, EBUSY, off-cgroup) is swallowed → returns success.
454fn write_cgroup(path: &Path, value: &str) -> bool {
455 std::fs::write(path, value).is_ok()
456}
457
458/// Parse the `oom_kill` counter from a `memory.events` body (one `key value`
459/// pair per line). `None` when the key is absent.
460fn parse_oom_kills(events: &str) -> Option<u64> {
461 events
462 .lines()
463 .find_map(|l| l.strip_prefix("oom_kill "))?
464 .trim()
465 .parse()
466 .ok()
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472 use std::io::Write;
473
474 #[test]
475 fn parse_mem_handles_max_and_numbers() {
476 assert_eq!(parse_mem("max\n"), None); // unlimited
477 assert_eq!(parse_mem("max"), None);
478 assert_eq!(parse_mem("1073741824\n"), Some(1_073_741_824));
479 assert_eq!(parse_mem("0"), Some(0));
480 assert_eq!(parse_mem("garbage"), None);
481 assert_eq!(parse_mem(""), None);
482 }
483
484 #[test]
485 fn read_mem_reads_a_fixture_or_degrades_to_none() {
486 let mut f = tempfile::NamedTempFile::new().unwrap();
487 writeln!(f, "536870912").unwrap();
488 assert_eq!(read_mem(f.path()), Some(536_870_912));
489
490 let mut unlimited = tempfile::NamedTempFile::new().unwrap();
491 writeln!(unlimited, "max").unwrap();
492 assert_eq!(read_mem(unlimited.path()), None);
493
494 // missing file → None, never an error
495 assert_eq!(read_mem(Path::new("/nonexistent/agentd/memory.max")), None);
496 }
497
498 #[test]
499 fn snapshot_detected_reflects_any_readable_field() {
500 assert!(!MemorySnapshot::default().detected());
501 assert!(
502 MemorySnapshot {
503 max: Some(1),
504 ..Default::default()
505 }
506 .detected()
507 );
508 }
509
510 #[test]
511 fn over_threshold_backpressures_at_95_percent_of_high() {
512 // No high set, or no current → can't tell → never backpressure.
513 assert!(!over_threshold(Some(1_000), None));
514 assert!(!over_threshold(None, Some(1_000)));
515 assert!(!over_threshold(None, None));
516 assert!(!over_threshold(Some(1_000), Some(0))); // high==0 → ignore
517 // Below the fraction → allow; at/above → backpressure.
518 assert!(!over_threshold(Some(900), Some(1_000))); // 90% < 95%
519 assert!(over_threshold(Some(950), Some(1_000))); // exactly 95%
520 assert!(over_threshold(Some(1_000), Some(1_000))); // at high
521 assert!(over_threshold(Some(2_000), Some(1_000))); // over high
522 }
523
524 #[test]
525 fn normalize_bytes_handles_suffixes_max_and_garbage() {
526 assert_eq!(normalize_bytes("max").as_deref(), Some("max"));
527 assert_eq!(normalize_bytes("MAX").as_deref(), Some("max"));
528 assert_eq!(normalize_bytes("1048576").as_deref(), Some("1048576"));
529 assert_eq!(
530 normalize_bytes("512M").as_deref(),
531 Some((512 * 1024 * 1024).to_string().as_str())
532 );
533 assert_eq!(
534 normalize_bytes("2G").as_deref(),
535 Some((2u64 * 1024 * 1024 * 1024).to_string().as_str())
536 );
537 assert_eq!(
538 normalize_bytes("64k").as_deref(),
539 Some((64 * 1024).to_string().as_str())
540 );
541 assert_eq!(normalize_bytes(""), None);
542 assert_eq!(normalize_bytes("M"), None); // no digits
543 assert_eq!(normalize_bytes("12T"), None); // unsupported suffix
544 assert_eq!(normalize_bytes("abc"), None);
545 }
546
547 #[test]
548 fn normalize_count_handles_max_and_integers() {
549 assert_eq!(normalize_count("max").as_deref(), Some("max"));
550 assert_eq!(normalize_count("128").as_deref(), Some("128"));
551 assert_eq!(normalize_count("0").as_deref(), Some("0"));
552 assert_eq!(normalize_count(""), None);
553 assert_eq!(normalize_count("-1"), None);
554 assert_eq!(normalize_count("lots"), None);
555 }
556
557 #[test]
558 fn parse_oom_kills_reads_the_counter() {
559 let events = "low 0\nhigh 0\nmax 3\noom 1\noom_kill 2\noom_group_kill 0\n";
560 assert_eq!(parse_oom_kills(events), Some(2));
561 assert_eq!(parse_oom_kills("oom_kill 0\n"), Some(0));
562 assert_eq!(parse_oom_kills("low 0\nhigh 0\n"), None); // key absent
563 assert_eq!(parse_oom_kills(""), None);
564 }
565
566 #[test]
567 fn limits_from_specs_drops_unparseable() {
568 let l = Limits::from_specs(Some("256M"), Some("32"));
569 assert_eq!(
570 l.memory_max.as_deref(),
571 Some((256 * 1024 * 1024).to_string().as_str())
572 );
573 assert_eq!(l.pids_max.as_deref(), Some("32"));
574 assert!(!l.is_empty());
575 assert!(Limits::from_specs(None, None).is_empty());
576 assert!(Limits::from_specs(Some("nonsense"), None).is_empty()); // dropped
577 }
578
579 /// Live proof that hard limits are both applied and enforced: build a manager
580 /// cgroup that delegates the pids controller, create a leaf via the real
581 /// `CgroupGuard` path, set `pids.max=1`, and confirm a process inside the leaf
582 /// cannot `fork` (kernel `EAGAIN`). Skips cleanly where controller delegation
583 /// isn't available (limits are best-effort).
584 #[test]
585 fn limits_are_applied_and_pids_max_is_enforced() {
586 let mgr = Path::new(CGROUP_ROOT).join(format!("agentd-test-limits-{}", std::process::id()));
587 if std::fs::create_dir(&mgr).is_err() {
588 eprintln!("skip: cannot create a cgroup under {CGROUP_ROOT}");
589 return;
590 }
591 struct Cleanup(PathBuf);
592 impl Drop for Cleanup {
593 fn drop(&mut self) {
594 let _ = std::fs::write(self.0.join("cgroup.kill"), "1");
595 let _ = std::fs::remove_dir(&self.0);
596 }
597 }
598 let _mgr_cleanup = Cleanup(mgr.clone());
599
600 let limits = Limits::from_specs(Some("32M"), Some("1"));
601 if !enable_controllers(&mgr, &limits) {
602 eprintln!("skip: parent cannot delegate memory/pids controllers");
603 return;
604 }
605 let guard = CgroupGuard::create(&mgr, "leaf").expect("create leaf cgroup");
606 let (mem_ok, pids_ok) = guard.apply_limits(&limits);
607 assert!(pids_ok, "pids.max applied");
608 assert_eq!(
609 std::fs::read_to_string(guard.dir.join("pids.max"))
610 .unwrap()
611 .trim(),
612 "1"
613 );
614 if mem_ok {
615 assert_eq!(
616 std::fs::read_to_string(guard.dir.join("memory.max"))
617 .unwrap()
618 .trim(),
619 (32 * 1024 * 1024).to_string()
620 );
621 }
622
623 // Functional enforcement: a process migrated INTO the leaf is the 1 task
624 // pids.max=1 allows, so its own `fork` must be refused. Sync via a pipe so
625 // the probe only forks after it has been placed in the cgroup.
626 let mut fds = [0i32; 2];
627 assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe");
628 let (rfd, wfd) = (fds[0], fds[1]);
629 let pid = unsafe { libc::fork() };
630 assert!(pid >= 0, "fork probe");
631 if pid == 0 {
632 // Child: async-signal-safe calls only.
633 unsafe {
634 libc::close(wfd);
635 let mut b = [0u8; 1];
636 libc::read(rfd, b.as_mut_ptr() as *mut libc::c_void, 1); // wait until placed
637 let g = libc::fork();
638 if g == 0 {
639 libc::_exit(0); // grandchild (only reached if enforcement failed)
640 }
641 if g < 0 {
642 libc::_exit(0); // EXPECTED: fork refused with EAGAIN
643 }
644 let mut s = 0;
645 libc::waitpid(g, &mut s, 0);
646 libc::_exit(1); // fork unexpectedly succeeded
647 }
648 }
649 // SIGKILL + reap the probe even if an assertion below panics first, so a
650 // failing run never leaks the blocked-on-read child / a busy leaf cgroup.
651 struct ProbeGuard(Option<i32>);
652 impl Drop for ProbeGuard {
653 fn drop(&mut self) {
654 if let Some(pid) = self.0 {
655 unsafe {
656 libc::kill(pid, libc::SIGKILL);
657 let mut s = 0;
658 libc::waitpid(pid, &mut s, 0);
659 }
660 }
661 }
662 }
663 let mut probe = ProbeGuard(Some(pid));
664
665 unsafe { libc::close(rfd) };
666 assert!(guard.place(pid), "migrate the probe into the leaf");
667 unsafe {
668 libc::write(wfd, b"x".as_ptr() as *const libc::c_void, 1);
669 libc::close(wfd);
670 }
671 let mut status = 0;
672 assert_eq!(
673 unsafe { libc::waitpid(pid, &mut status, 0) },
674 pid,
675 "reap probe"
676 );
677 probe.0 = None; // reaped — disarm the guard (avoid waitpid on a reused pid)
678 assert!(
679 libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0,
680 "a fork inside the pids.max=1 cgroup must be refused (status={status})"
681 );
682 }
683
684 #[test]
685 fn resolve_parent_accepts_auto_and_in_mount_paths_only() {
686 assert_eq!(resolve_parent(""), None);
687 assert_eq!(resolve_parent("relative/path"), None);
688 assert_eq!(resolve_parent("/etc/passwd"), None); // outside the mount
689 assert_eq!(resolve_parent("/sys/fs/cgroup/../etc"), None); // no `..` escape
690 assert_eq!(resolve_parent("/sys/fs/cgroup-sibling/x"), None); // component check, not byte prefix
691 assert_eq!(
692 resolve_parent("/sys/fs/cgroup/foo/agentd"),
693 Some(PathBuf::from("/sys/fs/cgroup/foo/agentd"))
694 );
695 // `auto` resolves iff this host exposes cgroup-v2 (`0::` line); either a
696 // path under the mount or None — never a panic.
697 if let Some(p) = resolve_parent("auto") {
698 assert!(p.starts_with(CGROUP_ROOT));
699 assert!(p.ends_with("agentd"));
700 }
701 }
702
703 #[test]
704 fn stale_run_targets_dead_and_own_pid_only() {
705 let me = std::process::id();
706 // A pid above any possible pid_max → never assigned → always dead (ESRCH).
707 let dead = i32::MAX as u32;
708 assert!(
709 stale_run(&format!("run-{dead}-0"), me),
710 "dead pid → reclaim"
711 );
712 assert!(
713 stale_run(&format!("run-{me}-7"), me),
714 "our reused pid → reclaim"
715 );
716 assert!(stale_run(".probe-123", me), "probe leftover → reclaim");
717 assert!(!stale_run("run-1-0", me), "live sibling (pid 1) → spare");
718 assert!(!stale_run("unrelated", me), "non-run dir → spare");
719 assert!(!stale_run("run-notapid-0", me), "unparseable pid → spare");
720 }
721
722 /// Live backstop proof: a process that `setsid()`s out of our process group
723 /// (so `killpg` would MISS it) is still SIGKILLed by `cgroup.kill` once placed
724 /// in the cgroup. Skips cleanly where the cgroup-v2 tree isn't writable — the
725 /// feature is never required, so its absence must not fail the suite.
726 #[test]
727 fn cgroup_kill_reaps_a_process_that_left_the_process_group() {
728 let Some(parent) = resolve_parent("auto") else {
729 eprintln!("skip: no cgroup-v2 on this host");
730 return;
731 };
732 if !ensure_writable(&parent) {
733 eprintln!("skip: cgroup-v2 tree not writable (no delegation)");
734 return;
735 }
736 let cg = CgroupGuard::create(&parent, &format!("test-kill-{}", std::process::id()))
737 .expect("create child cgroup");
738
739 // Fork a child that leaves our process group then waits. Only
740 // async-signal-safe libc calls between fork and exit (no allocations).
741 let pid = unsafe { libc::fork() };
742 assert!(pid >= 0, "fork failed");
743 if pid == 0 {
744 unsafe {
745 libc::setsid(); // new session/pgroup → killpg(our pgid) can't reach it
746 libc::sleep(10); // cgroup.kill should end us well before this
747 libc::_exit(0); // if we get here, we were NOT killed → test fails below
748 }
749 }
750
751 assert!(cg.place(pid), "place the child pid into the cgroup");
752 assert!(cg.kill_all(), "write cgroup.kill");
753
754 let mut status = 0i32;
755 let reaped = unsafe { libc::waitpid(pid, &mut status, 0) };
756 assert_eq!(reaped, pid, "reaped the child");
757 assert!(
758 libc::WIFSIGNALED(status),
759 "child was SIGKILLed by cgroup.kill, not a clean exit (status={status})"
760 );
761 assert_eq!(libc::WTERMSIG(status), libc::SIGKILL, "killed by SIGKILL");
762 // `cg` Drop removes the now-empty cgroup dir.
763 }
764}