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 rise_level(s: &Sample, t: &common::GuardTrigger) -> Level {
44 if s.full_avg10 >= t.psi_full_critical || s.mem_available_mb < t.mem_available_floor_mb {
45 Level::Critical
46 } else if s.some_avg10 >= t.psi_some_high || s.full_avg10 >= FULL_HIGH_RISE {
47 Level::High
48 } else if s.some_avg10 >= t.psi_some_warn {
49 Level::Warn
50 } else {
51 Level::Calm
52 }
53}
54
55pub fn is_scarce(s: &Sample, t: &common::GuardTrigger) -> bool {
59 s.mem_available_mb < t.mem_available_floor_mb
60 || (s.mem_total_mb > 0
61 && s.mem_available_mb.saturating_mul(100)
62 < s.mem_total_mb.saturating_mul(t.act_below_available_pct))
63}
64
65struct Growth {
67 last_bytes: u64,
68 last_ms: u64,
69 rate_bps: f64,
71 warm: bool,
74}
75
76pub struct PolicyEngine {
86 cfg: GuardConfig,
87 level: Level,
89 interventions: HashMap<String, (Intervention, Resolution, String)>,
93 last_freeze_ms: HashMap<String, u64>,
96 growth: HashMap<String, Growth>,
98 calm_since_ms: Option<u64>,
100 last_action_ms: Option<u64>,
103 last_action_partial: bool,
107 last_notify_ms: Option<u64>,
110 cold_defer_ticks: u32,
113}
114
115impl PolicyEngine {
116 pub fn new(cfg: GuardConfig) -> Self {
117 Self {
118 cfg,
119 level: Level::Calm,
120 interventions: HashMap::new(),
121 last_freeze_ms: HashMap::new(),
122 growth: HashMap::new(),
123 calm_since_ms: None,
124 last_action_ms: None,
125 last_action_partial: false,
126 last_notify_ms: None,
127 cold_defer_ticks: 0,
128 }
129 }
130
131 pub fn tick(
148 &mut self,
149 now_ms: u64,
150 sample: Sample,
151 targets: &[Target],
152 live_cgroups: &HashSet<String>,
153 ) -> Vec<Action> {
154 if !self.cfg.enabled {
156 return Vec::new();
157 }
158
159 let mut actions = Vec::new();
160
161 self.level = self.next_level(sample);
163 match self.level {
164 Level::Calm => {
165 if self.calm_since_ms.is_none() {
167 self.calm_since_ms = Some(now_ms);
168 }
169 self.cold_defer_ticks = 0;
170 }
171 _ => self.calm_since_ms = None,
172 }
173
174 self.update_growth(now_ms, targets);
176
177 let dead: Vec<String> = self
182 .interventions
183 .keys()
184 .filter(|cg| !live_cgroups.contains(cg.as_str()))
185 .cloned()
186 .collect();
187 for cg in dead {
188 let (_, res, _) = self.interventions.remove(&cg).expect("just found key");
189 actions.push(Action::LiftCap { res });
190 }
191
192 let freeze_hold_ms = self.cfg.timing.freeze_hold_secs.saturating_mul(1000);
194 let calm_hold_ms = self.cfg.timing.calm_hold_secs.saturating_mul(1000);
195 let mut recovered = Vec::new();
196 let mut thawed_apps: HashSet<String> = HashSet::new();
199 for (cg, (intervention, res, app)) in &self.interventions {
200 match *intervention {
201 Intervention::Frozen { since_ms } => {
202 if now_ms.saturating_sub(since_ms) >= freeze_hold_ms {
203 actions.push(Action::Thaw { res: res.clone() });
204 recovered.push(cg.clone());
205 thawed_apps.insert(app.clone());
206 }
207 }
208 Intervention::Capped { .. } => {
209 if self.level == Level::Calm {
213 if let Some(calm_since) = self.calm_since_ms {
214 if now_ms.saturating_sub(calm_since) >= calm_hold_ms {
215 actions.push(Action::LiftCap { res: res.clone() });
216 recovered.push(cg.clone());
217 }
218 }
219 }
220 }
221 }
222 }
223 for cg in recovered {
224 self.interventions.remove(&cg);
226 }
227
228 let mut victim_name: Option<String> = None;
232 if matches!(self.level, Level::High | Level::Critical)
233 && is_scarce(&sample, &self.cfg.trigger)
234 && self.held_apps().len() < MAX_HELD_APPS
235 {
236 let gate_ms = if self.last_action_partial {
240 PARTIAL_GATE_MS.min(freeze_hold_ms)
241 } else {
242 freeze_hold_ms
243 };
244 let gate_open = match self.last_action_ms {
245 None => true,
246 Some(last) => now_ms.saturating_sub(last) >= gate_ms,
247 };
248 if gate_open {
249 if let Some((app, members)) = self.select_app(targets, &thawed_apps) {
250 let cap_only = members
251 .iter()
252 .any(|m| m.resolution.verdict == Verdict::CapOnly);
253 let partial = members
254 .iter()
255 .any(|m| m.resolution.coverage == Coverage::Partial);
256 let cooldown_ms = self.cfg.timing.freeze_cooldown_secs.saturating_mul(1000);
257 let in_cooldown = self
258 .last_freeze_ms
259 .get(&app)
260 .is_some_and(|&last| now_ms.saturating_sub(last) < cooldown_ms);
261 let freeze = !cap_only && !in_cooldown;
264
265 for m in members {
266 let res = m.resolution.clone();
267 let intervention = if freeze {
268 actions.push(Action::Freeze {
269 res: res.clone(),
270 name: app.clone(),
271 });
272 Intervention::Frozen { since_ms: now_ms }
273 } else {
274 actions.push(Action::Cap {
275 res: res.clone(),
276 name: app.clone(),
277 });
278 Intervention::Capped { since_ms: now_ms }
279 };
280 self.interventions
281 .insert(res.cgroup.clone(), (intervention, res, app.clone()));
282 }
283 if freeze {
284 self.last_freeze_ms.insert(app.clone(), now_ms);
285 }
286 self.last_action_ms = Some(now_ms);
287 self.last_action_partial = partial;
288 victim_name = Some(app);
289 }
290 }
291 }
292
293 let notify_due = match self.last_notify_ms {
295 None => true,
296 Some(last) => now_ms.saturating_sub(last) >= NOTIFY_INTERVAL_MS,
297 };
298 if self.cfg.notify
299 && matches!(self.level, Level::Warn | Level::High | Level::Critical)
300 && notify_due
301 {
302 let message = match &victim_name {
303 Some(name) => format!(
304 "rlm-guard: memory pressure {:?}, acting on {}",
305 self.level, name
306 ),
307 None => format!("rlm-guard: memory pressure {:?}", self.level),
308 };
309 actions.push(Action::Notify { message });
310 self.last_notify_ms = Some(now_ms);
311 }
312
313 actions
314 }
315
316 pub fn wants_candidates(&self, sample: Sample) -> bool {
322 self.cfg.enabled
323 && (self.next_level(sample) != Level::Calm || is_scarce(&sample, &self.cfg.trigger))
324 }
325
326 pub fn level(&self) -> Level {
328 self.level
329 }
330
331 pub fn interventions(&self) -> Vec<(String, Intervention)> {
334 let mut out: Vec<(String, Intervention)> = self
335 .interventions
336 .iter()
337 .map(|(cg, (iv, _, _))| (cg.clone(), *iv))
338 .collect();
339 out.sort_by(|(a, _), (b, _)| a.cmp(b));
340 out
341 }
342
343 pub fn intervened_cgroups(&self) -> Vec<String> {
352 self.interventions.keys().cloned().collect()
353 }
354
355 fn held_apps(&self) -> HashSet<&str> {
357 self.interventions
358 .values()
359 .map(|(_, _, app)| app.as_str())
360 .collect()
361 }
362
363 fn next_level(&self, s: Sample) -> Level {
368 let t = &self.cfg.trigger;
369 let floor = t.mem_available_floor_mb;
370
371 let rise = rise_level(&s, t);
373 let crit_rise = rise == Level::Critical;
374 let high_rise = crit_rise || rise == Level::High;
375 let warn_rise = high_rise || rise == Level::Warn;
376
377 let warn_stay = s.some_avg10 >= t.psi_some_warn / 2.0;
379 let high_stay =
380 s.some_avg10 >= t.psi_some_high / 2.0 || s.full_avg10 >= FULL_HIGH_RISE / 2.0;
381 let crit_stay = s.full_avg10 >= t.psi_full_critical / 2.0 || s.mem_available_mb < floor;
382
383 let at_critical = self.level == Level::Critical;
387 let at_high = matches!(self.level, Level::High | Level::Critical);
388 let at_warn = matches!(self.level, Level::Warn | Level::High | Level::Critical);
389
390 if crit_rise || (at_critical && crit_stay) {
391 Level::Critical
392 } else if high_rise || (at_high && high_stay) {
393 Level::High
394 } else if warn_rise || (at_warn && warn_stay) {
395 Level::Warn
396 } else {
397 Level::Calm
398 }
399 }
400
401 fn update_growth(&mut self, now_ms: u64, targets: &[Target]) {
404 let mut seen = HashSet::new();
405 for t in targets {
406 let Some(cur) = t.current_bytes else {
407 continue;
408 };
409 let cg = &t.resolution.cgroup;
410 seen.insert(cg.clone());
411 match self.growth.get_mut(cg) {
412 Some(g) if now_ms > g.last_ms => {
413 let dt = (now_ms - g.last_ms) as f64 / 1000.0;
414 let inst = (cur as f64 - g.last_bytes as f64) / dt;
415 g.rate_bps = 0.5 * g.rate_bps + 0.5 * inst;
416 g.last_bytes = cur;
417 g.last_ms = now_ms;
418 g.warm = true;
419 }
420 Some(_) => {}
421 None => {
422 self.growth.insert(
423 cg.clone(),
424 Growth {
425 last_bytes: cur,
426 last_ms: now_ms,
427 rate_bps: 0.0,
428 warm: false,
429 },
430 );
431 }
432 }
433 }
434 self.growth.retain(|cg, _| seen.contains(cg));
435 }
436
437 fn select_app<'a>(
453 &mut self,
454 targets: &'a [Target],
455 blocked: &HashSet<String>,
456 ) -> Option<(String, Vec<&'a Target>)> {
457 let min_rss_kb = self.cfg.selection.min_rss_mb.saturating_mul(1024);
458 let held = self.held_apps();
459 let mut groups: BTreeMap<&str, Vec<&Target>> = BTreeMap::new();
460 for t in targets {
461 groups.entry(t.app.as_str()).or_default().push(t);
462 }
463 let eligible: Vec<(&str, Vec<&Target>)> = groups
464 .into_iter()
465 .filter(|(app, ms)| {
466 !held.contains(app)
467 && !blocked.contains(*app)
468 && ms.iter().any(|m| m.rss_kb >= min_rss_kb)
469 && ms
470 .iter()
471 .all(|m| !self.interventions.contains_key(&m.resolution.cgroup))
472 })
473 .collect();
474 let any_warm = eligible.iter().any(|(_, ms)| {
475 ms.iter().any(|m| {
476 self.growth
477 .get(&m.resolution.cgroup)
478 .is_some_and(|g| g.warm)
479 })
480 });
481 let any_cold = eligible.iter().any(|(_, ms)| {
482 ms.iter().any(|m| {
483 self.growth
484 .get(&m.resolution.cgroup)
485 .is_some_and(|g| !g.warm)
486 })
487 });
488 if !any_warm && any_cold && self.cold_defer_ticks < MAX_COLD_DEFER_TICKS {
489 self.cold_defer_ticks += 1;
490 return None;
491 }
492 self.cold_defer_ticks = 0;
493 let growth = |ms: &[&Target]| -> f64 {
494 ms.iter()
495 .map(|m| {
496 self.growth
497 .get(&m.resolution.cgroup)
498 .map_or(0.0, |g| g.rate_bps.max(0.0))
499 })
500 .sum()
501 };
502 let size = |ms: &[&Target]| -> u64 {
503 ms.iter()
504 .map(|m| m.current_bytes.unwrap_or(m.rss_kb.saturating_mul(1024)))
505 .sum()
506 };
507 let pick = eligible
508 .iter()
509 .filter(|(_, ms)| growth(ms) >= MIN_GROWTH_BPS)
510 .max_by(|a, b| {
511 growth(&a.1)
512 .total_cmp(&growth(&b.1))
513 .then_with(|| b.0.cmp(a.0))
514 })
515 .or_else(|| {
516 eligible
517 .iter()
518 .max_by(|a, b| size(&a.1).cmp(&size(&b.1)).then_with(|| b.0.cmp(a.0)))
519 })?;
520 Some((pick.0.to_string(), pick.1.clone()))
521 }
522}
523
524#[cfg(test)]
525mod tests {
526 use super::super::resolve::Mechanism;
527 use super::super::types::PsiSource;
528 use super::*;
529
530 fn cfg() -> GuardConfig {
532 GuardConfig::default()
533 }
534
535 fn sample(some: f64, full: f64, avail_mb: u64) -> Sample {
536 Sample {
537 some_avg10: some,
538 full_avg10: full,
539 mem_available_mb: avail_mb,
540 mem_total_mb: 16_000,
541 source: PsiSource::AppSlice,
542 }
543 }
544
545 fn res(cg: &str) -> Resolution {
547 Resolution {
548 cgroup: cg.into(),
549 unit: Some(format!("{}.scope", cg.rsplit('/').next().unwrap())),
550 verdict: Verdict::Freeze,
551 coverage: Coverage::Full,
552 mechanism: Mechanism::Unit,
553 }
554 }
555
556 const MIB: u64 = 1024 * 1024;
557
558 fn target(app: &str, cg: &str, mb: u64) -> Target {
561 Target {
562 app: app.into(),
563 resolution: res(cg),
564 rss_kb: mb * 1024,
565 current_bytes: Some(mb * MIB),
566 }
567 }
568
569 fn proc(pid: u32, name: &str, rss_mb: u64) -> Target {
571 target(name, &format!("/app.slice/app-{name}-{pid}.scope"), rss_mb)
572 }
573
574 fn proc_at(_pid: u32, name: &str, rss_mb: u64, cg: &str) -> Target {
575 target(name, cg, rss_mb)
576 }
577
578 fn calm() -> Sample {
580 sample(0.0, 0.0, 8000)
581 }
582
583 fn high() -> Sample {
585 sample(50.0, 0.0, 2_000)
586 }
587
588 #[test]
589 fn high_pressure_with_plenty_of_free_memory_never_escalates() {
590 let mut e = PolicyEngine::new(cfg());
591 let procs = vec![proc(2, "chrome", 4000)];
592 let a = e.tick(0, sample(80.0, 20.0, 8_000), &procs, &live_from(&procs));
594 assert_eq!(e.level, Level::Critical);
595 assert!(
596 !a.iter()
597 .any(|x| matches!(x, Action::Freeze { .. } | Action::Cap { .. })),
598 "escalated without scarcity: {a:?}"
599 );
600 }
601
602 #[test]
603 fn is_scarce_uses_floor_or_percentage() {
604 let t = common::GuardTrigger::default(); assert!(is_scarce(&sample(0.0, 0.0, 300), &t));
606 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 {
609 mem_total_mb: 0,
610 mem_available_mb: u64::MAX,
611 ..sample(0.0, 0.0, 0)
612 };
613 assert!(
614 !is_scarce(&unknown, &t),
615 "unreadable meminfo must not enable actions"
616 );
617 }
618
619 fn freeze_targets(actions: &[Action]) -> Vec<String> {
620 actions
621 .iter()
622 .filter_map(|a| match a {
623 Action::Freeze { res, .. } => Some(res.cgroup.clone()),
624 _ => None,
625 })
626 .collect()
627 }
628
629 fn has_cap_target(actions: &[Action], cg: &str) -> bool {
630 actions
631 .iter()
632 .any(|a| matches!(a, Action::Cap { res, .. } if res.cgroup == cg))
633 }
634
635 fn has_freeze_target(actions: &[Action], cg: &str) -> bool {
636 actions
637 .iter()
638 .any(|a| matches!(a, Action::Freeze { res, .. } if res.cgroup == cg))
639 }
640
641 fn has_thaw_target(actions: &[Action], cg: &str) -> bool {
642 actions
643 .iter()
644 .any(|a| matches!(a, Action::Thaw { res } if res.cgroup == cg))
645 }
646
647 fn has_liftcap_target(actions: &[Action], cg: &str) -> bool {
648 actions
649 .iter()
650 .any(|a| matches!(a, Action::LiftCap { res } if res.cgroup == cg))
651 }
652
653 fn prime(e: &mut PolicyEngine, ts: &[Target]) {
657 for t in ts {
658 e.growth.insert(
659 t.resolution.cgroup.clone(),
660 Growth {
661 last_bytes: t.current_bytes.unwrap_or(0),
662 last_ms: 0,
663 rate_bps: 0.0,
664 warm: true,
665 },
666 );
667 }
668 }
669
670 fn live_from(ts: &[Target]) -> std::collections::HashSet<String> {
673 ts.iter().map(|t| t.resolution.cgroup.clone()).collect()
674 }
675
676 #[test]
677 fn rise_level_follows_the_engine_rise_rules() {
678 let t = common::GuardTrigger::default();
679 assert_eq!(rise_level(&sample(0.0, 0.0, 8000), &t), Level::Calm);
680 assert_eq!(rise_level(&sample(12.0, 0.0, 8000), &t), Level::Warn);
681 assert_eq!(rise_level(&sample(5.0, 4.0, 2000), &t), Level::High);
682 assert_eq!(rise_level(&sample(31.0, 0.0, 8000), &t), Level::High);
683 assert_eq!(rise_level(&sample(0.0, 10.0, 8000), &t), Level::Critical);
684 assert_eq!(rise_level(&sample(0.0, 0.0, 300), &t), Level::Critical);
686 assert_eq!(rise_level(&sample(0.0, 0.0, u64::MAX), &t), Level::Calm);
688 for s in [
690 sample(5.0, 4.0, 2000),
691 sample(0.0, 0.0, 300),
692 sample(12.0, 0.0, 8000),
693 ] {
694 let e = PolicyEngine::new(cfg());
695 assert_eq!(e.next_level(s), rise_level(&s, &t), "{s:?}");
696 }
697 }
698
699 #[test]
700 fn calm_yields_no_actions() {
701 let mut e = PolicyEngine::new(cfg());
702 let procs = vec![proc(100, "firefox", 2000)];
703 let actions = e.tick(1000, calm(), &procs, &live_from(&procs));
704 assert!(actions.is_empty(), "calm produced actions: {actions:?}");
705 assert_eq!(e.level, Level::Calm);
706 }
707
708 #[test]
709 fn full_signal_has_fall_hysteresis() {
710 let mut e = PolicyEngine::new(cfg());
712 let procs = vec![proc(100, "firefox", 4000)];
713 e.tick(1_000, sample(0.0, 4.0, 8000), &procs, &live_from(&procs));
714 assert_eq!(e.level, Level::High, "full=4.0 should enter High");
715
716 e.tick(2_000, sample(0.0, 2.0, 8000), &procs, &live_from(&procs));
719 assert_eq!(
720 e.level,
721 Level::High,
722 "full=2.0 (between fall and rise) must hold High, not flap"
723 );
724
725 e.tick(3_000, sample(0.0, 1.0, 8000), &procs, &live_from(&procs));
727 assert_eq!(
728 e.level,
729 Level::Calm,
730 "full below fall threshold drops to Calm"
731 );
732 }
733
734 #[test]
735 fn disabled_engine_is_inert() {
736 let mut c = cfg();
737 c.enabled = false;
738 let mut e = PolicyEngine::new(c);
739 let procs = vec![proc(100, "firefox", 4000)];
740 assert!(e.tick(1000, high(), &procs, &live_from(&procs)).is_empty());
741 }
742
743 #[test]
744 fn high_freezes_largest_eligible_process() {
745 let mut e = PolicyEngine::new(cfg());
746 let procs = vec![
747 proc(1, "small", 300),
748 proc(2, "biggest", 4000),
749 proc(3, "medium", 1000),
750 ];
751 prime(&mut e, &procs);
752 let actions = e.tick(1000, high(), &procs, &live_from(&procs));
753 assert_eq!(
755 freeze_targets(&actions),
756 vec!["/app.slice/app-biggest-2.scope"]
757 );
758 assert_eq!(e.level, Level::High);
759 }
760
761 #[test]
762 fn process_below_min_rss_is_never_selected() {
763 let mut e = PolicyEngine::new(cfg());
764 let procs = vec![proc(1, "tiny", 50), proc(2, "small", 150)];
766 let actions = e.tick(1000, high(), &procs, &live_from(&procs));
767 assert!(
768 freeze_targets(&actions).is_empty(),
769 "froze a sub-min-rss process: {actions:?}"
770 );
771 }
772
773 #[test]
774 fn frozen_process_thaws_after_freeze_hold() {
775 let mut e = PolicyEngine::new(cfg()); let procs = vec![proc(2, "hog", 4000)];
777 prime(&mut e, &procs);
778 let cg = "/app.slice/app-hog-2.scope";
779
780 let a0 = e.tick(0, high(), &procs, &live_from(&procs));
781 assert_eq!(freeze_targets(&a0), vec![cg]);
782
783 let a1 = e.tick(4_000, high(), &procs, &live_from(&procs));
785 assert!(!has_thaw_target(&a1, cg), "thawed too early: {a1:?}");
786
787 let a2 = e.tick(5_000, high(), &procs, &live_from(&procs));
789 assert!(has_thaw_target(&a2, cg), "expected thaw at hold: {a2:?}");
790 assert!(e.interventions().is_empty());
791 }
792
793 #[test]
794 fn still_high_within_cooldown_caps_instead_of_refreezing() {
795 let mut e = PolicyEngine::new(cfg()); let procs = vec![proc(2, "hog", 4000)];
797 prime(&mut e, &procs);
798 let cg = "/app.slice/app-hog-2.scope";
799
800 assert_eq!(
802 freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
803 vec![cg]
804 );
805 assert!(has_thaw_target(
807 &e.tick(5_000, high(), &procs, &live_from(&procs)),
808 cg
809 ));
810
811 let a = e.tick(10_000, high(), &procs, &live_from(&procs));
814 assert!(
815 has_cap_target(&a, cg),
816 "expected cap within cooldown: {a:?}"
817 );
818 assert!(freeze_targets(&a).is_empty(), "should not re-freeze: {a:?}");
819 assert!(matches!(
820 e.interventions().as_slice(),
821 [(c, Intervention::Capped { .. })] if c == cg
822 ));
823 }
824
825 #[test]
826 fn hysteresis_holds_level_between_fall_and_rise() {
827 let mut e = PolicyEngine::new(cfg());
828 let procs = vec![proc(2, "hog", 4000)];
829 let cg = "/app.slice/app-hog-2.scope";
830
831 e.tick(0, high(), &procs, &live_from(&procs));
833 assert_eq!(e.level, Level::High);
834
835 let a = e.tick(20_000, sample(20.0, 0.0, 8000), &procs, &live_from(&procs));
837 assert_eq!(e.level, Level::High, "dropped out of High prematurely");
838 assert!(
841 !has_liftcap_target(&a, cg),
842 "should not lift while still High: {a:?}"
843 );
844
845 e.tick(21_000, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
847 assert_eq!(e.level, Level::Warn);
848 }
849
850 #[test]
851 fn capped_process_lifted_only_after_sustained_calm() {
852 let mut e = PolicyEngine::new(cfg()); let procs = vec![proc(2, "hog", 4000)];
854 prime(&mut e, &procs);
855 let cg = "/app.slice/app-hog-2.scope";
856
857 e.tick(0, high(), &procs, &live_from(&procs));
859 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));
862
863 let a1 = e.tick(15_000, calm(), &procs, &live_from(&procs));
865 assert!(
866 !has_liftcap_target(&a1, cg),
867 "lifted before calm sustained: {a1:?}"
868 );
869 let a2 = e.tick(44_000, calm(), &procs, &live_from(&procs)); assert!(
871 !has_liftcap_target(&a2, cg),
872 "lifted just before hold: {a2:?}"
873 );
874
875 let a3 = e.tick(45_000, calm(), &procs, &live_from(&procs));
877 assert!(
878 has_liftcap_target(&a3, cg),
879 "expected lift after calm hold: {a3:?}"
880 );
881 assert!(e.interventions().is_empty());
882 }
883
884 #[test]
885 fn cap_lift_resets_if_calm_is_interrupted() {
886 let mut e = PolicyEngine::new(cfg());
887 let procs = vec![proc(2, "hog", 4000)];
888 prime(&mut e, &procs);
889 let cg = "/app.slice/app-hog-2.scope";
890 e.tick(0, high(), &procs, &live_from(&procs));
891 e.tick(5_000, high(), &procs, &live_from(&procs));
892 assert!(has_cap_target(
893 &e.tick(10_000, high(), &procs, &live_from(&procs)),
894 cg
895 ));
896
897 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));
901 let a = e.tick(50_000, calm(), &procs, &live_from(&procs));
902 assert!(
903 !has_liftcap_target(&a, cg),
904 "calm clock should have reset: {a:?}"
905 );
906 }
907
908 #[test]
909 fn escalation_gate_limits_to_one_freeze_per_hold_window() {
910 let mut e = PolicyEngine::new(cfg());
911 let procs = vec![proc(1, "hog-a", 4000), proc(2, "hog-b", 3000)];
912 prime(&mut e, &procs);
913 let cg_a = "/app.slice/app-hog-a-1.scope";
914 let cg_b = "/app.slice/app-hog-b-2.scope";
915
916 let a0 = e.tick(0, high(), &procs, &live_from(&procs));
918 assert_eq!(freeze_targets(&a0), vec![cg_a]);
919
920 let a1 = e.tick(2_000, high(), &procs, &live_from(&procs));
922 assert!(
923 freeze_targets(&a1).is_empty(),
924 "gate should suppress second freeze: {a1:?}"
925 );
926
927 let a2 = e.tick(5_000, high(), &procs, &live_from(&procs));
929 assert_eq!(freeze_targets(&a2), vec![cg_b]);
930 }
931
932 #[test]
933 fn dead_pid_is_pruned_with_liftcap() {
934 let mut e = PolicyEngine::new(cfg());
935 let procs = vec![proc(2, "hog", 4000)];
936 prime(&mut e, &procs);
937 let cg = "/app.slice/app-hog-2.scope";
938
939 assert_eq!(
941 freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
942 vec![cg]
943 );
944 assert_eq!(e.interventions().len(), 1);
945
946 let a = e.tick(1_000, calm(), &[], &live_from(&[]));
949 assert!(
950 has_liftcap_target(&a, cg),
951 "expected LiftCap for dead cgroup: {a:?}"
952 );
953 assert!(e.interventions().is_empty());
954 }
955
956 #[test]
962 fn intervention_survives_in_live_cgroups_but_absent_from_procs() {
963 let mut e = PolicyEngine::new(cfg());
964 let procs = vec![proc(2, "hog", 4000)];
965 prime(&mut e, &procs);
966 let cg = "/app.slice/app-hog-2.scope";
967
968 assert_eq!(
970 freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
971 vec![cg]
972 );
973 assert_eq!(e.interventions().len(), 1);
974
975 let live: std::collections::HashSet<String> = [cg.to_string()].into();
979 let a1 = e.tick(1_000, calm(), &[], &live);
980 assert!(
981 !has_liftcap_target(&a1, cg),
982 "must not prune a cgroup still present in live_cgroups: {a1:?}"
983 );
984 assert_eq!(
985 e.interventions().len(),
986 1,
987 "intervention must survive while the cgroup is live"
988 );
989
990 let a2 = e.tick(2_000, calm(), &[], &std::collections::HashSet::new());
992 assert!(
993 has_liftcap_target(&a2, cg),
994 "expected LiftCap once absent from live_cgroups too: {a2:?}"
995 );
996 assert!(e.interventions().is_empty());
997 }
998
999 #[test]
1000 fn interventions_reflect_state_sorted_by_cgroup() {
1001 let mut e = PolicyEngine::new(cfg());
1002 let procs = vec![proc(5, "a", 4000), proc(3, "b", 3500)];
1007 prime(&mut e, &procs);
1008 let cg_a = "/app.slice/app-a-5.scope";
1009 let cg_b = "/app.slice/app-b-3.scope";
1010
1011 assert_eq!(
1016 freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
1017 vec![cg_a]
1018 ); let a1 = e.tick(5_000, high(), &procs, &live_from(&procs)); assert!(has_thaw_target(&a1, cg_a));
1021 assert_eq!(freeze_targets(&a1), vec![cg_b]);
1022 let a2 = e.tick(10_000, high(), &procs, &live_from(&procs)); assert!(has_thaw_target(&a2, cg_b));
1024 assert!(has_cap_target(&a2, cg_a));
1025 let a3 = e.tick(15_000, high(), &procs, &live_from(&procs)); assert!(has_cap_target(&a3, cg_b));
1027
1028 let ivs = e.interventions();
1029 assert_eq!(ivs.len(), 2, "expected a + b both Capped: {ivs:?}");
1030 assert_eq!(ivs[0].0, cg_a);
1032 assert_eq!(ivs[1].0, cg_b);
1033 assert!(ivs
1034 .iter()
1035 .all(|(_, iv)| matches!(iv, Intervention::Capped { .. })));
1036 }
1037
1038 #[test]
1039 fn critical_via_mem_floor_triggers_action() {
1040 let mut e = PolicyEngine::new(cfg());
1041 let procs = vec![proc(2, "hog", 4000)];
1042 prime(&mut e, &procs);
1043 let a = e.tick(0, sample(0.0, 0.0, 100), &procs, &live_from(&procs));
1045 assert_eq!(e.level, Level::Critical);
1046 assert_eq!(freeze_targets(&a), vec!["/app.slice/app-hog-2.scope"]);
1047 }
1048
1049 #[test]
1050 fn notify_emitted_and_rate_limited() {
1051 let mut e = PolicyEngine::new(cfg());
1052 let procs = vec![proc(2, "hog", 4000)];
1053
1054 let a0 = e.tick(0, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1056 assert!(
1057 a0.iter().any(|x| matches!(x, Action::Notify { .. })),
1058 "expected a notify at Warn: {a0:?}"
1059 );
1060 assert!(freeze_targets(&a0).is_empty());
1061
1062 let a1 = e.tick(30_000, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1064 assert!(
1065 !a1.iter().any(|x| matches!(x, Action::Notify { .. })),
1066 "notify should be rate-limited: {a1:?}"
1067 );
1068
1069 let a2 = e.tick(60_000, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1071 assert!(a2.iter().any(|x| matches!(x, Action::Notify { .. })));
1072 }
1073
1074 #[test]
1075 fn notify_disabled_suppresses_notifications() {
1076 let mut c = cfg();
1077 c.notify = false;
1078 let mut e = PolicyEngine::new(c);
1079 let procs = vec![proc(2, "hog", 4000)];
1080 let a = e.tick(0, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1081 assert!(!a.iter().any(|x| matches!(x, Action::Notify { .. })));
1082 }
1083
1084 #[test]
1087 fn caponly_verdict_never_freezes() {
1088 let mut e = PolicyEngine::new(cfg());
1089 let mut p = proc_at(2, "script", 4000, "/app.slice/app-alacritty-9.scope");
1090 let r = &mut p.resolution;
1091 r.verdict = Verdict::CapOnly;
1092 r.coverage = Coverage::Partial;
1093 let procs = [p];
1094 prime(&mut e, &procs);
1095 let a = e.tick(0, high(), &procs, &live_from(&procs));
1096 assert!(
1097 freeze_targets(&a).is_empty(),
1098 "CapOnly must not freeze: {a:?}"
1099 );
1100 assert!(has_cap_target(&a, "/app.slice/app-alacritty-9.scope"));
1101 }
1102
1103 #[test]
1104 fn partial_action_waits_at_least_three_seconds() {
1105 let mut e = PolicyEngine::new(cfg());
1106 let mut term = target("python3", "/app.slice/term.scope", 4000);
1107 term.resolution.verdict = Verdict::CapOnly;
1108 term.resolution.coverage = Coverage::Partial;
1109 let ts = vec![term, target("hog", "/app.slice/hog.scope", 3000)];
1110 prime(&mut e, &ts);
1111 assert!(has_cap_target(
1112 &e.tick(0, high(), &ts, &live_from(&ts)),
1113 "/app.slice/term.scope"
1114 ));
1115 assert!(freeze_targets(&e.tick(1_000, high(), &ts, &live_from(&ts))).is_empty());
1116 assert!(freeze_targets(&e.tick(2_000, high(), &ts, &live_from(&ts))).is_empty());
1117 assert!(has_freeze_target(
1118 &e.tick(3_000, high(), &ts, &live_from(&ts)),
1119 "/app.slice/hog.scope"
1120 ));
1121 }
1122
1123 #[test]
1124 fn two_scopes_of_one_app_are_acted_on_together() {
1125 let mut e = PolicyEngine::new(cfg());
1126 let ts = vec![
1127 target("chrome", "/app.slice/app-chrome-1.scope", 1500),
1128 target("chrome", "/app.slice/app-chrome-2.scope", 900),
1129 target("hog", "/app.slice/app-hog-3.scope", 2000),
1130 ];
1131 prime(&mut e, &ts);
1132 let mut frozen = freeze_targets(&e.tick(0, high(), &ts, &live_from(&ts)));
1133 frozen.sort();
1134 assert_eq!(
1135 frozen,
1136 vec![
1137 "/app.slice/app-chrome-1.scope",
1138 "/app.slice/app-chrome-2.scope"
1139 ]
1140 );
1141 let a1 = e.tick(1_000, high(), &ts, &live_from(&ts));
1142 assert!(
1143 freeze_targets(&a1).is_empty(),
1144 "one app is one escalation step: {a1:?}"
1145 );
1146 }
1147
1148 #[test]
1149 fn fastest_growing_app_is_chosen_over_largest() {
1150 let mut e = PolicyEngine::new(cfg());
1151 let warn = sample(12.0, 0.0, 2_000);
1152 let t0 = vec![
1153 target("firefox", "/app.slice/ff.scope", 4000),
1154 target("script", "/app.slice/sh.scope", 1000),
1155 ];
1156 assert!(freeze_targets(&e.tick(0, warn, &t0, &live_from(&t0))).is_empty());
1157 let t1 = vec![
1158 target("firefox", "/app.slice/ff.scope", 4000),
1159 target("script", "/app.slice/sh.scope", 1600),
1160 ];
1161 assert_eq!(
1162 freeze_targets(&e.tick(1_000, high(), &t1, &live_from(&t1))),
1163 vec!["/app.slice/sh.scope"]
1164 );
1165 }
1166
1167 #[test]
1168 fn largest_app_is_the_fallback_when_nothing_grows() {
1169 let mut e = PolicyEngine::new(cfg());
1170 let ts = vec![
1171 target("small", "/app.slice/s.scope", 300),
1172 target("big", "/app.slice/b.scope", 3000),
1173 ];
1174 e.tick(0, sample(12.0, 0.0, 2_000), &ts, &live_from(&ts));
1175 assert_eq!(
1176 freeze_targets(&e.tick(1_000, high(), &ts, &live_from(&ts))),
1177 vec!["/app.slice/b.scope"]
1178 );
1179 }
1180
1181 #[test]
1182 fn at_most_three_apps_are_held_at_once() {
1183 let mut e = PolicyEngine::new(cfg());
1184 let ts: Vec<Target> = (0..5u64)
1185 .map(|i| {
1186 target(
1187 &format!("app{i}"),
1188 &format!("/app.slice/a{i}.scope"),
1189 1000 + i * 100,
1190 )
1191 })
1192 .collect();
1193 for step in 0..40u64 {
1194 e.tick(step * 5_000, high(), &ts, &live_from(&ts));
1195 assert!(
1196 e.interventions().len() <= MAX_HELD_APPS,
1197 "held {:?}",
1198 e.interventions()
1199 );
1200 }
1201 }
1202
1203 #[test]
1204 fn held_limit_counts_apps_not_cgroups() {
1205 let mut e = PolicyEngine::new(cfg());
1206 let ts = vec![
1207 target("a", "/app.slice/a1.scope", 2000),
1208 target("a", "/app.slice/a2.scope", 2000),
1209 target("b", "/app.slice/b.scope", 1500),
1210 target("c", "/app.slice/c.scope", 1200),
1211 target("d", "/app.slice/d.scope", 1100),
1212 ];
1213 for step in 0..40u64 {
1214 e.tick(step * 5_000, high(), &ts, &live_from(&ts));
1215 }
1216 let held: Vec<String> = e.interventions().into_iter().map(|(cg, _)| cg).collect();
1217 assert_eq!(
1218 held,
1219 vec![
1220 "/app.slice/a1.scope",
1221 "/app.slice/a2.scope",
1222 "/app.slice/b.scope",
1223 "/app.slice/c.scope"
1224 ],
1225 "4 cgroups across 3 apps are allowed, a 4th app is refused"
1226 );
1227 }
1228
1229 #[test]
1230 fn candidates_are_wanted_above_calm_or_when_scarce() {
1231 let e = PolicyEngine::new(cfg());
1232 assert!(!e.wants_candidates(calm()));
1233 assert!(e.wants_candidates(sample(12.0, 0.0, 8_000)));
1234 assert!(e.wants_candidates(sample(0.0, 0.0, 3_000)));
1236 }
1237
1238 #[test]
1243 fn cold_start_defers_then_picks_grower_not_largest() {
1244 let mut e = PolicyEngine::new(cfg());
1245 for step in 0..5u64 {
1246 e.tick(step * 1_000, calm(), &[], &HashSet::new());
1247 }
1248 let crit = sample(0.0, 0.0, 300);
1249 let t0 = vec![
1250 target("chrome", "/app.slice/chrome.scope", 6000),
1251 target("hog", "/app.slice/hog.scope", 3000),
1252 ];
1253 let a0 = e.tick(5_000, crit, &t0, &live_from(&t0));
1254 assert_eq!(e.level, Level::Critical);
1255 assert!(
1256 freeze_targets(&a0).is_empty(),
1257 "no growth data yet, must defer: {a0:?}"
1258 );
1259 let t1 = vec![
1260 target("chrome", "/app.slice/chrome.scope", 6000),
1261 target("hog", "/app.slice/hog.scope", 3200),
1262 ];
1263 assert_eq!(
1264 freeze_targets(&e.tick(6_000, crit, &t1, &live_from(&t1))),
1265 vec!["/app.slice/hog.scope"]
1266 );
1267 }
1268
1269 #[test]
1272 fn scarce_calm_ticks_warm_growth_for_first_critical_tick() {
1273 let mut e = PolicyEngine::new(cfg());
1274 let scarce_calm = sample(0.0, 0.0, 3_000);
1275 assert!(e.wants_candidates(scarce_calm));
1276 let t0 = vec![
1277 target("chrome", "/app.slice/chrome.scope", 6000),
1278 target("hog", "/app.slice/hog.scope", 2000),
1279 ];
1280 assert!(freeze_targets(&e.tick(0, scarce_calm, &t0, &live_from(&t0))).is_empty());
1281 let t1 = vec![
1282 target("chrome", "/app.slice/chrome.scope", 6000),
1283 target("hog", "/app.slice/hog.scope", 3000),
1284 ];
1285 assert_eq!(
1286 freeze_targets(&e.tick(1_000, sample(0.0, 0.0, 300), &t1, &live_from(&t1))),
1287 vec!["/app.slice/hog.scope"]
1288 );
1289 }
1290
1291 #[test]
1295 fn cold_start_deferral_is_bounded_when_cgroups_keep_changing() {
1296 let mut e = PolicyEngine::new(cfg());
1297 let crit = sample(0.0, 0.0, 300);
1298 for tick in 0..4u64 {
1299 let ts = vec![
1300 target(
1301 &format!("big{tick}"),
1302 &format!("/app.slice/b{tick}.scope"),
1303 3000,
1304 ),
1305 target(
1306 &format!("small{tick}"),
1307 &format!("/app.slice/s{tick}.scope"),
1308 1000,
1309 ),
1310 ];
1311 let frozen = freeze_targets(&e.tick(tick * 1_000, crit, &ts, &live_from(&ts)));
1312 if tick < u64::from(MAX_COLD_DEFER_TICKS) {
1313 assert!(frozen.is_empty(), "tick {tick} should defer: {frozen:?}");
1314 } else {
1315 assert_eq!(frozen, vec![format!("/app.slice/b{tick}.scope")]);
1316 }
1317 }
1318 }
1319
1320 #[test]
1323 fn no_current_bytes_does_not_defer() {
1324 let mut e = PolicyEngine::new(cfg());
1325 let mut big = target("big", "/app.slice/b.scope", 3000);
1326 big.current_bytes = None;
1327 let mut small = target("small", "/app.slice/s.scope", 1000);
1328 small.current_bytes = None;
1329 let ts = vec![big, small];
1330 assert_eq!(
1331 freeze_targets(&e.tick(0, high(), &ts, &live_from(&ts))),
1332 vec!["/app.slice/b.scope"]
1333 );
1334 }
1335}