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;
32
33pub fn is_scarce(s: &Sample, t: &common::GuardTrigger) -> bool {
37 s.mem_available_mb < t.mem_available_floor_mb
38 || (s.mem_total_mb > 0
39 && s.mem_available_mb.saturating_mul(100)
40 < s.mem_total_mb.saturating_mul(t.act_below_available_pct))
41}
42
43struct Growth {
45 last_bytes: u64,
46 last_ms: u64,
47 rate_bps: f64,
49 warm: bool,
52}
53
54pub struct PolicyEngine {
64 cfg: GuardConfig,
65 level: Level,
67 interventions: HashMap<String, (Intervention, Resolution, String)>,
71 last_freeze_ms: HashMap<String, u64>,
74 growth: HashMap<String, Growth>,
76 calm_since_ms: Option<u64>,
78 last_action_ms: Option<u64>,
81 last_action_partial: bool,
85 last_notify_ms: Option<u64>,
88}
89
90impl PolicyEngine {
91 pub fn new(cfg: GuardConfig) -> Self {
92 Self {
93 cfg,
94 level: Level::Calm,
95 interventions: HashMap::new(),
96 last_freeze_ms: HashMap::new(),
97 growth: HashMap::new(),
98 calm_since_ms: None,
99 last_action_ms: None,
100 last_action_partial: false,
101 last_notify_ms: None,
102 }
103 }
104
105 pub fn tick(
122 &mut self,
123 now_ms: u64,
124 sample: Sample,
125 targets: &[Target],
126 live_cgroups: &HashSet<String>,
127 ) -> Vec<Action> {
128 if !self.cfg.enabled {
130 return Vec::new();
131 }
132
133 let mut actions = Vec::new();
134
135 self.level = self.next_level(sample);
137 match self.level {
138 Level::Calm => {
139 if self.calm_since_ms.is_none() {
141 self.calm_since_ms = Some(now_ms);
142 }
143 }
144 _ => self.calm_since_ms = None,
145 }
146
147 self.update_growth(now_ms, targets);
149
150 let dead: Vec<String> = self
155 .interventions
156 .keys()
157 .filter(|cg| !live_cgroups.contains(cg.as_str()))
158 .cloned()
159 .collect();
160 for cg in dead {
161 let (_, res, _) = self.interventions.remove(&cg).expect("just found key");
162 actions.push(Action::LiftCap { res });
163 }
164
165 let freeze_hold_ms = self.cfg.timing.freeze_hold_secs * 1000;
167 let calm_hold_ms = self.cfg.timing.calm_hold_secs * 1000;
168 let mut recovered = Vec::new();
169 let mut thawed_apps: HashSet<String> = HashSet::new();
172 for (cg, (intervention, res, app)) in &self.interventions {
173 match *intervention {
174 Intervention::Frozen { since_ms } => {
175 if now_ms.saturating_sub(since_ms) >= freeze_hold_ms {
176 actions.push(Action::Thaw { res: res.clone() });
177 recovered.push(cg.clone());
178 thawed_apps.insert(app.clone());
179 }
180 }
181 Intervention::Capped { .. } => {
182 if self.level == Level::Calm {
186 if let Some(calm_since) = self.calm_since_ms {
187 if now_ms.saturating_sub(calm_since) >= calm_hold_ms {
188 actions.push(Action::LiftCap { res: res.clone() });
189 recovered.push(cg.clone());
190 }
191 }
192 }
193 }
194 }
195 }
196 for cg in recovered {
197 self.interventions.remove(&cg);
199 }
200
201 let mut victim_name: Option<String> = None;
205 if matches!(self.level, Level::High | Level::Critical)
206 && is_scarce(&sample, &self.cfg.trigger)
207 && self.held_apps().len() < MAX_HELD_APPS
208 {
209 let gate_ms = if self.last_action_partial {
213 PARTIAL_GATE_MS.min(freeze_hold_ms)
214 } else {
215 freeze_hold_ms
216 };
217 let gate_open = match self.last_action_ms {
218 None => true,
219 Some(last) => now_ms.saturating_sub(last) >= gate_ms,
220 };
221 if gate_open {
222 if let Some((app, members)) = self.select_app(targets, &thawed_apps) {
223 let cap_only = members
224 .iter()
225 .any(|m| m.resolution.verdict == Verdict::CapOnly);
226 let partial = members
227 .iter()
228 .any(|m| m.resolution.coverage == Coverage::Partial);
229 let cooldown_ms = self.cfg.timing.freeze_cooldown_secs * 1000;
230 let in_cooldown = self
231 .last_freeze_ms
232 .get(&app)
233 .is_some_and(|&last| now_ms.saturating_sub(last) < cooldown_ms);
234 let freeze = !cap_only && !in_cooldown;
237
238 for m in members {
239 let res = m.resolution.clone();
240 let intervention = if freeze {
241 actions.push(Action::Freeze {
242 res: res.clone(),
243 name: app.clone(),
244 });
245 Intervention::Frozen { since_ms: now_ms }
246 } else {
247 actions.push(Action::Cap {
248 res: res.clone(),
249 name: app.clone(),
250 });
251 Intervention::Capped { since_ms: now_ms }
252 };
253 self.interventions
254 .insert(res.cgroup.clone(), (intervention, res, app.clone()));
255 }
256 if freeze {
257 self.last_freeze_ms.insert(app.clone(), now_ms);
258 }
259 self.last_action_ms = Some(now_ms);
260 self.last_action_partial = partial;
261 victim_name = Some(app);
262 }
263 }
264 }
265
266 let notify_due = match self.last_notify_ms {
268 None => true,
269 Some(last) => now_ms.saturating_sub(last) >= NOTIFY_INTERVAL_MS,
270 };
271 if self.cfg.notify
272 && matches!(self.level, Level::Warn | Level::High | Level::Critical)
273 && notify_due
274 {
275 let message = match &victim_name {
276 Some(name) => format!(
277 "rlm-guard: memory pressure {:?}, acting on {}",
278 self.level, name
279 ),
280 None => format!("rlm-guard: memory pressure {:?}", self.level),
281 };
282 actions.push(Action::Notify { message });
283 self.last_notify_ms = Some(now_ms);
284 }
285
286 actions
287 }
288
289 pub fn wants_candidates(&self, sample: Sample) -> bool {
295 self.cfg.enabled
296 && (self.next_level(sample) != Level::Calm || is_scarce(&sample, &self.cfg.trigger))
297 }
298
299 pub fn level(&self) -> Level {
301 self.level
302 }
303
304 pub fn interventions(&self) -> Vec<(String, Intervention)> {
307 let mut out: Vec<(String, Intervention)> = self
308 .interventions
309 .iter()
310 .map(|(cg, (iv, _, _))| (cg.clone(), *iv))
311 .collect();
312 out.sort_by(|(a, _), (b, _)| a.cmp(b));
313 out
314 }
315
316 pub fn intervened_cgroups(&self) -> Vec<String> {
325 self.interventions.keys().cloned().collect()
326 }
327
328 fn held_apps(&self) -> HashSet<&str> {
330 self.interventions
331 .values()
332 .map(|(_, _, app)| app.as_str())
333 .collect()
334 }
335
336 fn next_level(&self, s: Sample) -> Level {
341 let t = &self.cfg.trigger;
342 let floor = t.mem_available_floor_mb;
343
344 let warn_rise = s.some_avg10 >= t.psi_some_warn;
346 let high_rise = s.some_avg10 >= t.psi_some_high || s.full_avg10 >= FULL_HIGH_RISE;
347 let crit_rise = s.full_avg10 >= t.psi_full_critical || s.mem_available_mb < floor;
348
349 let warn_stay = s.some_avg10 >= t.psi_some_warn / 2.0;
351 let high_stay =
352 s.some_avg10 >= t.psi_some_high / 2.0 || s.full_avg10 >= FULL_HIGH_RISE / 2.0;
353 let crit_stay = s.full_avg10 >= t.psi_full_critical / 2.0 || s.mem_available_mb < floor;
354
355 let at_critical = self.level == Level::Critical;
359 let at_high = matches!(self.level, Level::High | Level::Critical);
360 let at_warn = matches!(self.level, Level::Warn | Level::High | Level::Critical);
361
362 if crit_rise || (at_critical && crit_stay) {
363 Level::Critical
364 } else if high_rise || (at_high && high_stay) {
365 Level::High
366 } else if warn_rise || (at_warn && warn_stay) {
367 Level::Warn
368 } else {
369 Level::Calm
370 }
371 }
372
373 fn update_growth(&mut self, now_ms: u64, targets: &[Target]) {
376 let mut seen = HashSet::new();
377 for t in targets {
378 let Some(cur) = t.current_bytes else {
379 continue;
380 };
381 let cg = &t.resolution.cgroup;
382 seen.insert(cg.clone());
383 match self.growth.get_mut(cg) {
384 Some(g) if now_ms > g.last_ms => {
385 let dt = (now_ms - g.last_ms) as f64 / 1000.0;
386 let inst = (cur as f64 - g.last_bytes as f64) / dt;
387 g.rate_bps = 0.5 * g.rate_bps + 0.5 * inst;
388 g.last_bytes = cur;
389 g.last_ms = now_ms;
390 g.warm = true;
391 }
392 Some(_) => {}
393 None => {
394 self.growth.insert(
395 cg.clone(),
396 Growth {
397 last_bytes: cur,
398 last_ms: now_ms,
399 rate_bps: 0.0,
400 warm: false,
401 },
402 );
403 }
404 }
405 }
406 self.growth.retain(|cg, _| seen.contains(cg));
407 }
408
409 fn select_app<'a>(
424 &self,
425 targets: &'a [Target],
426 blocked: &HashSet<String>,
427 ) -> Option<(String, Vec<&'a Target>)> {
428 let min_rss_kb = self.cfg.selection.min_rss_mb * 1024;
429 let held = self.held_apps();
430 let mut groups: BTreeMap<&str, Vec<&Target>> = BTreeMap::new();
431 for t in targets {
432 groups.entry(t.app.as_str()).or_default().push(t);
433 }
434 let eligible: Vec<(&str, Vec<&Target>)> = groups
435 .into_iter()
436 .filter(|(app, ms)| {
437 !held.contains(app)
438 && !blocked.contains(*app)
439 && ms.iter().any(|m| m.rss_kb >= min_rss_kb)
440 && ms
441 .iter()
442 .all(|m| !self.interventions.contains_key(&m.resolution.cgroup))
443 })
444 .collect();
445 let any_warm = eligible.iter().any(|(_, ms)| {
446 ms.iter().any(|m| {
447 self.growth
448 .get(&m.resolution.cgroup)
449 .is_some_and(|g| g.warm)
450 })
451 });
452 let any_cold = eligible.iter().any(|(_, ms)| {
453 ms.iter().any(|m| {
454 self.growth
455 .get(&m.resolution.cgroup)
456 .is_some_and(|g| !g.warm)
457 })
458 });
459 if !any_warm && any_cold {
460 return None;
461 }
462 let growth = |ms: &[&Target]| -> f64 {
463 ms.iter()
464 .map(|m| {
465 self.growth
466 .get(&m.resolution.cgroup)
467 .map_or(0.0, |g| g.rate_bps.max(0.0))
468 })
469 .sum()
470 };
471 let size = |ms: &[&Target]| -> u64 {
472 ms.iter()
473 .map(|m| m.current_bytes.unwrap_or(m.rss_kb * 1024))
474 .sum()
475 };
476 let pick = eligible
477 .iter()
478 .filter(|(_, ms)| growth(ms) >= MIN_GROWTH_BPS)
479 .max_by(|a, b| {
480 growth(&a.1)
481 .total_cmp(&growth(&b.1))
482 .then_with(|| b.0.cmp(a.0))
483 })
484 .or_else(|| {
485 eligible
486 .iter()
487 .max_by(|a, b| size(&a.1).cmp(&size(&b.1)).then_with(|| b.0.cmp(a.0)))
488 })?;
489 Some((pick.0.to_string(), pick.1.clone()))
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::super::resolve::Mechanism;
496 use super::super::types::PsiSource;
497 use super::*;
498
499 fn cfg() -> GuardConfig {
501 GuardConfig::default()
502 }
503
504 fn sample(some: f64, full: f64, avail_mb: u64) -> Sample {
505 Sample {
506 some_avg10: some,
507 full_avg10: full,
508 mem_available_mb: avail_mb,
509 mem_total_mb: 16_000,
510 source: PsiSource::AppSlice,
511 }
512 }
513
514 fn res(cg: &str) -> Resolution {
516 Resolution {
517 cgroup: cg.into(),
518 unit: Some(format!("{}.scope", cg.rsplit('/').next().unwrap())),
519 verdict: Verdict::Freeze,
520 coverage: Coverage::Full,
521 mechanism: Mechanism::Unit,
522 }
523 }
524
525 const MIB: u64 = 1024 * 1024;
526
527 fn target(app: &str, cg: &str, mb: u64) -> Target {
530 Target {
531 app: app.into(),
532 resolution: res(cg),
533 rss_kb: mb * 1024,
534 current_bytes: Some(mb * MIB),
535 }
536 }
537
538 fn proc(pid: u32, name: &str, rss_mb: u64) -> Target {
540 target(name, &format!("/app.slice/app-{name}-{pid}.scope"), rss_mb)
541 }
542
543 fn proc_at(_pid: u32, name: &str, rss_mb: u64, cg: &str) -> Target {
544 target(name, cg, rss_mb)
545 }
546
547 fn calm() -> Sample {
549 sample(0.0, 0.0, 8000)
550 }
551
552 fn high() -> Sample {
554 sample(50.0, 0.0, 2_000)
555 }
556
557 #[test]
558 fn high_pressure_with_plenty_of_free_memory_never_escalates() {
559 let mut e = PolicyEngine::new(cfg());
560 let procs = vec![proc(2, "chrome", 4000)];
561 let a = e.tick(0, sample(80.0, 20.0, 8_000), &procs, &live_from(&procs));
563 assert_eq!(e.level, Level::Critical);
564 assert!(
565 !a.iter()
566 .any(|x| matches!(x, Action::Freeze { .. } | Action::Cap { .. })),
567 "escalated without scarcity: {a:?}"
568 );
569 }
570
571 #[test]
572 fn is_scarce_uses_floor_or_percentage() {
573 let t = common::GuardTrigger::default(); assert!(is_scarce(&sample(0.0, 0.0, 300), &t));
575 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 {
578 mem_total_mb: 0,
579 mem_available_mb: u64::MAX,
580 ..sample(0.0, 0.0, 0)
581 };
582 assert!(
583 !is_scarce(&unknown, &t),
584 "unreadable meminfo must not enable actions"
585 );
586 }
587
588 fn freeze_targets(actions: &[Action]) -> Vec<String> {
589 actions
590 .iter()
591 .filter_map(|a| match a {
592 Action::Freeze { res, .. } => Some(res.cgroup.clone()),
593 _ => None,
594 })
595 .collect()
596 }
597
598 fn has_cap_target(actions: &[Action], cg: &str) -> bool {
599 actions
600 .iter()
601 .any(|a| matches!(a, Action::Cap { res, .. } if res.cgroup == cg))
602 }
603
604 fn has_freeze_target(actions: &[Action], cg: &str) -> bool {
605 actions
606 .iter()
607 .any(|a| matches!(a, Action::Freeze { res, .. } if res.cgroup == cg))
608 }
609
610 fn has_thaw_target(actions: &[Action], cg: &str) -> bool {
611 actions
612 .iter()
613 .any(|a| matches!(a, Action::Thaw { res } if res.cgroup == cg))
614 }
615
616 fn has_liftcap_target(actions: &[Action], cg: &str) -> bool {
617 actions
618 .iter()
619 .any(|a| matches!(a, Action::LiftCap { res } if res.cgroup == cg))
620 }
621
622 fn prime(e: &mut PolicyEngine, ts: &[Target]) {
626 for t in ts {
627 e.growth.insert(
628 t.resolution.cgroup.clone(),
629 Growth {
630 last_bytes: t.current_bytes.unwrap_or(0),
631 last_ms: 0,
632 rate_bps: 0.0,
633 warm: true,
634 },
635 );
636 }
637 }
638
639 fn live_from(ts: &[Target]) -> std::collections::HashSet<String> {
642 ts.iter().map(|t| t.resolution.cgroup.clone()).collect()
643 }
644
645 #[test]
646 fn calm_yields_no_actions() {
647 let mut e = PolicyEngine::new(cfg());
648 let procs = vec![proc(100, "firefox", 2000)];
649 let actions = e.tick(1000, calm(), &procs, &live_from(&procs));
650 assert!(actions.is_empty(), "calm produced actions: {actions:?}");
651 assert_eq!(e.level, Level::Calm);
652 }
653
654 #[test]
655 fn full_signal_has_fall_hysteresis() {
656 let mut e = PolicyEngine::new(cfg());
658 let procs = vec![proc(100, "firefox", 4000)];
659 e.tick(1_000, sample(0.0, 4.0, 8000), &procs, &live_from(&procs));
660 assert_eq!(e.level, Level::High, "full=4.0 should enter High");
661
662 e.tick(2_000, sample(0.0, 2.0, 8000), &procs, &live_from(&procs));
665 assert_eq!(
666 e.level,
667 Level::High,
668 "full=2.0 (between fall and rise) must hold High, not flap"
669 );
670
671 e.tick(3_000, sample(0.0, 1.0, 8000), &procs, &live_from(&procs));
673 assert_eq!(
674 e.level,
675 Level::Calm,
676 "full below fall threshold drops to Calm"
677 );
678 }
679
680 #[test]
681 fn disabled_engine_is_inert() {
682 let mut c = cfg();
683 c.enabled = false;
684 let mut e = PolicyEngine::new(c);
685 let procs = vec![proc(100, "firefox", 4000)];
686 assert!(e.tick(1000, high(), &procs, &live_from(&procs)).is_empty());
687 }
688
689 #[test]
690 fn high_freezes_largest_eligible_process() {
691 let mut e = PolicyEngine::new(cfg());
692 let procs = vec![
693 proc(1, "small", 300),
694 proc(2, "biggest", 4000),
695 proc(3, "medium", 1000),
696 ];
697 prime(&mut e, &procs);
698 let actions = e.tick(1000, high(), &procs, &live_from(&procs));
699 assert_eq!(
701 freeze_targets(&actions),
702 vec!["/app.slice/app-biggest-2.scope"]
703 );
704 assert_eq!(e.level, Level::High);
705 }
706
707 #[test]
708 fn process_below_min_rss_is_never_selected() {
709 let mut e = PolicyEngine::new(cfg());
710 let procs = vec![proc(1, "tiny", 50), proc(2, "small", 150)];
712 let actions = e.tick(1000, high(), &procs, &live_from(&procs));
713 assert!(
714 freeze_targets(&actions).is_empty(),
715 "froze a sub-min-rss process: {actions:?}"
716 );
717 }
718
719 #[test]
720 fn frozen_process_thaws_after_freeze_hold() {
721 let mut e = PolicyEngine::new(cfg()); let procs = vec![proc(2, "hog", 4000)];
723 prime(&mut e, &procs);
724 let cg = "/app.slice/app-hog-2.scope";
725
726 let a0 = e.tick(0, high(), &procs, &live_from(&procs));
727 assert_eq!(freeze_targets(&a0), vec![cg]);
728
729 let a1 = e.tick(4_000, high(), &procs, &live_from(&procs));
731 assert!(!has_thaw_target(&a1, cg), "thawed too early: {a1:?}");
732
733 let a2 = e.tick(5_000, high(), &procs, &live_from(&procs));
735 assert!(has_thaw_target(&a2, cg), "expected thaw at hold: {a2:?}");
736 assert!(e.interventions().is_empty());
737 }
738
739 #[test]
740 fn still_high_within_cooldown_caps_instead_of_refreezing() {
741 let mut e = PolicyEngine::new(cfg()); let procs = vec![proc(2, "hog", 4000)];
743 prime(&mut e, &procs);
744 let cg = "/app.slice/app-hog-2.scope";
745
746 assert_eq!(
748 freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
749 vec![cg]
750 );
751 assert!(has_thaw_target(
753 &e.tick(5_000, high(), &procs, &live_from(&procs)),
754 cg
755 ));
756
757 let a = e.tick(10_000, high(), &procs, &live_from(&procs));
760 assert!(
761 has_cap_target(&a, cg),
762 "expected cap within cooldown: {a:?}"
763 );
764 assert!(freeze_targets(&a).is_empty(), "should not re-freeze: {a:?}");
765 assert!(matches!(
766 e.interventions().as_slice(),
767 [(c, Intervention::Capped { .. })] if c == cg
768 ));
769 }
770
771 #[test]
772 fn hysteresis_holds_level_between_fall_and_rise() {
773 let mut e = PolicyEngine::new(cfg());
774 let procs = vec![proc(2, "hog", 4000)];
775 let cg = "/app.slice/app-hog-2.scope";
776
777 e.tick(0, high(), &procs, &live_from(&procs));
779 assert_eq!(e.level, Level::High);
780
781 let a = e.tick(20_000, sample(20.0, 0.0, 8000), &procs, &live_from(&procs));
783 assert_eq!(e.level, Level::High, "dropped out of High prematurely");
784 assert!(
787 !has_liftcap_target(&a, cg),
788 "should not lift while still High: {a:?}"
789 );
790
791 e.tick(21_000, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
793 assert_eq!(e.level, Level::Warn);
794 }
795
796 #[test]
797 fn capped_process_lifted_only_after_sustained_calm() {
798 let mut e = PolicyEngine::new(cfg()); let procs = vec![proc(2, "hog", 4000)];
800 prime(&mut e, &procs);
801 let cg = "/app.slice/app-hog-2.scope";
802
803 e.tick(0, high(), &procs, &live_from(&procs));
805 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));
808
809 let a1 = e.tick(15_000, calm(), &procs, &live_from(&procs));
811 assert!(
812 !has_liftcap_target(&a1, cg),
813 "lifted before calm sustained: {a1:?}"
814 );
815 let a2 = e.tick(44_000, calm(), &procs, &live_from(&procs)); assert!(
817 !has_liftcap_target(&a2, cg),
818 "lifted just before hold: {a2:?}"
819 );
820
821 let a3 = e.tick(45_000, calm(), &procs, &live_from(&procs));
823 assert!(
824 has_liftcap_target(&a3, cg),
825 "expected lift after calm hold: {a3:?}"
826 );
827 assert!(e.interventions().is_empty());
828 }
829
830 #[test]
831 fn cap_lift_resets_if_calm_is_interrupted() {
832 let mut e = PolicyEngine::new(cfg());
833 let procs = vec![proc(2, "hog", 4000)];
834 prime(&mut e, &procs);
835 let cg = "/app.slice/app-hog-2.scope";
836 e.tick(0, high(), &procs, &live_from(&procs));
837 e.tick(5_000, high(), &procs, &live_from(&procs));
838 assert!(has_cap_target(
839 &e.tick(10_000, high(), &procs, &live_from(&procs)),
840 cg
841 ));
842
843 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));
847 let a = e.tick(50_000, calm(), &procs, &live_from(&procs));
848 assert!(
849 !has_liftcap_target(&a, cg),
850 "calm clock should have reset: {a:?}"
851 );
852 }
853
854 #[test]
855 fn escalation_gate_limits_to_one_freeze_per_hold_window() {
856 let mut e = PolicyEngine::new(cfg());
857 let procs = vec![proc(1, "hog-a", 4000), proc(2, "hog-b", 3000)];
858 prime(&mut e, &procs);
859 let cg_a = "/app.slice/app-hog-a-1.scope";
860 let cg_b = "/app.slice/app-hog-b-2.scope";
861
862 let a0 = e.tick(0, high(), &procs, &live_from(&procs));
864 assert_eq!(freeze_targets(&a0), vec![cg_a]);
865
866 let a1 = e.tick(2_000, high(), &procs, &live_from(&procs));
868 assert!(
869 freeze_targets(&a1).is_empty(),
870 "gate should suppress second freeze: {a1:?}"
871 );
872
873 let a2 = e.tick(5_000, high(), &procs, &live_from(&procs));
875 assert_eq!(freeze_targets(&a2), vec![cg_b]);
876 }
877
878 #[test]
879 fn dead_pid_is_pruned_with_liftcap() {
880 let mut e = PolicyEngine::new(cfg());
881 let procs = vec![proc(2, "hog", 4000)];
882 prime(&mut e, &procs);
883 let cg = "/app.slice/app-hog-2.scope";
884
885 assert_eq!(
887 freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
888 vec![cg]
889 );
890 assert_eq!(e.interventions().len(), 1);
891
892 let a = e.tick(1_000, calm(), &[], &live_from(&[]));
895 assert!(
896 has_liftcap_target(&a, cg),
897 "expected LiftCap for dead cgroup: {a:?}"
898 );
899 assert!(e.interventions().is_empty());
900 }
901
902 #[test]
908 fn intervention_survives_in_live_cgroups_but_absent_from_procs() {
909 let mut e = PolicyEngine::new(cfg());
910 let procs = vec![proc(2, "hog", 4000)];
911 prime(&mut e, &procs);
912 let cg = "/app.slice/app-hog-2.scope";
913
914 assert_eq!(
916 freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
917 vec![cg]
918 );
919 assert_eq!(e.interventions().len(), 1);
920
921 let live: std::collections::HashSet<String> = [cg.to_string()].into();
925 let a1 = e.tick(1_000, calm(), &[], &live);
926 assert!(
927 !has_liftcap_target(&a1, cg),
928 "must not prune a cgroup still present in live_cgroups: {a1:?}"
929 );
930 assert_eq!(
931 e.interventions().len(),
932 1,
933 "intervention must survive while the cgroup is live"
934 );
935
936 let a2 = e.tick(2_000, calm(), &[], &std::collections::HashSet::new());
938 assert!(
939 has_liftcap_target(&a2, cg),
940 "expected LiftCap once absent from live_cgroups too: {a2:?}"
941 );
942 assert!(e.interventions().is_empty());
943 }
944
945 #[test]
946 fn interventions_reflect_state_sorted_by_cgroup() {
947 let mut e = PolicyEngine::new(cfg());
948 let procs = vec![proc(5, "a", 4000), proc(3, "b", 3500)];
953 prime(&mut e, &procs);
954 let cg_a = "/app.slice/app-a-5.scope";
955 let cg_b = "/app.slice/app-b-3.scope";
956
957 assert_eq!(
962 freeze_targets(&e.tick(0, high(), &procs, &live_from(&procs))),
963 vec![cg_a]
964 ); let a1 = e.tick(5_000, high(), &procs, &live_from(&procs)); assert!(has_thaw_target(&a1, cg_a));
967 assert_eq!(freeze_targets(&a1), vec![cg_b]);
968 let a2 = e.tick(10_000, high(), &procs, &live_from(&procs)); assert!(has_thaw_target(&a2, cg_b));
970 assert!(has_cap_target(&a2, cg_a));
971 let a3 = e.tick(15_000, high(), &procs, &live_from(&procs)); assert!(has_cap_target(&a3, cg_b));
973
974 let ivs = e.interventions();
975 assert_eq!(ivs.len(), 2, "expected a + b both Capped: {ivs:?}");
976 assert_eq!(ivs[0].0, cg_a);
978 assert_eq!(ivs[1].0, cg_b);
979 assert!(ivs
980 .iter()
981 .all(|(_, iv)| matches!(iv, Intervention::Capped { .. })));
982 }
983
984 #[test]
985 fn critical_via_mem_floor_triggers_action() {
986 let mut e = PolicyEngine::new(cfg());
987 let procs = vec![proc(2, "hog", 4000)];
988 prime(&mut e, &procs);
989 let a = e.tick(0, sample(0.0, 0.0, 100), &procs, &live_from(&procs));
991 assert_eq!(e.level, Level::Critical);
992 assert_eq!(freeze_targets(&a), vec!["/app.slice/app-hog-2.scope"]);
993 }
994
995 #[test]
996 fn notify_emitted_and_rate_limited() {
997 let mut e = PolicyEngine::new(cfg());
998 let procs = vec![proc(2, "hog", 4000)];
999
1000 let a0 = e.tick(0, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1002 assert!(
1003 a0.iter().any(|x| matches!(x, Action::Notify { .. })),
1004 "expected a notify at Warn: {a0:?}"
1005 );
1006 assert!(freeze_targets(&a0).is_empty());
1007
1008 let a1 = e.tick(30_000, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1010 assert!(
1011 !a1.iter().any(|x| matches!(x, Action::Notify { .. })),
1012 "notify should be rate-limited: {a1:?}"
1013 );
1014
1015 let a2 = e.tick(60_000, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1017 assert!(a2.iter().any(|x| matches!(x, Action::Notify { .. })));
1018 }
1019
1020 #[test]
1021 fn notify_disabled_suppresses_notifications() {
1022 let mut c = cfg();
1023 c.notify = false;
1024 let mut e = PolicyEngine::new(c);
1025 let procs = vec![proc(2, "hog", 4000)];
1026 let a = e.tick(0, sample(12.0, 0.0, 8000), &procs, &live_from(&procs));
1027 assert!(!a.iter().any(|x| matches!(x, Action::Notify { .. })));
1028 }
1029
1030 #[test]
1033 fn caponly_verdict_never_freezes() {
1034 let mut e = PolicyEngine::new(cfg());
1035 let mut p = proc_at(2, "script", 4000, "/app.slice/app-alacritty-9.scope");
1036 let r = &mut p.resolution;
1037 r.verdict = Verdict::CapOnly;
1038 r.coverage = Coverage::Partial;
1039 let procs = [p];
1040 prime(&mut e, &procs);
1041 let a = e.tick(0, high(), &procs, &live_from(&procs));
1042 assert!(
1043 freeze_targets(&a).is_empty(),
1044 "CapOnly must not freeze: {a:?}"
1045 );
1046 assert!(has_cap_target(&a, "/app.slice/app-alacritty-9.scope"));
1047 }
1048
1049 #[test]
1050 fn partial_action_waits_at_least_three_seconds() {
1051 let mut e = PolicyEngine::new(cfg());
1052 let mut term = target("python3", "/app.slice/term.scope", 4000);
1053 term.resolution.verdict = Verdict::CapOnly;
1054 term.resolution.coverage = Coverage::Partial;
1055 let ts = vec![term, target("hog", "/app.slice/hog.scope", 3000)];
1056 prime(&mut e, &ts);
1057 assert!(has_cap_target(
1058 &e.tick(0, high(), &ts, &live_from(&ts)),
1059 "/app.slice/term.scope"
1060 ));
1061 assert!(freeze_targets(&e.tick(1_000, high(), &ts, &live_from(&ts))).is_empty());
1062 assert!(freeze_targets(&e.tick(2_000, high(), &ts, &live_from(&ts))).is_empty());
1063 assert!(has_freeze_target(
1064 &e.tick(3_000, high(), &ts, &live_from(&ts)),
1065 "/app.slice/hog.scope"
1066 ));
1067 }
1068
1069 #[test]
1070 fn two_scopes_of_one_app_are_acted_on_together() {
1071 let mut e = PolicyEngine::new(cfg());
1072 let ts = vec![
1073 target("chrome", "/app.slice/app-chrome-1.scope", 1500),
1074 target("chrome", "/app.slice/app-chrome-2.scope", 900),
1075 target("hog", "/app.slice/app-hog-3.scope", 2000),
1076 ];
1077 prime(&mut e, &ts);
1078 let mut frozen = freeze_targets(&e.tick(0, high(), &ts, &live_from(&ts)));
1079 frozen.sort();
1080 assert_eq!(
1081 frozen,
1082 vec![
1083 "/app.slice/app-chrome-1.scope",
1084 "/app.slice/app-chrome-2.scope"
1085 ]
1086 );
1087 let a1 = e.tick(1_000, high(), &ts, &live_from(&ts));
1088 assert!(
1089 freeze_targets(&a1).is_empty(),
1090 "one app is one escalation step: {a1:?}"
1091 );
1092 }
1093
1094 #[test]
1095 fn fastest_growing_app_is_chosen_over_largest() {
1096 let mut e = PolicyEngine::new(cfg());
1097 let warn = sample(12.0, 0.0, 2_000);
1098 let t0 = vec![
1099 target("firefox", "/app.slice/ff.scope", 4000),
1100 target("script", "/app.slice/sh.scope", 1000),
1101 ];
1102 assert!(freeze_targets(&e.tick(0, warn, &t0, &live_from(&t0))).is_empty());
1103 let t1 = vec![
1104 target("firefox", "/app.slice/ff.scope", 4000),
1105 target("script", "/app.slice/sh.scope", 1600),
1106 ];
1107 assert_eq!(
1108 freeze_targets(&e.tick(1_000, high(), &t1, &live_from(&t1))),
1109 vec!["/app.slice/sh.scope"]
1110 );
1111 }
1112
1113 #[test]
1114 fn largest_app_is_the_fallback_when_nothing_grows() {
1115 let mut e = PolicyEngine::new(cfg());
1116 let ts = vec![
1117 target("small", "/app.slice/s.scope", 300),
1118 target("big", "/app.slice/b.scope", 3000),
1119 ];
1120 e.tick(0, sample(12.0, 0.0, 2_000), &ts, &live_from(&ts));
1121 assert_eq!(
1122 freeze_targets(&e.tick(1_000, high(), &ts, &live_from(&ts))),
1123 vec!["/app.slice/b.scope"]
1124 );
1125 }
1126
1127 #[test]
1128 fn at_most_three_apps_are_held_at_once() {
1129 let mut e = PolicyEngine::new(cfg());
1130 let ts: Vec<Target> = (0..5u64)
1131 .map(|i| {
1132 target(
1133 &format!("app{i}"),
1134 &format!("/app.slice/a{i}.scope"),
1135 1000 + i * 100,
1136 )
1137 })
1138 .collect();
1139 for step in 0..40u64 {
1140 e.tick(step * 5_000, high(), &ts, &live_from(&ts));
1141 assert!(
1142 e.interventions().len() <= MAX_HELD_APPS,
1143 "held {:?}",
1144 e.interventions()
1145 );
1146 }
1147 }
1148
1149 #[test]
1150 fn held_limit_counts_apps_not_cgroups() {
1151 let mut e = PolicyEngine::new(cfg());
1152 let ts = vec![
1153 target("a", "/app.slice/a1.scope", 2000),
1154 target("a", "/app.slice/a2.scope", 2000),
1155 target("b", "/app.slice/b.scope", 1500),
1156 target("c", "/app.slice/c.scope", 1200),
1157 target("d", "/app.slice/d.scope", 1100),
1158 ];
1159 for step in 0..40u64 {
1160 e.tick(step * 5_000, high(), &ts, &live_from(&ts));
1161 }
1162 let held: Vec<String> = e.interventions().into_iter().map(|(cg, _)| cg).collect();
1163 assert_eq!(
1164 held,
1165 vec![
1166 "/app.slice/a1.scope",
1167 "/app.slice/a2.scope",
1168 "/app.slice/b.scope",
1169 "/app.slice/c.scope"
1170 ],
1171 "4 cgroups across 3 apps are allowed, a 4th app is refused"
1172 );
1173 }
1174
1175 #[test]
1176 fn candidates_are_wanted_above_calm_or_when_scarce() {
1177 let e = PolicyEngine::new(cfg());
1178 assert!(!e.wants_candidates(calm()));
1179 assert!(e.wants_candidates(sample(12.0, 0.0, 8_000)));
1180 assert!(e.wants_candidates(sample(0.0, 0.0, 3_000)));
1182 }
1183
1184 #[test]
1189 fn cold_start_defers_then_picks_grower_not_largest() {
1190 let mut e = PolicyEngine::new(cfg());
1191 for step in 0..5u64 {
1192 e.tick(step * 1_000, calm(), &[], &HashSet::new());
1193 }
1194 let crit = sample(0.0, 0.0, 300);
1195 let t0 = vec![
1196 target("chrome", "/app.slice/chrome.scope", 6000),
1197 target("hog", "/app.slice/hog.scope", 3000),
1198 ];
1199 let a0 = e.tick(5_000, crit, &t0, &live_from(&t0));
1200 assert_eq!(e.level, Level::Critical);
1201 assert!(
1202 freeze_targets(&a0).is_empty(),
1203 "no growth data yet, must defer: {a0:?}"
1204 );
1205 let t1 = vec![
1206 target("chrome", "/app.slice/chrome.scope", 6000),
1207 target("hog", "/app.slice/hog.scope", 3200),
1208 ];
1209 assert_eq!(
1210 freeze_targets(&e.tick(6_000, crit, &t1, &live_from(&t1))),
1211 vec!["/app.slice/hog.scope"]
1212 );
1213 }
1214
1215 #[test]
1218 fn scarce_calm_ticks_warm_growth_for_first_critical_tick() {
1219 let mut e = PolicyEngine::new(cfg());
1220 let scarce_calm = sample(0.0, 0.0, 3_000);
1221 assert!(e.wants_candidates(scarce_calm));
1222 let t0 = vec![
1223 target("chrome", "/app.slice/chrome.scope", 6000),
1224 target("hog", "/app.slice/hog.scope", 2000),
1225 ];
1226 assert!(freeze_targets(&e.tick(0, scarce_calm, &t0, &live_from(&t0))).is_empty());
1227 let t1 = vec![
1228 target("chrome", "/app.slice/chrome.scope", 6000),
1229 target("hog", "/app.slice/hog.scope", 3000),
1230 ];
1231 assert_eq!(
1232 freeze_targets(&e.tick(1_000, sample(0.0, 0.0, 300), &t1, &live_from(&t1))),
1233 vec!["/app.slice/hog.scope"]
1234 );
1235 }
1236
1237 #[test]
1240 fn no_current_bytes_does_not_defer() {
1241 let mut e = PolicyEngine::new(cfg());
1242 let mut big = target("big", "/app.slice/b.scope", 3000);
1243 big.current_bytes = None;
1244 let mut small = target("small", "/app.slice/s.scope", 1000);
1245 small.current_bytes = None;
1246 let ts = vec![big, small];
1247 assert_eq!(
1248 freeze_targets(&e.tick(0, high(), &ts, &live_from(&ts))),
1249 vec!["/app.slice/b.scope"]
1250 );
1251 }
1252}