1use 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
15pub struct Sampler {
17 cfg: GuardConfig,
18 self_pid: u32,
20 uid: u32,
22 protect: HashSet<String>,
24 rlm_base: Option<String>,
30}
31
32pub 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 pub fn new(cfg: GuardConfig, self_pid: u32, uid: u32, rlm_base: Option<String>) -> Self {
58 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 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 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 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 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 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
160pub 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
177const 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
207const 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
215fn 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
228pub 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
257pub use crate::process::parse_cgroup_v2 as parse_cgroup_path;
261
262fn 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
271fn parse_psi(content: &str) -> Option<(f64, f64)> {
281 let mut some = None;
282 let mut full = 0.0; 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
298fn 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#[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
315pub 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
330pub 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
339pub 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 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")), snap(10, 1000, "Isolated Web Co", "firefox", 900, &app("ff")),
430 snap(11, 1001, "firefox", "firefox", 900, &app("other")), snap(12, 1000, "gnome-shell", "gnome-shell", 900, &app("gs")), snap(13, 1000, "tiny", "tiny", 10, &app("tiny")), ];
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 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 #[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 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 #[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 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 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 #[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); }
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 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 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 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}