1use std::collections::{BTreeMap, HashMap, HashSet};
11
12use super::resolve::{Coverage, Resolution, Verdict};
13use super::types::{Action, Intervention, Level, Sample, Target};
14use common::GuardConfig;
15
16const FULL_HIGH_RISE: f64 = 3.0;
19const NOTIFY_INTERVAL_MS: u64 = 60_000;
21
22pub const PARTIAL_GATE_MS: u64 = 3_000;
27pub const MAX_HELD_APPS: usize = 3;
29pub const MIN_GROWTH_BPS: f64 = 1_048_576.0;
32pub const MAX_COLD_DEFER_TICKS: u32 = 3;
36
37pub fn is_scarce(s: &Sample, t: &common::GuardTrigger) -> bool {
41 s.mem_available_mb < t.mem_available_floor_mb
42 || (s.mem_total_mb > 0
43 && s.mem_available_mb.saturating_mul(100)
44 < s.mem_total_mb.saturating_mul(t.act_below_available_pct))
45}
46
47struct Growth {
49 last_bytes: u64,
50 last_ms: u64,
51 rate_bps: f64,
53 warm: bool,
56}
57
58pub struct PolicyEngine {
68 cfg: GuardConfig,
69 level: Level,
71 interventions: HashMap<String, (Intervention, Resolution, String)>,
75 last_freeze_ms: HashMap<String, u64>,
78 growth: HashMap<String, Growth>,
80 calm_since_ms: Option<u64>,
82 last_action_ms: Option<u64>,
85 last_action_partial: bool,
89 last_notify_ms: Option<u64>,
92 cold_defer_ticks: u32,
95}
96
97impl PolicyEngine {
98 pub fn new(cfg: GuardConfig) -> Self {
99 Self {
100 cfg,
101 level: Level::Calm,
102 interventions: HashMap::new(),
103 last_freeze_ms: HashMap::new(),
104 growth: HashMap::new(),
105 calm_since_ms: None,
106 last_action_ms: None,
107 last_action_partial: false,
108 last_notify_ms: None,
109 cold_defer_ticks: 0,
110 }
111 }
112
113 pub fn tick(
130 &mut self,
131 now_ms: u64,
132 sample: Sample,
133 targets: &[Target],
134 live_cgroups: &HashSet<String>,
135 ) -> Vec<Action> {
136 if !self.cfg.enabled {
138 return Vec::new();
139 }
140
141 let mut actions = Vec::new();
142
143 self.level = self.next_level(sample);
145 match self.level {
146 Level::Calm => {
147 if self.calm_since_ms.is_none() {
149 self.calm_since_ms = Some(now_ms);
150 }
151 self.cold_defer_ticks = 0;
152 }
153 _ => self.calm_since_ms = None,
154 }
155
156 self.update_growth(now_ms, targets);
158
159 let dead: Vec<String> = self
164 .interventions
165 .keys()
166 .filter(|cg| !live_cgroups.contains(cg.as_str()))
167 .cloned()
168 .collect();
169 for cg in dead {
170 let (_, res, _) = self.interventions.remove(&cg).expect("just found key");
171 actions.push(Action::LiftCap { res });
172 }
173
174 let freeze_hold_ms = self.cfg.timing.freeze_hold_secs.saturating_mul(1000);
176 let calm_hold_ms = self.cfg.timing.calm_hold_secs.saturating_mul(1000);
177 let mut recovered = Vec::new();
178 let mut thawed_apps: HashSet<String> = HashSet::new();
181 for (cg, (intervention, res, app)) in &self.interventions {
182 match *intervention {
183 Intervention::Frozen { since_ms } => {
184 if now_ms.saturating_sub(since_ms) >= freeze_hold_ms {
185 actions.push(Action::Thaw { res: res.clone() });
186 recovered.push(cg.clone());
187 thawed_apps.insert(app.clone());
188 }
189 }
190 Intervention::Capped { .. } => {
191 if self.level == Level::Calm {
195 if let Some(calm_since) = self.calm_since_ms {
196 if now_ms.saturating_sub(calm_since) >= calm_hold_ms {
197 actions.push(Action::LiftCap { res: res.clone() });
198 recovered.push(cg.clone());
199 }
200 }
201 }
202 }
203 }
204 }
205 for cg in recovered {
206 self.interventions.remove(&cg);
208 }
209
210 let mut victim_name: Option<String> = None;
214 if matches!(self.level, Level::High | Level::Critical)
215 && is_scarce(&sample, &self.cfg.trigger)
216 && self.held_apps().len() < MAX_HELD_APPS
217 {
218 let gate_ms = if self.last_action_partial {
222 PARTIAL_GATE_MS.min(freeze_hold_ms)
223 } else {
224 freeze_hold_ms
225 };
226 let gate_open = match self.last_action_ms {
227 None => true,
228 Some(last) => now_ms.saturating_sub(last) >= gate_ms,
229 };
230 if gate_open {
231 if let Some((app, members)) = self.select_app(targets, &thawed_apps) {
232 let cap_only = members
233 .iter()
234 .any(|m| m.resolution.verdict == Verdict::CapOnly);
235 let partial = members
236 .iter()
237 .any(|m| m.resolution.coverage == Coverage::Partial);
238 let cooldown_ms = self.cfg.timing.freeze_cooldown_secs.saturating_mul(1000);
239 let in_cooldown = self
240 .last_freeze_ms
241 .get(&app)
242 .is_some_and(|&last| now_ms.saturating_sub(last) < cooldown_ms);
243 let freeze = !cap_only && !in_cooldown;
246
247 for m in members {
248 let res = m.resolution.clone();
249 let intervention = if freeze {
250 actions.push(Action::Freeze {
251 res: res.clone(),
252 name: app.clone(),
253 });
254 Intervention::Frozen { since_ms: now_ms }
255 } else {
256 actions.push(Action::Cap {
257 res: res.clone(),
258 name: app.clone(),
259 });
260 Intervention::Capped { since_ms: now_ms }
261 };
262 self.interventions
263 .insert(res.cgroup.clone(), (intervention, res, app.clone()));
264 }
265 if freeze {
266 self.last_freeze_ms.insert(app.clone(), now_ms);
267 }
268 self.last_action_ms = Some(now_ms);
269 self.last_action_partial = partial;
270 victim_name = Some(app);
271 }
272 }
273 }
274
275 let notify_due = match self.last_notify_ms {
277 None => true,
278 Some(last) => now_ms.saturating_sub(last) >= NOTIFY_INTERVAL_MS,
279 };
280 if self.cfg.notify
281 && matches!(self.level, Level::Warn | Level::High | Level::Critical)
282 && notify_due
283 {
284 let message = match &victim_name {
285 Some(name) => format!(
286 "rlm-guard: memory pressure {:?}, acting on {}",
287 self.level, name
288 ),
289 None => format!("rlm-guard: memory pressure {:?}", self.level),
290 };
291 actions.push(Action::Notify { message });
292 self.last_notify_ms = Some(now_ms);
293 }
294
295 actions
296 }
297
298 pub fn wants_candidates(&self, sample: Sample) -> bool {
304 self.cfg.enabled
305 && (self.next_level(sample) != Level::Calm || is_scarce(&sample, &self.cfg.trigger))
306 }
307
308 pub fn level(&self) -> Level {
310 self.level
311 }
312
313 pub fn interventions(&self) -> Vec<(String, Intervention)> {
316 let mut out: Vec<(String, Intervention)> = self
317 .interventions
318 .iter()
319 .map(|(cg, (iv, _, _))| (cg.clone(), *iv))
320 .collect();
321 out.sort_by(|(a, _), (b, _)| a.cmp(b));
322 out
323 }
324
325 pub fn intervened_cgroups(&self) -> Vec<String> {
334 self.interventions.keys().cloned().collect()
335 }
336
337 fn held_apps(&self) -> HashSet<&str> {
339 self.interventions
340 .values()
341 .map(|(_, _, app)| app.as_str())
342 .collect()
343 }
344
345 fn next_level(&self, s: Sample) -> Level {
350 let t = &self.cfg.trigger;
351 let floor = t.mem_available_floor_mb;
352
353 let warn_rise = s.some_avg10 >= t.psi_some_warn;
355 let high_rise = s.some_avg10 >= t.psi_some_high || s.full_avg10 >= FULL_HIGH_RISE;
356 let crit_rise = s.full_avg10 >= t.psi_full_critical || s.mem_available_mb < floor;
357
358 let warn_stay = s.some_avg10 >= t.psi_some_warn / 2.0;
360 let high_stay =
361 s.some_avg10 >= t.psi_some_high / 2.0 || s.full_avg10 >= FULL_HIGH_RISE / 2.0;
362 let crit_stay = s.full_avg10 >= t.psi_full_critical / 2.0 || s.mem_available_mb < floor;
363
364 let at_critical = self.level == Level::Critical;
368 let at_high = matches!(self.level, Level::High | Level::Critical);
369 let at_warn = matches!(self.level, Level::Warn | Level::High | Level::Critical);
370
371 if crit_rise || (at_critical && crit_stay) {
372 Level::Critical
373 } else if high_rise || (at_high && high_stay) {
374 Level::High
375 } else if warn_rise || (at_warn && warn_stay) {
376 Level::Warn
377 } else {
378 Level::Calm
379 }
380 }
381
382 fn update_growth(&mut self, now_ms: u64, targets: &[Target]) {
385 let mut seen = HashSet::new();
386 for t in targets {
387 let Some(cur) = t.current_bytes else {
388 continue;
389 };
390 let cg = &t.resolution.cgroup;
391 seen.insert(cg.clone());
392 match self.growth.get_mut(cg) {
393 Some(g) if now_ms > g.last_ms => {
394 let dt = (now_ms - g.last_ms) as f64 / 1000.0;
395 let inst = (cur as f64 - g.last_bytes as f64) / dt;
396 g.rate_bps = 0.5 * g.rate_bps + 0.5 * inst;
397 g.last_bytes = cur;
398 g.last_ms = now_ms;
399 g.warm = true;
400 }
401 Some(_) => {}
402 None => {
403 self.growth.insert(
404 cg.clone(),
405 Growth {
406 last_bytes: cur,
407 last_ms: now_ms,
408 rate_bps: 0.0,
409 warm: false,
410 },
411 );
412 }
413 }
414 }
415 self.growth.retain(|cg, _| seen.contains(cg));
416 }
417
418 fn select_app<'a>(
434 &mut self,
435 targets: &'a [Target],
436 blocked: &HashSet<String>,
437 ) -> Option<(String, Vec<&'a Target>)> {
438 let min_rss_kb = self.cfg.selection.min_rss_mb.saturating_mul(1024);
439 let held = self.held_apps();
440 let mut groups: BTreeMap<&str, Vec<&Target>> = BTreeMap::new();
441 for t in targets {
442 groups.entry(t.app.as_str()).or_default().push(t);
443 }
444 let eligible: Vec<(&str, Vec<&Target>)> = groups
445 .into_iter()
446 .filter(|(app, ms)| {
447 !held.contains(app)
448 && !blocked.contains(*app)
449 && ms.iter().any(|m| m.rss_kb >= min_rss_kb)
450 && ms
451 .iter()
452 .all(|m| !self.interventions.contains_key(&m.resolution.cgroup))
453 })
454 .collect();
455 let any_warm = eligible.iter().any(|(_, ms)| {
456 ms.iter().any(|m| {
457 self.growth
458 .get(&m.resolution.cgroup)
459 .is_some_and(|g| g.warm)
460 })
461 });
462 let any_cold = eligible.iter().any(|(_, ms)| {
463 ms.iter().any(|m| {
464 self.growth
465 .get(&m.resolution.cgroup)
466 .is_some_and(|g| !g.warm)
467 })
468 });
469 if !any_warm && any_cold && self.cold_defer_ticks < MAX_COLD_DEFER_TICKS {
470 self.cold_defer_ticks += 1;
471 return None;
472 }
473 self.cold_defer_ticks = 0;
474 let growth = |ms: &[&Target]| -> f64 {
475 ms.iter()
476 .map(|m| {
477 self.growth
478 .get(&m.resolution.cgroup)
479 .map_or(0.0, |g| g.rate_bps.max(0.0))
480 })
481 .sum()
482 };
483 let size = |ms: &[&Target]| -> u64 {
484 ms.iter()
485 .map(|m| m.current_bytes.unwrap_or(m.rss_kb.saturating_mul(1024)))
486 .sum()
487 };
488 let pick = eligible
489 .iter()
490 .filter(|(_, ms)| growth(ms) >= MIN_GROWTH_BPS)
491 .max_by(|a, b| {
492 growth(&a.1)
493 .total_cmp(&growth(&b.1))
494 .then_with(|| b.0.cmp(a.0))
495 })
496 .or_else(|| {
497 eligible
498 .iter()
499 .max_by(|a, b| size(&a.1).cmp(&size(&b.1)).then_with(|| b.0.cmp(a.0)))
500 })?;
501 Some((pick.0.to_string(), pick.1.clone()))
502 }
503}
504
505#[cfg(test)]
506mod tests {
507 use super::super::resolve::Mechanism;
508 use super::super::types::PsiSource;
509 use super::*;
510
511 fn cfg() -> GuardConfig {
513 GuardConfig::default()
514 }
515
516 fn sample(some: f64, full: f64, avail_mb: u64) -> Sample {
517 Sample {
518 some_avg10: some,
519 full_avg10: full,
520 mem_available_mb: avail_mb,
521 mem_total_mb: 16_000,
522 source: PsiSource::AppSlice,
523 }
524 }
525
526 fn res(cg: &str) -> Resolution {
528 Resolution {
529 cgroup: cg.into(),
530 unit: Some(format!("{}.scope", cg.rsplit('/').next().unwrap())),
531 verdict: Verdict::Freeze,
532 coverage: Coverage::Full,
533 mechanism: Mechanism::Unit,
534 }
535 }
536
537 const MIB: u64 = 1024 * 1024;
538
539 fn target(app: &str, cg: &str, mb: u64) -> Target {
542 Target {
543 app: app.into(),
544 resolution: res(cg),
545 rss_kb: mb * 1024,
546 current_bytes: Some(mb * MIB),
547 }
548 }
549
550 fn proc(pid: u32, name: &str, rss_mb: u64) -> Target {
552 target(name, &format!("/app.slice/app-{name}-{pid}.scope"), rss_mb)
553 }
554
555 fn proc_at(_pid: u32, name: &str, rss_mb: u64, cg: &str) -> Target {
556 target(name, cg, rss_mb)
557 }
558
559 fn calm() -> Sample {
561 sample(0.0, 0.0, 8000)
562 }
563
564 fn high() -> Sample {
566 sample(50.0, 0.0, 2_000)
567 }
568
569 #[test]
570 fn high_pressure_with_plenty_of_free_memory_never_escalates() {
571 let mut e = PolicyEngine::new(cfg());
572 let procs = vec![proc(2, "chrome", 4000)];
573 let a = e.tick(0, sample(80.0, 20.0, 8_000), &procs, &live_from(&procs));
575 assert_eq!(e.level, Level::Critical);
576 assert!(
577 !a.iter()
578 .any(|x| matches!(x, Action::Freeze { .. } | Action::Cap { .. })),
579 "escalated without scarcity: {a:?}"
580 );
581 }
582
583 #[test]
584 fn is_scarce_uses_floor_or_percentage() {
585 let t = common::GuardTrigger::default(); assert!(is_scarce(&sample(0.0, 0.0, 300), &t));
587 assert!(is_scarce(&sample(0.0, 0.0, 3_000), &t)); assert!(!is_scarce(&sample(0.0, 0.0, 3_300), &t)); let unknown = Sample {
590 mem_total_mb: 0,
591 mem_available_mb: u64::MAX,
592 ..sample(0.0, 0.0, 0)
593 };
594 assert!(
595 !is_scarce(&unknown, &t),
596 "unreadable meminfo must not enable actions"
597 );
598 }
599
600 fn freeze_targets(actions: &[Action]) -> Vec<String> {
601 actions
602 .iter()
603 .filter_map(|a| match a {
604 Action::Freeze { res, .. } => Some(res.cgroup.clone()),
605 _ => None,
606 })
607 .collect()
608 }
609
610 fn has_cap_target(actions: &[Action], cg: &str) -> bool {
611 actions
612 .iter()
613 .any(|a| matches!(a, Action::Cap { res, .. } if res.cgroup == cg))
614 }
615
616 fn has_freeze_target(actions: &[Action], cg: &str) -> bool {
617 actions
618 .iter()
619 .any(|a| matches!(a, Action::Freeze { res, .. } if res.cgroup == cg))
620 }
621
622 fn has_thaw_target(actions: &[Action], cg: &str) -> bool {
623 actions
624 .iter()
625 .any(|a| matches!(a, Action::Thaw { res } if res.cgroup == cg))
626 }
627
628 fn has_liftcap_target(actions: &[Action], cg: &str) -> bool {
629 actions
630 .iter()
631 .any(|a| matches!(a, Action::LiftCap { res } if res.cgroup == cg))
632 }
633
634 fn prime(e: &mut PolicyEngine, ts: &[Target]) {
638 for t in ts {
639 e.growth.insert(
640 t.resolution.cgroup.clone(),
641 Growth {
642 last_bytes: t.current_bytes.unwrap_or(0),
643 last_ms: 0,
644 rate_bps: 0.0,
645 warm: true,
646 },
647 );
648 }
649 }
650
651 fn live_from(ts: &[Target]) -> std::collections::HashSet<String> {
654 ts.iter().map(|t| t.resolution.cgroup.clone()).collect()
655 }
656
657 #[test]
658 fn calm_yields_no_actions() {
659 let mut e = PolicyEngine::new(cfg());
660 let procs = vec![proc(100, "firefox", 2000)];
661 let actions = e.tick(1000, calm(), &procs, &live_from(&procs));
662 assert!(actions.is_empty(), "calm produced actions: {actions:?}");
663 assert_eq!(e.level, Level::Calm);
664 }
665
666 #[test]
667 fn full_signal_has_fall_hysteresis() {
668 let mut e = PolicyEngine::new(cfg());
670 let procs = vec![proc(100, "firefox", 4000)];
671 e.tick(1_000, sample(0.0, 4.0, 8000), &procs, &live_from(&procs));
672 assert_eq!(e.level, Level::High, "full=4.0 should enter High");
673
674 e.tick(2_000, sample(0.0, 2.0, 8000), &procs, &live_from(&procs));
677 assert_eq!(
678 e.level,
679 Level::High,
680 "full=2.0 (between fall and rise) must hold High, not flap"
681 );
682
683 e.tick(3_000, sample(0.0, 1.0, 8000), &procs, &live_from(&procs));
685 assert_eq!(
686 e.level,
687 Level::Calm,
688 "full below fall threshold drops to Calm"
689 );
690 }
691
692 #[test]
693 fn disabled_engine_is_inert() {
694 let mut c = cfg();
695 c.enabled = false;
696 let mut e = PolicyEngine::new(c);
697 let procs = vec![proc(100, "firefox", 4000)];
698 assert!(e.tick(1000, high(), &procs, &live_from(&procs)).is_empty());
699 }
700
701 #[test]
702 fn high_freezes_largest_eligible_process() {
703 let mut e = PolicyEngine::new(cfg());
704 let procs = vec![
705 proc(1, "small", 300),
706 proc(2, "biggest", 4000),
707 proc(3, "medium", 1000),
708 ];
709 prime(&mut e, &procs);
710 let actions = e.tick(1000, high(), &procs, &live_from(&procs));
711 assert_eq!(
713 freeze_targets(&actions),
714 vec!["/app.slice/app-biggest-2.scope"]
715 );
716 assert_eq!(e.level, Level::High);
717 }
718
719 #[test]
720 fn process_below_min_rss_is_never_selected() {
721 let mut e = PolicyEngine::new(cfg());
722 let procs = vec![proc(1, "tiny", 50), proc(2, "small", 150)];
724 let actions = e.tick(1000, high(), &procs, &live_from(&procs));
725 assert!(
726 freeze_targets(&actions).is_empty(),
727 "froze a sub-min-rss process: {actions:?}"
728 );
729 }
730
731 #[test]
732 fn frozen_process_thaws_after_freeze_hold() {
733 let mut e = PolicyEngine::new(cfg()); let procs = vec![proc(2, "hog", 4000)];
735 prime(&mut e, &procs);
736 let cg = "/app.slice/app-hog-2.scope";
737
738 let a0 = e.tick(0, high(), &procs, &live_from(&procs));
739 assert_eq!(freeze_targets(&a0), vec![cg]);
740
741 let a1 = e.tick(4_000, high(), &procs, &live_from(&procs));
743 assert!(!has_thaw_target(&a1, cg), "thawed too early: {a1:?}");
744
745 let a2 = e.tick(5_000, high(), &procs, &live_from(&procs));
747 assert!(has_thaw_target(&a2, cg), "expected thaw at hold: {a2:?}");
748 assert!(e.interventions().is_empty());
749 }
750
751 #[test]
752 fn still_high_within_cooldown_caps_instead_of_refreezing() {
753 let mut e = PolicyEngine::new(cfg()); let procs = vec![proc(2, "hog", 4000)];
755 prime(&mut e, &procs);
756 let cg = "/app.slice/app-hog-2.scope";
757
758 assert_eq!(
760 freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
761 vec![cg]
762 );
763 assert!(has_thaw_target(
765 &e.tick(5_000, high(), &procs, &live_from(&procs)),
766 cg
767 ));
768
769 let a = e.tick(10_000, high(), &procs, &live_from(&procs));
772 assert!(
773 has_cap_target(&a, cg),
774 "expected cap within cooldown: {a:?}"
775 );
776 assert!(freeze_targets(&a).is_empty(), "should not re-freeze: {a:?}");
777 assert!(matches!(
778 e.interventions().as_slice(),
779 [(c, Intervention::Capped { .. })] if c == cg
780 ));
781 }
782
783 #[test]
784 fn hysteresis_holds_level_between_fall_and_rise() {
785 let mut e = PolicyEngine::new(cfg());
786 let procs = vec![proc(2, "hog", 4000)];
787 let cg = "/app.slice/app-hog-2.scope";
788
789 e.tick(0, high(), &procs, &live_from(&procs));
791 assert_eq!(e.level, Level::High);
792
793 let a = e.tick(20_000, sample(20.0, 0.0, 8000), &procs, &live_from(&procs));
795 assert_eq!(e.level, Level::High, "dropped out of High prematurely");
796 assert!(
799 !has_liftcap_target(&a, cg),
800 "should not lift while still High: {a:?}"
801 );
802
803 e.tick(21_000, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
805 assert_eq!(e.level, Level::Warn);
806 }
807
808 #[test]
809 fn capped_process_lifted_only_after_sustained_calm() {
810 let mut e = PolicyEngine::new(cfg()); let procs = vec![proc(2, "hog", 4000)];
812 prime(&mut e, &procs);
813 let cg = "/app.slice/app-hog-2.scope";
814
815 e.tick(0, high(), &procs, &live_from(&procs));
817 e.tick(5_000, high(), &procs, &live_from(&procs)); let a = e.tick(10_000, high(), &procs, &live_from(&procs)); assert!(has_cap_target(&a, cg));
820
821 let a1 = e.tick(15_000, calm(), &procs, &live_from(&procs));
823 assert!(
824 !has_liftcap_target(&a1, cg),
825 "lifted before calm sustained: {a1:?}"
826 );
827 let a2 = e.tick(44_000, calm(), &procs, &live_from(&procs)); assert!(
829 !has_liftcap_target(&a2, cg),
830 "lifted just before hold: {a2:?}"
831 );
832
833 let a3 = e.tick(45_000, calm(), &procs, &live_from(&procs));
835 assert!(
836 has_liftcap_target(&a3, cg),
837 "expected lift after calm hold: {a3:?}"
838 );
839 assert!(e.interventions().is_empty());
840 }
841
842 #[test]
843 fn cap_lift_resets_if_calm_is_interrupted() {
844 let mut e = PolicyEngine::new(cfg());
845 let procs = vec![proc(2, "hog", 4000)];
846 prime(&mut e, &procs);
847 let cg = "/app.slice/app-hog-2.scope";
848 e.tick(0, high(), &procs, &live_from(&procs));
849 e.tick(5_000, high(), &procs, &live_from(&procs));
850 assert!(has_cap_target(
851 &e.tick(10_000, high(), &procs, &live_from(&procs)),
852 cg
853 ));
854
855 e.tick(15_000, calm(), &procs, &live_from(&procs)); e.tick(20_000, high(), &procs, &live_from(&procs)); e.tick(25_000, calm(), &procs, &live_from(&procs));
859 let a = e.tick(50_000, calm(), &procs, &live_from(&procs));
860 assert!(
861 !has_liftcap_target(&a, cg),
862 "calm clock should have reset: {a:?}"
863 );
864 }
865
866 #[test]
867 fn escalation_gate_limits_to_one_freeze_per_hold_window() {
868 let mut e = PolicyEngine::new(cfg());
869 let procs = vec![proc(1, "hog-a", 4000), proc(2, "hog-b", 3000)];
870 prime(&mut e, &procs);
871 let cg_a = "/app.slice/app-hog-a-1.scope";
872 let cg_b = "/app.slice/app-hog-b-2.scope";
873
874 let a0 = e.tick(0, high(), &procs, &live_from(&procs));
876 assert_eq!(freeze_targets(&a0), vec![cg_a]);
877
878 let a1 = e.tick(2_000, high(), &procs, &live_from(&procs));
880 assert!(
881 freeze_targets(&a1).is_empty(),
882 "gate should suppress second freeze: {a1:?}"
883 );
884
885 let a2 = e.tick(5_000, high(), &procs, &live_from(&procs));
887 assert_eq!(freeze_targets(&a2), vec![cg_b]);
888 }
889
890 #[test]
891 fn dead_pid_is_pruned_with_liftcap() {
892 let mut e = PolicyEngine::new(cfg());
893 let procs = vec![proc(2, "hog", 4000)];
894 prime(&mut e, &procs);
895 let cg = "/app.slice/app-hog-2.scope";
896
897 assert_eq!(
899 freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
900 vec![cg]
901 );
902 assert_eq!(e.interventions().len(), 1);
903
904 let a = e.tick(1_000, calm(), &[], &live_from(&[]));
907 assert!(
908 has_liftcap_target(&a, cg),
909 "expected LiftCap for dead cgroup: {a:?}"
910 );
911 assert!(e.interventions().is_empty());
912 }
913
914 #[test]
920 fn intervention_survives_in_live_cgroups_but_absent_from_procs() {
921 let mut e = PolicyEngine::new(cfg());
922 let procs = vec![proc(2, "hog", 4000)];
923 prime(&mut e, &procs);
924 let cg = "/app.slice/app-hog-2.scope";
925
926 assert_eq!(
928 freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
929 vec![cg]
930 );
931 assert_eq!(e.interventions().len(), 1);
932
933 let live: std::collections::HashSet<String> = [cg.to_string()].into();
937 let a1 = e.tick(1_000, calm(), &[], &live);
938 assert!(
939 !has_liftcap_target(&a1, cg),
940 "must not prune a cgroup still present in live_cgroups: {a1:?}"
941 );
942 assert_eq!(
943 e.interventions().len(),
944 1,
945 "intervention must survive while the cgroup is live"
946 );
947
948 let a2 = e.tick(2_000, calm(), &[], &std::collections::HashSet::new());
950 assert!(
951 has_liftcap_target(&a2, cg),
952 "expected LiftCap once absent from live_cgroups too: {a2:?}"
953 );
954 assert!(e.interventions().is_empty());
955 }
956
957 #[test]
958 fn interventions_reflect_state_sorted_by_cgroup() {
959 let mut e = PolicyEngine::new(cfg());
960 let procs = vec![proc(5, "a", 4000), proc(3, "b", 3500)];
965 prime(&mut e, &procs);
966 let cg_a = "/app.slice/app-a-5.scope";
967 let cg_b = "/app.slice/app-b-3.scope";
968
969 assert_eq!(
974 freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
975 vec![cg_a]
976 ); let a1 = e.tick(5_000, high(), &procs, &live_from(&procs)); assert!(has_thaw_target(&a1, cg_a));
979 assert_eq!(freeze_targets(&a1), vec![cg_b]);
980 let a2 = e.tick(10_000, high(), &procs, &live_from(&procs)); assert!(has_thaw_target(&a2, cg_b));
982 assert!(has_cap_target(&a2, cg_a));
983 let a3 = e.tick(15_000, high(), &procs, &live_from(&procs)); assert!(has_cap_target(&a3, cg_b));
985
986 let ivs = e.interventions();
987 assert_eq!(ivs.len(), 2, "expected a + b both Capped: {ivs:?}");
988 assert_eq!(ivs[0].0, cg_a);
990 assert_eq!(ivs[1].0, cg_b);
991 assert!(ivs
992 .iter()
993 .all(|(_, iv)| matches!(iv, Intervention::Capped { .. })));
994 }
995
996 #[test]
997 fn critical_via_mem_floor_triggers_action() {
998 let mut e = PolicyEngine::new(cfg());
999 let procs = vec![proc(2, "hog", 4000)];
1000 prime(&mut e, &procs);
1001 let a = e.tick(0, sample(0.0, 0.0, 100), &procs, &live_from(&procs));
1003 assert_eq!(e.level, Level::Critical);
1004 assert_eq!(freeze_targets(&a), vec!["/app.slice/app-hog-2.scope"]);
1005 }
1006
1007 #[test]
1008 fn notify_emitted_and_rate_limited() {
1009 let mut e = PolicyEngine::new(cfg());
1010 let procs = vec![proc(2, "hog", 4000)];
1011
1012 let a0 = e.tick(0, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1014 assert!(
1015 a0.iter().any(|x| matches!(x, Action::Notify { .. })),
1016 "expected a notify at Warn: {a0:?}"
1017 );
1018 assert!(freeze_targets(&a0).is_empty());
1019
1020 let a1 = e.tick(30_000, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1022 assert!(
1023 !a1.iter().any(|x| matches!(x, Action::Notify { .. })),
1024 "notify should be rate-limited: {a1:?}"
1025 );
1026
1027 let a2 = e.tick(60_000, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1029 assert!(a2.iter().any(|x| matches!(x, Action::Notify { .. })));
1030 }
1031
1032 #[test]
1033 fn notify_disabled_suppresses_notifications() {
1034 let mut c = cfg();
1035 c.notify = false;
1036 let mut e = PolicyEngine::new(c);
1037 let procs = vec![proc(2, "hog", 4000)];
1038 let a = e.tick(0, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1039 assert!(!a.iter().any(|x| matches!(x, Action::Notify { .. })));
1040 }
1041
1042 #[test]
1045 fn caponly_verdict_never_freezes() {
1046 let mut e = PolicyEngine::new(cfg());
1047 let mut p = proc_at(2, "script", 4000, "/app.slice/app-alacritty-9.scope");
1048 let r = &mut p.resolution;
1049 r.verdict = Verdict::CapOnly;
1050 r.coverage = Coverage::Partial;
1051 let procs = [p];
1052 prime(&mut e, &procs);
1053 let a = e.tick(0, high(), &procs, &live_from(&procs));
1054 assert!(
1055 freeze_targets(&a).is_empty(),
1056 "CapOnly must not freeze: {a:?}"
1057 );
1058 assert!(has_cap_target(&a, "/app.slice/app-alacritty-9.scope"));
1059 }
1060
1061 #[test]
1062 fn partial_action_waits_at_least_three_seconds() {
1063 let mut e = PolicyEngine::new(cfg());
1064 let mut term = target("python3", "/app.slice/term.scope", 4000);
1065 term.resolution.verdict = Verdict::CapOnly;
1066 term.resolution.coverage = Coverage::Partial;
1067 let ts = vec![term, target("hog", "/app.slice/hog.scope", 3000)];
1068 prime(&mut e, &ts);
1069 assert!(has_cap_target(
1070 &e.tick(0, high(), &ts, &live_from(&ts)),
1071 "/app.slice/term.scope"
1072 ));
1073 assert!(freeze_targets(&e.tick(1_000, high(), &ts, &live_from(&ts))).is_empty());
1074 assert!(freeze_targets(&e.tick(2_000, high(), &ts, &live_from(&ts))).is_empty());
1075 assert!(has_freeze_target(
1076 &e.tick(3_000, high(), &ts, &live_from(&ts)),
1077 "/app.slice/hog.scope"
1078 ));
1079 }
1080
1081 #[test]
1082 fn two_scopes_of_one_app_are_acted_on_together() {
1083 let mut e = PolicyEngine::new(cfg());
1084 let ts = vec![
1085 target("chrome", "/app.slice/app-chrome-1.scope", 1500),
1086 target("chrome", "/app.slice/app-chrome-2.scope", 900),
1087 target("hog", "/app.slice/app-hog-3.scope", 2000),
1088 ];
1089 prime(&mut e, &ts);
1090 let mut frozen = freeze_targets(&e.tick(0, high(), &ts, &live_from(&ts)));
1091 frozen.sort();
1092 assert_eq!(
1093 frozen,
1094 vec![
1095 "/app.slice/app-chrome-1.scope",
1096 "/app.slice/app-chrome-2.scope"
1097 ]
1098 );
1099 let a1 = e.tick(1_000, high(), &ts, &live_from(&ts));
1100 assert!(
1101 freeze_targets(&a1).is_empty(),
1102 "one app is one escalation step: {a1:?}"
1103 );
1104 }
1105
1106 #[test]
1107 fn fastest_growing_app_is_chosen_over_largest() {
1108 let mut e = PolicyEngine::new(cfg());
1109 let warn = sample(12.0, 0.0, 2_000);
1110 let t0 = vec![
1111 target("firefox", "/app.slice/ff.scope", 4000),
1112 target("script", "/app.slice/sh.scope", 1000),
1113 ];
1114 assert!(freeze_targets(&e.tick(0, warn, &t0, &live_from(&t0))).is_empty());
1115 let t1 = vec![
1116 target("firefox", "/app.slice/ff.scope", 4000),
1117 target("script", "/app.slice/sh.scope", 1600),
1118 ];
1119 assert_eq!(
1120 freeze_targets(&e.tick(1_000, high(), &t1, &live_from(&t1))),
1121 vec!["/app.slice/sh.scope"]
1122 );
1123 }
1124
1125 #[test]
1126 fn largest_app_is_the_fallback_when_nothing_grows() {
1127 let mut e = PolicyEngine::new(cfg());
1128 let ts = vec![
1129 target("small", "/app.slice/s.scope", 300),
1130 target("big", "/app.slice/b.scope", 3000),
1131 ];
1132 e.tick(0, sample(12.0, 0.0, 2_000), &ts, &live_from(&ts));
1133 assert_eq!(
1134 freeze_targets(&e.tick(1_000, high(), &ts, &live_from(&ts))),
1135 vec!["/app.slice/b.scope"]
1136 );
1137 }
1138
1139 #[test]
1140 fn at_most_three_apps_are_held_at_once() {
1141 let mut e = PolicyEngine::new(cfg());
1142 let ts: Vec<Target> = (0..5u64)
1143 .map(|i| {
1144 target(
1145 &format!("app{i}"),
1146 &format!("/app.slice/a{i}.scope"),
1147 1000 + i * 100,
1148 )
1149 })
1150 .collect();
1151 for step in 0..40u64 {
1152 e.tick(step * 5_000, high(), &ts, &live_from(&ts));
1153 assert!(
1154 e.interventions().len() <= MAX_HELD_APPS,
1155 "held {:?}",
1156 e.interventions()
1157 );
1158 }
1159 }
1160
1161 #[test]
1162 fn held_limit_counts_apps_not_cgroups() {
1163 let mut e = PolicyEngine::new(cfg());
1164 let ts = vec![
1165 target("a", "/app.slice/a1.scope", 2000),
1166 target("a", "/app.slice/a2.scope", 2000),
1167 target("b", "/app.slice/b.scope", 1500),
1168 target("c", "/app.slice/c.scope", 1200),
1169 target("d", "/app.slice/d.scope", 1100),
1170 ];
1171 for step in 0..40u64 {
1172 e.tick(step * 5_000, high(), &ts, &live_from(&ts));
1173 }
1174 let held: Vec<String> = e.interventions().into_iter().map(|(cg, _)| cg).collect();
1175 assert_eq!(
1176 held,
1177 vec![
1178 "/app.slice/a1.scope",
1179 "/app.slice/a2.scope",
1180 "/app.slice/b.scope",
1181 "/app.slice/c.scope"
1182 ],
1183 "4 cgroups across 3 apps are allowed, a 4th app is refused"
1184 );
1185 }
1186
1187 #[test]
1188 fn candidates_are_wanted_above_calm_or_when_scarce() {
1189 let e = PolicyEngine::new(cfg());
1190 assert!(!e.wants_candidates(calm()));
1191 assert!(e.wants_candidates(sample(12.0, 0.0, 8_000)));
1192 assert!(e.wants_candidates(sample(0.0, 0.0, 3_000)));
1194 }
1195
1196 #[test]
1201 fn cold_start_defers_then_picks_grower_not_largest() {
1202 let mut e = PolicyEngine::new(cfg());
1203 for step in 0..5u64 {
1204 e.tick(step * 1_000, calm(), &[], &HashSet::new());
1205 }
1206 let crit = sample(0.0, 0.0, 300);
1207 let t0 = vec![
1208 target("chrome", "/app.slice/chrome.scope", 6000),
1209 target("hog", "/app.slice/hog.scope", 3000),
1210 ];
1211 let a0 = e.tick(5_000, crit, &t0, &live_from(&t0));
1212 assert_eq!(e.level, Level::Critical);
1213 assert!(
1214 freeze_targets(&a0).is_empty(),
1215 "no growth data yet, must defer: {a0:?}"
1216 );
1217 let t1 = vec![
1218 target("chrome", "/app.slice/chrome.scope", 6000),
1219 target("hog", "/app.slice/hog.scope", 3200),
1220 ];
1221 assert_eq!(
1222 freeze_targets(&e.tick(6_000, crit, &t1, &live_from(&t1))),
1223 vec!["/app.slice/hog.scope"]
1224 );
1225 }
1226
1227 #[test]
1230 fn scarce_calm_ticks_warm_growth_for_first_critical_tick() {
1231 let mut e = PolicyEngine::new(cfg());
1232 let scarce_calm = sample(0.0, 0.0, 3_000);
1233 assert!(e.wants_candidates(scarce_calm));
1234 let t0 = vec![
1235 target("chrome", "/app.slice/chrome.scope", 6000),
1236 target("hog", "/app.slice/hog.scope", 2000),
1237 ];
1238 assert!(freeze_targets(&e.tick(0, scarce_calm, &t0, &live_from(&t0))).is_empty());
1239 let t1 = vec![
1240 target("chrome", "/app.slice/chrome.scope", 6000),
1241 target("hog", "/app.slice/hog.scope", 3000),
1242 ];
1243 assert_eq!(
1244 freeze_targets(&e.tick(1_000, sample(0.0, 0.0, 300), &t1, &live_from(&t1))),
1245 vec!["/app.slice/hog.scope"]
1246 );
1247 }
1248
1249 #[test]
1253 fn cold_start_deferral_is_bounded_when_cgroups_keep_changing() {
1254 let mut e = PolicyEngine::new(cfg());
1255 let crit = sample(0.0, 0.0, 300);
1256 for tick in 0..4u64 {
1257 let ts = vec![
1258 target(
1259 &format!("big{tick}"),
1260 &format!("/app.slice/b{tick}.scope"),
1261 3000,
1262 ),
1263 target(
1264 &format!("small{tick}"),
1265 &format!("/app.slice/s{tick}.scope"),
1266 1000,
1267 ),
1268 ];
1269 let frozen = freeze_targets(&e.tick(tick * 1_000, crit, &ts, &live_from(&ts)));
1270 if tick < u64::from(MAX_COLD_DEFER_TICKS) {
1271 assert!(frozen.is_empty(), "tick {tick} should defer: {frozen:?}");
1272 } else {
1273 assert_eq!(frozen, vec![format!("/app.slice/b{tick}.scope")]);
1274 }
1275 }
1276 }
1277
1278 #[test]
1281 fn no_current_bytes_does_not_defer() {
1282 let mut e = PolicyEngine::new(cfg());
1283 let mut big = target("big", "/app.slice/b.scope", 3000);
1284 big.current_bytes = None;
1285 let mut small = target("small", "/app.slice/s.scope", 1000);
1286 small.current_bytes = None;
1287 let ts = vec![big, small];
1288 assert_eq!(
1289 freeze_targets(&e.tick(0, high(), &ts, &live_from(&ts))),
1290 vec!["/app.slice/b.scope"]
1291 );
1292 }
1293}