Skip to main content

rlmctl_core/guard/
sampler.rs

1//! Reads memory pressure (PSI) and picks escalation candidates from a process
2//! snapshot. Pure reads; no decisions. The parsing is factored into small pure free
3//! functions so it can be unit-tested without touching the filesystem.
4
5use std::collections::{BTreeMap, HashMap, HashSet};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use super::cgfs;
10use super::resolve::{candidate_target, finalize, Resolution};
11use super::types::{ProcInfo, PsiSource, Sample, Target};
12use crate::process::ProcessInfo;
13use common::GuardConfig;
14
15/// Samples system pressure and the user's eligible processes.
16pub struct Sampler {
17    cfg: GuardConfig,
18    /// The guard's own PID — always excluded from the eligible set.
19    self_pid: u32,
20    /// Only processes owned by this uid are eligible.
21    uid: u32,
22    /// Precomputed protect-set: builtin names ∪ config additions.
23    protect: HashSet<String>,
24    /// `CgroupManager::base_path()` minus the leading `/sys/fs/cgroup`, e.g.
25    /// "/user.slice/user-1000.slice/user@1000.service/rlm". Used to resolve
26    /// raw (non-systemd-unit) rlm cgroups as targets. `None` means the strip
27    /// failed (see [`strip_cgroup_root`]) — resolution assembly is disabled
28    /// entirely rather than risk a bogus permissive match (see [`Sampler::resolve`]).
29    rlm_base: Option<String>,
30}
31
32/// Strip the `/sys/fs/cgroup` prefix from a `CgroupManager::base_path()` so
33/// the result matches the convention `resolve::candidate_target` expects
34/// (paths relative to the cgroupfs root). Pure string manipulation.
35///
36/// Returns `None` if `base_path` isn't valid UTF-8 or doesn't start with
37/// `/sys/fs/cgroup` — that's a broken invariant (base_path always comes from
38/// `CgroupManager`, which is hardcoded to build under `/sys/fs/cgroup`), and
39/// callers must fail closed rather than substitute an empty string: an empty
40/// `rlm_base` makes `candidate_target`'s raw-cgroup prefix check `""` (i.e.
41/// "/"), which matches almost every absolute cgroup path as a bogus Raw
42/// candidate — the dangerous direction for a freeze decision.
43pub fn strip_cgroup_root(base_path: &Path) -> Option<String> {
44    base_path
45        .to_str()?
46        .strip_prefix("/sys/fs/cgroup")
47        .map(str::to_string)
48}
49
50impl Sampler {
51    /// `self_pid` is the guard's own PID (always excluded). `uid` is the user
52    /// whose processes are eligible. `rlm_base` is
53    /// `CgroupManager::base_path()` with the `/sys/fs/cgroup` prefix
54    /// stripped (see [`strip_cgroup_root`]); `None` disables resolution
55    /// assembly entirely (fail closed — every process reports `resolution:
56    /// None`, so the policy engine can never select an escalation victim).
57    pub fn new(cfg: GuardConfig, self_pid: u32, uid: u32, rlm_base: Option<String>) -> Self {
58        // Merge the baked-in protect-list with the user's additions once, up
59        // front, so the per-process scan is a cheap hash lookup.
60        let protect = common::protect_set(&cfg.selection.protect);
61
62        Self {
63            cfg,
64            self_pid,
65            uid,
66            protect,
67            rlm_base,
68        }
69    }
70
71    /// Read current pressure. Prefers the user's app.slice PSI (what the
72    /// processes the guard can act on actually feel) and falls back to system
73    /// PSI when that file is missing OR unreadable/unparseable. `None` if
74    /// neither is readable (e.g. a kernel built without `CONFIG_PSI`).
75    pub fn sample(&self) -> Option<Sample> {
76        let app = fs::read_to_string(app_slice_pressure_path(self.uid)).ok();
77        let sys = fs::read_to_string("/proc/pressure/memory").ok();
78        let (some_avg10, full_avg10, source) = pick_pressure(app.as_deref(), sys.as_deref())?;
79
80        // Unreadable meminfo reports "plenty available, total unknown", which
81        // the policy's scarcity gate treats as not scarce: no action.
82        let mem = fs::read_to_string("/proc/meminfo")
83            .ok()
84            .and_then(|m| parse_meminfo(&m));
85
86        Some(Sample {
87            some_avg10,
88            full_avg10,
89            mem_available_mb: mem.map_or(u64::MAX, |m| m.available_mb),
90            mem_total_mb: mem.map_or(0, |m| m.total_mb),
91            source,
92        })
93    }
94
95    /// The escalation candidates in `snapshot` (normally
96    /// `process::list_for_uid(uid)`, taken once per tick and shared with the
97    /// rules enforcer): owned by `uid`, not the guard itself, at least
98    /// `min_rss_mb`, and not protected (builtin + config protect-list, matched
99    /// on comm and on the untruncated exe basename). Sorted by `rss_kb`
100    /// descending.
101    ///
102    /// Each candidate is named by its exe basename (comm fallback): Firefox
103    /// content processes have comm "Isolated Web Co" but run `firefox`.
104    /// Resolutions are cached per candidate cgroup, so N processes sharing
105    /// one cgroup cost a single member scan.
106    pub fn candidates(&self, snapshot: &[ProcessInfo]) -> Vec<ProcInfo> {
107        let min_rss_kb = self.cfg.selection.min_rss_mb.saturating_mul(1024);
108        let mut cache: HashMap<String, Resolution> = HashMap::new();
109        let mut out: Vec<ProcInfo> = snapshot
110            .iter()
111            .filter(|p| p.pid != self.self_pid && p.uid == self.uid && p.rss_kb >= min_rss_kb)
112            .filter(|p| !common::is_protected(&self.protect, &p.name, p.exe_name()))
113            .map(|p| ProcInfo {
114                pid: p.pid,
115                name: p.display_name().to_string(),
116                rss_kb: p.rss_kb,
117                resolution: p
118                    .cgroup
119                    .as_deref()
120                    .and_then(|cg| self.resolve_cgroup(cg, &mut cache)),
121            })
122            .collect();
123        out.sort_by_key(|p| std::cmp::Reverse(p.rss_kb));
124        out
125    }
126
127    /// Resolve a process's cgroup (the "0::" path) to its freeze/cap target,
128    /// if any. `cache` is keyed by candidate cgroup so processes sharing one
129    /// only pay for the member scan once. Returns `None` outright if
130    /// `rlm_base` failed to compute at startup; see [`strip_cgroup_root`].
131    fn resolve_cgroup(
132        &self,
133        victim_cgroup: &str,
134        cache: &mut HashMap<String, Resolution>,
135    ) -> Option<Resolution> {
136        let rlm_base = self.rlm_base.as_deref()?;
137        let candidate = candidate_target(victim_cgroup, self.uid, rlm_base)?;
138
139        if let Some(cached) = cache.get(&candidate.cgroup) {
140            return Some(cached.clone());
141        }
142
143        // Recursive: a protected process nested in a child cgroup (shell in
144        // a terminal scope's descendant, nested systemd-run, app-created
145        // sub-cgroup) is still within the freeze/stop blast radius, so it
146        // must be visible to the protect check even though it isn't a
147        // direct member of the candidate cgroup itself.
148        let member_exes: Vec<String> = cgfs::pids_under(&candidate.cgroup)
149            .into_iter()
150            .filter_map(|p| cgfs::exe_basename(p).or_else(|| comm_of(p)))
151            .collect();
152
153        let key = candidate.cgroup.clone();
154        let resolution = finalize(candidate, &member_exes, &self.protect);
155        cache.insert(key, resolution.clone());
156        Some(resolution)
157    }
158}
159
160/// The cgroups in `cgroups` that still hold at least one process
161/// (`cgroup.events` says `populated 1`). `PolicyEngine::tick` prunes its
162/// interventions against this set, so only the cgroups it holds are checked:
163/// one small read each, no `/proc` scan. A cgroup whose events file can't be
164/// read (removed, or never existed) is not live.
165///
166/// This is deliberately not derived from the candidate list: a successful
167/// `Cap` can push a process below the min-RSS floor while its cgroup, and
168/// the process in it, are still alive (D2).
169pub fn live_cgroups(cgroups: &[String]) -> HashSet<String> {
170    cgroups
171        .iter()
172        .filter(|cg| cgfs::is_populated(cg) == Some(true))
173        .cloned()
174        .collect()
175}
176
177/// Runtimes, interpreters and shells: many unrelated apps run the same
178/// binary, so for these the exe basename alone does not identify an app.
179/// Matched exactly, except the [`RUNTIME_PREFIXES`] which match by prefix.
180const RUNTIME_EXES: &[&str] = &[
181    "java",
182    "node",
183    "nodejs",
184    "python",
185    "python2",
186    "python3",
187    "electron",
188    "wine",
189    "wine64",
190    "wine-preloader",
191    "wine64-preloader",
192    "gjs-console",
193    "bash",
194    "sh",
195    "dash",
196    "zsh",
197    "fish",
198    "ruby",
199    "perl",
200    "php",
201    "dotnet",
202    "mono",
203    "deno",
204    "bun",
205];
206
207/// Versioned interpreters, the dynamic loader, and QEMU system emulators
208/// (each user VM is its own app).
209const RUNTIME_PREFIXES: &[&str] = &["python2.", "python3.", "ld-linux", "qemu-system-"];
210
211fn is_runtime_exe(name: &str) -> bool {
212    RUNTIME_EXES.contains(&name) || RUNTIME_PREFIXES.iter().any(|p| name.starts_with(p))
213}
214
215/// The grouping key for a cgroup whose largest process is `name`: the exe
216/// basename, so every scope of one browser is one app, except for runtimes,
217/// which get `<name>@<cgroup leaf>` so each unit is its own app (a growing
218/// Gradle daemon must not take IntelliJ down with it).
219fn app_key(name: &str, cgroup: &str) -> String {
220    if is_runtime_exe(name) {
221        let leaf = cgroup.rsplit('/').next().unwrap_or(cgroup);
222        format!("{name}@{leaf}")
223    } else {
224        name.to_string()
225    }
226}
227
228/// Group resolved processes by cgroup into one [`Target`] each. The app is
229/// named after the cgroup's largest process (see [`app_key`]), and `current_bytes` reads the
230/// cgroup's `memory.current` (injected so tests stay off the real cgroupfs).
231/// Unresolved processes are dropped: the guard can't act on them.
232pub fn targets_from_procs(
233    procs: &[ProcInfo],
234    current_bytes: &dyn Fn(&str) -> Option<u64>,
235) -> Vec<Target> {
236    let mut heaviest: BTreeMap<&str, &ProcInfo> = BTreeMap::new();
237    for p in procs {
238        let Some(res) = p.resolution.as_ref() else {
239            continue;
240        };
241        let slot = heaviest.entry(res.cgroup.as_str()).or_insert(p);
242        if p.rss_kb > slot.rss_kb {
243            *slot = p;
244        }
245    }
246    heaviest
247        .into_iter()
248        .map(|(cg, p)| Target {
249            app: app_key(&p.name, cg),
250            resolution: p.resolution.clone().expect("grouped only resolved procs"),
251            rss_kb: p.rss_kb,
252            current_bytes: current_bytes(cg),
253        })
254        .collect()
255}
256
257/// Parse the v2 line of /proc/<pid>/cgroup ("0::<path>"). Hybrid-mode lines
258/// for other controllers are noise and skipped. Delegates to
259/// [`crate::process::parse_cgroup_v2`]; kept under this name for callers.
260pub use crate::process::parse_cgroup_v2 as parse_cgroup_path;
261
262/// Fallback comm lookup (`Name:` in /proc/<pid>/status) for member processes
263/// whose `/proc/<pid>/exe` isn't readable.
264fn comm_of(pid: u32) -> Option<String> {
265    let status = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
266    status
267        .lines()
268        .find_map(|l| l.strip_prefix("Name:").map(|r| r.trim().to_string()))
269}
270
271/// Parse `/proc/pressure/memory`, returning `(some_avg10, full_avg10)`.
272///
273/// Expected format (the `full` line may be absent on some kernels):
274/// ```text
275/// some avg10=0.00 avg60=0.00 avg300=0.00 total=12345
276/// full avg10=0.00 avg60=0.00 avg300=0.00 total=6789
277/// ```
278/// Returns `None` if the `some` line or its `avg10` can't be found. A missing
279/// `full` line defaults its avg10 to `0.0`.
280fn parse_psi(content: &str) -> Option<(f64, f64)> {
281    let mut some = None;
282    let mut full = 0.0; // default if the `full` line is missing
283
284    for line in content.lines() {
285        let line = line.trim();
286        if let Some(rest) = line.strip_prefix("some ") {
287            some = field_f64(rest, "avg10");
288        } else if let Some(rest) = line.strip_prefix("full ") {
289            if let Some(v) = field_f64(rest, "avg10") {
290                full = v;
291            }
292        }
293    }
294
295    some.map(|s| (s, full))
296}
297
298/// Find `key=<number>` among space-separated `k=v` tokens and parse the value.
299fn field_f64(tokens: &str, key: &str) -> Option<f64> {
300    tokens.split_whitespace().find_map(|tok| {
301        tok.strip_prefix(key)
302            .and_then(|r| r.strip_prefix('='))
303            .and_then(|v| v.parse().ok())
304    })
305}
306
307/// The fields of `/proc/meminfo` the guard uses.
308#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309pub struct MemInfo {
310    pub available_mb: u64,
311    pub total_mb: u64,
312    pub swap_total_kb: u64,
313}
314
315/// Parse `/proc/meminfo`. `MemAvailable` and `MemTotal` are required;
316/// a missing `SwapTotal` reads as 0.
317pub fn parse_meminfo(s: &str) -> Option<MemInfo> {
318    let kb = |key: &str| {
319        s.lines()
320            .find_map(|l| l.strip_prefix(key))
321            .and_then(|r| r.split_whitespace().next()?.parse::<u64>().ok())
322    };
323    Some(MemInfo {
324        available_mb: kb("MemAvailable:")? / 1024,
325        total_mb: kb("MemTotal:")? / 1024,
326        swap_total_kb: kb("SwapTotal:").unwrap_or(0),
327    })
328}
329
330/// The memory PSI file of the user's `app.slice`: covers the apps the guard
331/// may act on, and excludes rlm's own subtree (`user@UID.service/rlm/`) and
332/// `session.slice`.
333pub fn app_slice_pressure_path(uid: u32) -> PathBuf {
334    PathBuf::from(format!(
335        "/sys/fs/cgroup/user.slice/user-{uid}.slice/user@{uid}.service/app.slice/memory.pressure"
336    ))
337}
338
339/// Choose the pressure to act on: app.slice PSI when it parses, otherwise
340/// system PSI. Returns `(some_avg10, full_avg10, source)`.
341pub fn pick_pressure(
342    app_slice: Option<&str>,
343    system: Option<&str>,
344) -> Option<(f64, f64, PsiSource)> {
345    if let Some((s, f)) = app_slice.and_then(parse_psi) {
346        return Some((s, f, PsiSource::AppSlice));
347    }
348    system
349        .and_then(parse_psi)
350        .map(|(s, f)| (s, f, PsiSource::System))
351}
352
353#[cfg(test)]
354mod tests {
355    use super::super::resolve::{Coverage, Mechanism, Verdict};
356    use super::*;
357
358    /// `(real_uid, name, rss_kb)` from `/proc/<pid>/status` text.
359    fn parse_proc_status(status: &str) -> Option<(u32, String, u64)> {
360        crate::process::parse_status(status).map(|f| (f.uid, f.name, f.rss_kb))
361    }
362
363    fn pinfo(pid: u32, name: &str, rss_mb: u64, cg: Option<&str>) -> ProcInfo {
364        ProcInfo {
365            pid,
366            name: name.into(),
367            rss_kb: rss_mb * 1024,
368            resolution: cg.map(|c| Resolution {
369                cgroup: c.into(),
370                unit: None,
371                verdict: Verdict::Freeze,
372                coverage: Coverage::Full,
373                mechanism: Mechanism::Raw,
374            }),
375        }
376    }
377
378    fn high_sample() -> Sample {
379        Sample {
380            some_avg10: 50.0,
381            full_avg10: 0.0,
382            mem_available_mb: 2_000,
383            mem_total_mb: 16_000,
384            source: PsiSource::AppSlice,
385        }
386    }
387
388    fn frozen(actions: &[super::super::types::Action]) -> Vec<String> {
389        let mut v: Vec<String> = actions
390            .iter()
391            .filter_map(|a| match a {
392                super::super::types::Action::Freeze { res, .. } => Some(res.cgroup.clone()),
393                _ => None,
394            })
395            .collect();
396        v.sort();
397        v
398    }
399
400    fn tick_once(procs: &[ProcInfo]) -> Vec<String> {
401        let ts = targets_from_procs(procs, &|_| None);
402        let live: HashSet<String> = ts.iter().map(|t| t.resolution.cgroup.clone()).collect();
403        let mut e = super::super::PolicyEngine::new(GuardConfig::default());
404        frozen(&e.tick(0, high_sample(), &ts, &live))
405    }
406
407    const RLM: &str = "/user.slice/user-1000.slice/user@1000.service/rlm";
408
409    fn snap(pid: u32, uid: u32, comm: &str, exe: &str, rss_mb: u64, cg: &str) -> ProcessInfo {
410        ProcessInfo {
411            pid,
412            uid,
413            name: comm.into(),
414            executable: Some(format!("/usr/bin/{exe}").into()),
415            rss_kb: rss_mb * 1024,
416            cgroup: Some(cg.into()),
417            ..Default::default()
418        }
419    }
420    fn app(n: &str) -> String {
421        format!("/user.slice/user-1000.slice/user@1000.service/app.slice/app-{n}.scope")
422    }
423
424    #[test]
425    fn candidates_keep_own_large_unprotected_processes_only() {
426        let s = Sampler::new(GuardConfig::default(), 1, 1000, Some(RLM.into()));
427        let snapshot = vec![
428            snap(1, 1000, "rlm-guard", "rlm-guard", 500, &app("guard")), // the guard itself
429            snap(10, 1000, "Isolated Web Co", "firefox", 900, &app("ff")),
430            snap(11, 1001, "firefox", "firefox", 900, &app("other")), // another user
431            snap(12, 1000, "gnome-shell", "gnome-shell", 900, &app("gs")), // protected
432            snap(13, 1000, "tiny", "tiny", 10, &app("tiny")),         // below min_rss
433        ];
434        let c = s.candidates(&snapshot);
435        assert_eq!(c.iter().map(|p| p.pid).collect::<Vec<_>>(), vec![10]);
436        assert_eq!(c[0].name, "firefox", "app identity is the exe basename");
437        assert!(c[0].resolution.is_some());
438    }
439
440    #[test]
441    fn session_scope_processes_have_no_resolution() {
442        // Review Focus 1: a runaway started from a tty or ssh login lives in session-N.scope.
443        let s = Sampler::new(GuardConfig::default(), 1, 1000, Some(RLM.into()));
444        let snapshot = vec![snap(
445            20,
446            1000,
447            "python3",
448            "python3",
449            900,
450            "/user.slice/user-1000.slice/session-3.scope",
451        )];
452        let c = s.candidates(&snapshot);
453        assert_eq!(c.len(), 1);
454        assert!(
455            c[0].resolution.is_none(),
456            "outside app.slice and rlm/, never a target"
457        );
458    }
459
460    #[test]
461    fn candidates_name_a_replaced_binary_without_the_deleted_suffix() {
462        let s = Sampler::new(GuardConfig::default(), 1, 1000, Some(RLM.into()));
463        let mut p = snap(30, 1000, "chrome", "chrome", 900, &app("c"));
464        p.executable = Some("/opt/google/chrome/chrome (deleted)".into());
465        assert_eq!(s.candidates(&[p])[0].name, "chrome");
466    }
467
468    #[test]
469    fn two_java_scopes_are_two_apps() {
470        let procs = vec![
471            pinfo(1, "java", 3000, Some("/app.slice/app-idea-1.scope")),
472            pinfo(2, "java", 2000, Some("/app.slice/app-gradle-2.scope")),
473        ];
474        assert_eq!(tick_once(&procs), vec!["/app.slice/app-idea-1.scope"]);
475    }
476
477    #[test]
478    fn two_chrome_scopes_are_still_one_app() {
479        let procs = vec![
480            pinfo(1, "chrome", 3000, Some("/app.slice/app-a-1.scope")),
481            pinfo(2, "chrome", 2000, Some("/app.slice/app-b-2.scope")),
482        ];
483        assert_eq!(
484            tick_once(&procs),
485            vec!["/app.slice/app-a-1.scope", "/app.slice/app-b-2.scope"]
486        );
487    }
488
489    #[test]
490    fn runtime_binaries_are_keyed_per_unit() {
491        let procs = vec![
492            pinfo(1, "python3.12", 900, Some("/app.slice/run-u7.service")),
493            pinfo(2, "ld-linux-x86-64.so.2", 900, Some("/app.slice/x.scope")),
494            pinfo(3, "firefox", 900, Some("/app.slice/ff.scope")),
495            pinfo(4, "wine64-preloader", 900, Some("/app.slice/w.scope")),
496            pinfo(5, "gjs-console", 900, Some("/app.slice/g.scope")),
497            pinfo(6, "python2.7", 900, Some("/app.slice/p2.scope")),
498            pinfo(7, "qemu-system-x86_64", 900, Some("/app.slice/vm.scope")),
499        ];
500        let mut apps: Vec<String> = targets_from_procs(&procs, &|_| None)
501            .into_iter()
502            .map(|t| t.app)
503            .collect();
504        apps.sort();
505        assert_eq!(
506            apps,
507            vec![
508                "firefox",
509                "gjs-console@g.scope",
510                "ld-linux-x86-64.so.2@x.scope",
511                "python2.7@p2.scope",
512                "python3.12@run-u7.service",
513                "qemu-system-x86_64@vm.scope",
514                "wine64-preloader@w.scope",
515            ]
516        );
517    }
518
519    #[test]
520    fn targets_merge_processes_sharing_a_cgroup_and_drop_unresolved() {
521        let procs = vec![
522            pinfo(10, "firefox", 900, Some("/a.scope")),
523            pinfo(11, "firefox", 1200, Some("/a.scope")),
524            pinfo(12, "stray", 5000, None),
525        ];
526        let t = targets_from_procs(&procs, &|_| Some(42));
527        assert_eq!(t.len(), 1);
528        assert_eq!(t[0].app, "firefox");
529        assert_eq!(t[0].rss_kb, 1200 * 1024);
530        assert_eq!(t[0].current_bytes, Some(42));
531    }
532
533    // ---- parse_cgroup_path ------------------------------------------------
534
535    #[test]
536    fn cgroup_path_parses_v2_line() {
537        assert_eq!(
538            parse_cgroup_path("0::/user.slice/x.scope\n"),
539            Some("/user.slice/x.scope".into())
540        );
541        // Hybrid line noise is skipped; only the "0::" entry counts.
542        assert_eq!(
543            parse_cgroup_path("1:name=systemd:/foo\n0::/bar\n"),
544            Some("/bar".into())
545        );
546        assert_eq!(parse_cgroup_path(""), None);
547    }
548
549    // ---- parse_psi -------------------------------------------------------
550
551    #[test]
552    fn psi_parses_some_and_full() {
553        let s = "some avg10=12.34 avg60=5.00 avg300=1.00 total=999\n\
554                 full avg10=3.21 avg60=2.00 avg300=0.50 total=42\n";
555        assert_eq!(parse_psi(s), Some((12.34, 3.21)));
556    }
557
558    #[test]
559    fn psi_missing_full_line_defaults_to_zero() {
560        let s = "some avg10=7.50 avg60=1.00 avg300=0.10 total=10\n";
561        assert_eq!(parse_psi(s), Some((7.50, 0.0)));
562    }
563
564    #[test]
565    fn psi_missing_some_line_is_none() {
566        let s = "full avg10=3.00 avg60=1.00 avg300=0.10 total=10\n";
567        assert_eq!(parse_psi(s), None);
568    }
569
570    #[test]
571    fn psi_empty_is_none() {
572        assert_eq!(parse_psi(""), None);
573    }
574
575    #[test]
576    fn psi_malformed_avg10_is_none() {
577        let s = "some avg10=NaNNN avg60=1.00 total=5\n";
578        assert_eq!(parse_psi(s), None);
579    }
580
581    #[test]
582    fn psi_zero_values() {
583        let s = "some avg10=0.00 avg60=0.00 avg300=0.00 total=0\n\
584                 full avg10=0.00 avg60=0.00 avg300=0.00 total=0\n";
585        assert_eq!(parse_psi(s), Some((0.0, 0.0)));
586    }
587
588    #[test]
589    fn psi_tolerates_leading_whitespace() {
590        let s = "  some avg10=1.00 avg60=0.00 avg300=0.00 total=1\n";
591        assert_eq!(parse_psi(s), Some((1.0, 0.0)));
592    }
593
594    // ---- pick_pressure / parse_meminfo ------------------------------------
595
596    const APP_CALM: &str = "some avg10=0.00 avg60=0.00 avg300=0.00 total=0\nfull avg10=0.00 avg60=0.00 avg300=0.00 total=0\n";
597    const SYS_HOT: &str = "some avg10=60.00 avg60=20.00 avg300=5.00 total=1\nfull avg10=40.00 avg60=10.00 avg300=2.00 total=1\n";
598
599    #[test]
600    fn stall_inside_a_limited_rlm_cgroup_does_not_count_as_app_pressure() {
601        // Incident replay: a cgroup rlm limited thrashed at its own memory.max;
602        // system PSI read 60% while nothing in app.slice was waiting.
603        assert_eq!(
604            pick_pressure(Some(APP_CALM), Some(SYS_HOT)),
605            Some((0.0, 0.0, PsiSource::AppSlice))
606        );
607    }
608
609    #[test]
610    fn falls_back_to_system_psi_when_app_slice_file_missing() {
611        assert_eq!(
612            pick_pressure(None, Some(SYS_HOT)),
613            Some((60.0, 40.0, PsiSource::System))
614        );
615        assert_eq!(
616            pick_pressure(Some("garbage"), Some(SYS_HOT)),
617            Some((60.0, 40.0, PsiSource::System))
618        );
619    }
620
621    #[test]
622    fn no_psi_anywhere_is_none() {
623        assert_eq!(pick_pressure(None, None), None);
624    }
625
626    #[test]
627    fn app_slice_pressure_path_is_under_the_user_manager() {
628        assert_eq!(
629            app_slice_pressure_path(1000),
630            std::path::PathBuf::from(
631                "/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/memory.pressure"
632            )
633        );
634    }
635
636    #[test]
637    fn parse_meminfo_reads_available_total_and_swap() {
638        let m = "MemTotal:       16384000 kB\nMemFree: 1 kB\nMemAvailable:    2097152 kB\nSwapTotal:       8388604 kB\n";
639        assert_eq!(
640            parse_meminfo(m),
641            Some(MemInfo {
642                available_mb: 2048,
643                total_mb: 16000,
644                swap_total_kb: 8_388_604
645            })
646        );
647    }
648
649    #[test]
650    fn parse_meminfo_without_swaptotal_defaults_zero() {
651        let m = "MemTotal: 1024000 kB\nMemAvailable: 512000 kB\n";
652        assert_eq!(parse_meminfo(m).unwrap().swap_total_kb, 0);
653    }
654
655    #[test]
656    fn parse_meminfo_requires_available_and_total() {
657        assert_eq!(parse_meminfo("MemTotal: 1 kB\n"), None);
658        assert_eq!(parse_meminfo("MemAvailable: 1 kB\n"), None);
659    }
660
661    // ---- parse_proc_status ----------------------------------------------
662
663    #[test]
664    fn status_full_fields() {
665        let s = "Name:\tfirefox\n\
666                 State:\tS (sleeping)\n\
667                 Tgid:\t1234\n\
668                 Pid:\t1234\n\
669                 Uid:\t1000\t1000\t1000\t1000\n\
670                 VmRSS:\t  500000 kB\n\
671                 VmSwap:\t   2000 kB\n";
672        let (uid, name, rss) = parse_proc_status(s).unwrap();
673        assert_eq!(uid, 1000);
674        assert_eq!(name, "firefox");
675        assert_eq!(rss, 502000); // 500000 + 2000
676    }
677
678    #[test]
679    fn status_missing_vmswap_defaults_zero() {
680        let s = "Name:\tcode\n\
681                 Uid:\t1000\t1000\t1000\t1000\n\
682                 VmRSS:\t  300000 kB\n";
683        let (uid, name, rss) = parse_proc_status(s).unwrap();
684        assert_eq!(uid, 1000);
685        assert_eq!(name, "code");
686        assert_eq!(rss, 300000);
687    }
688
689    #[test]
690    fn status_missing_vmrss_treated_as_zero() {
691        // Kernel threads have no VmRSS line at all.
692        let s = "Name:\tkworker/0:0\n\
693                 Uid:\t0\t0\t0\t0\n";
694        let (uid, name, rss) = parse_proc_status(s).unwrap();
695        assert_eq!(uid, 0);
696        assert_eq!(name, "kworker/0:0");
697        assert_eq!(rss, 0);
698    }
699
700    #[test]
701    fn status_truncated_name_15_chars() {
702        // The kernel truncates comm to 15 chars; we keep it verbatim.
703        let s = "Name:\tsome-very-long-\n\
704                 Uid:\t1000\t1000\t1000\t1000\n\
705                 VmRSS:\t  100000 kB\n";
706        let (_, name, _) = parse_proc_status(s).unwrap();
707        assert_eq!(name, "some-very-long-");
708        assert_eq!(name.len(), 15);
709    }
710
711    #[test]
712    fn status_takes_real_uid_first_field() {
713        // A setuid process: real=1000, effective=0. We must pick the real uid.
714        let s = "Name:\tsetuid-proc\n\
715                 Uid:\t1000\t0\t0\t1000\n\
716                 VmRSS:\t  100000 kB\n";
717        let (uid, _, _) = parse_proc_status(s).unwrap();
718        assert_eq!(uid, 1000);
719    }
720
721    #[test]
722    fn status_missing_uid_is_none() {
723        let s = "Name:\tfoo\nVmRSS:\t  100000 kB\n";
724        assert_eq!(parse_proc_status(s), None);
725    }
726
727    #[test]
728    fn status_missing_name_is_none() {
729        let s = "Uid:\t1000\t1000\t1000\t1000\nVmRSS:\t  100000 kB\n";
730        assert_eq!(parse_proc_status(s), None);
731    }
732
733    #[test]
734    fn status_malformed_rss_is_zero() {
735        let s = "Name:\tfoo\n\
736                 Uid:\t1000\t1000\t1000\t1000\n\
737                 VmRSS:\tbogus kB\n";
738        let (_, _, rss) = parse_proc_status(s).unwrap();
739        assert_eq!(rss, 0);
740    }
741}