1use super::effector::Applied;
19use super::policy::is_scarce;
20use super::types::{Action, Level, Sample};
21use common::GuardConfig;
22use std::collections::{HashMap, HashSet};
23use std::process::Command;
24use std::sync::mpsc;
25use std::time::{Duration, Instant};
26
27pub const APP_ID: &str = "io.github.rlm.gtk";
29pub const APP_NAME: &str = "rlm";
31
32pub const PRESSURE_TITLE: &str = "Memory is running low";
34pub const PRESSURE_BODY: &str = "rlm will step in if an app keeps growing.";
35
36pub const PRESSURE_INTERVAL_MS: u64 = 60_000;
38
39pub const PRESSURE_KEY: &str = "/pressure";
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Memory {
46 Unknown,
48 Fine,
50 Low,
53}
54
55pub fn memory_state(level: Level, sample: &Sample, trigger: &common::GuardTrigger) -> Memory {
57 if matches!(level, Level::High | Level::Critical) && is_scarce(sample, trigger) {
58 Memory::Low
59 } else {
60 Memory::Fine
61 }
62}
63
64pub trait NotifySink {
67 fn show(&mut self, key: &str, title: &str, body: &str);
68 fn close(&mut self, key: &str);
69}
70
71pub fn paused_text(app: &str, hold_secs: u64) -> (String, String) {
73 let unit = if hold_secs == 1 { "second" } else { "seconds" };
74 (
75 format!("{app} paused"),
76 format!("Paused for {hold_secs} {unit} while memory is low."),
79 )
80}
81
82pub fn slowed_text(app: &str, cap_bytes: u64) -> (String, String) {
84 (
85 format!("{app} slowed down"),
86 format!(
87 "Held to about {} until memory frees up.",
88 format_size(cap_bytes)
89 ),
90 )
91}
92
93pub fn failed_resume_text(app: &str) -> (String, String) {
95 (
96 format!("{app} could not be resumed"),
97 "It may stay paused. Run rlm guard status for details.".to_string(),
98 )
99}
100
101pub fn format_size(bytes: u64) -> String {
103 const GB: f64 = 1e9;
104 const MB: f64 = 1e6;
105 let b = bytes as f64;
106 if b >= GB {
107 format!("{:.1} GB", b / GB)
108 } else {
109 format!("{:.0} MB", (b / MB).max(1.0))
110 }
111}
112
113pub fn display_name(key: &str, desktop: &HashMap<String, String>, comm: Option<&str>) -> String {
120 let base = key.split('@').next().unwrap_or(key);
121 let has_letters = |s: &str| s.chars().any(char::is_alphabetic);
122 let program = match comm {
123 Some(c) if !has_letters(base) && has_letters(c) => c.trim(),
124 _ => base,
125 };
126 if let Some(name) = desktop.get(program).or_else(|| desktop.get(base)) {
127 return name.clone();
128 }
129 if program.is_empty() {
130 return "An app".to_string();
131 }
132 let mut chars = program.chars();
133 match chars.next() {
134 Some(first) => first.to_uppercase().chain(chars).collect(),
135 None => String::new(),
136 }
137}
138
139pub struct AppNames {
143 desktop: HashMap<String, String>,
144 loading: Option<mpsc::Receiver<HashMap<String, String>>>,
146}
147
148impl AppNames {
149 pub fn new() -> Self {
151 Self {
152 desktop: crate::desktop::names_by_program(),
153 loading: None,
154 }
155 }
156
157 pub fn in_background() -> Self {
161 let (tx, rx) = mpsc::channel();
162 let spawned = std::thread::Builder::new()
163 .name("rlm-app-names".into())
164 .spawn(move || {
165 let _ = tx.send(crate::desktop::names_by_program());
166 });
167 Self {
168 desktop: HashMap::new(),
169 loading: spawned.ok().map(|_| rx),
170 }
171 }
172
173 pub fn name(&mut self, key: &str, cgroup: &str) -> String {
175 if let Some(rx) = &self.loading {
176 match rx.try_recv() {
177 Ok(desktop) => {
178 self.desktop = desktop;
179 self.loading = None;
180 }
181 Err(mpsc::TryRecvError::Disconnected) => self.loading = None,
182 Err(mpsc::TryRecvError::Empty) => {}
183 }
184 }
185 let base = key.split('@').next().unwrap_or(key);
186 let comm = if base.chars().any(char::is_alphabetic) {
187 None
188 } else {
189 comm_in(cgroup, base)
190 };
191 display_name(key, &self.desktop, comm.as_deref())
192 }
193}
194
195impl Default for AppNames {
196 fn default() -> Self {
197 Self::new()
198 }
199}
200
201fn comm_in(cgroup: &str, exe: &str) -> Option<String> {
203 super::cgfs::pids_under(cgroup)
204 .into_iter()
205 .find(|&p| super::cgfs::exe_basename(p).as_deref() == Some(exe))
206 .and_then(|p| std::fs::read_to_string(format!("/proc/{p}/comm")).ok())
207 .map(|c| c.trim().to_string())
208 .filter(|c| !c.is_empty())
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub struct NotifyFlags {
214 pub notify: bool,
216 pub pressure: bool,
218}
219
220impl NotifyFlags {
221 pub fn from_config(g: &GuardConfig) -> Self {
222 Self {
223 notify: g.notify,
224 pressure: g.notify_pressure,
225 }
226 }
227}
228
229pub fn flags_after_reload(
233 current: NotifyFlags,
234 reloaded: std::result::Result<&GuardConfig, &common::Error>,
235) -> NotifyFlags {
236 match reloaded {
237 Ok(g) => NotifyFlags::from_config(g),
238 Err(_) => current,
239 }
240}
241
242#[derive(Debug, Clone)]
244struct Held {
245 app: String,
246 cap_bytes: Option<u64>,
248}
249
250pub struct Notifier<S: NotifySink> {
259 sink: S,
260 flags: NotifyFlags,
261 freeze_hold_secs: u64,
262 held: HashMap<String, Held>,
264 names: HashMap<String, String>,
266 shown: HashMap<String, (String, String)>,
268 thawed: HashSet<String>,
270 stuck: HashMap<String, String>,
272 cgroup_exists: Box<dyn Fn(&str) -> bool>,
275 pressure_shown: bool,
276 last_pressure_ms: Option<u64>,
277}
278
279impl<S: NotifySink> Notifier<S> {
280 pub fn new(sink: S, cfg: &GuardConfig) -> Self {
281 Self {
282 sink,
283 flags: NotifyFlags::from_config(cfg),
284 freeze_hold_secs: cfg.timing.freeze_hold_secs,
285 held: HashMap::new(),
286 names: HashMap::new(),
287 shown: HashMap::new(),
288 thawed: HashSet::new(),
289 stuck: HashMap::new(),
290 cgroup_exists: Box::new(|cg| super::cgfs::abs(cg).is_dir()),
291 pressure_shown: false,
292 last_pressure_ms: None,
293 }
294 }
295
296 pub fn sink(&self) -> &S {
297 &self.sink
298 }
299
300 pub fn flags(&self) -> NotifyFlags {
301 self.flags
302 }
303
304 pub fn set_flags(&mut self, flags: NotifyFlags) {
307 if !flags.notify {
308 self.close_all();
309 } else if !flags.pressure {
310 self.close_pressure();
311 }
312 self.flags = flags;
313 }
314
315 pub fn record(&mut self, action: &Action, applied: Option<Applied>) {
319 match action {
320 Action::Freeze { res, name: app } | Action::Cap { res, name: app } => {
321 let Some(applied) = applied else {
322 return;
323 };
324 let cap_bytes = match action {
325 Action::Cap { .. } => Some(applied.cap_bytes.unwrap_or(0)),
326 _ => None,
327 };
328 self.held.insert(
329 res.cgroup.clone(),
330 Held {
331 app: app.clone(),
332 cap_bytes,
333 },
334 );
335 }
336 Action::Thaw { res } => {
339 if let Some(h) = self.held.remove(&res.cgroup) {
340 if applied.is_some() {
341 self.thawed.insert(h.app);
342 } else {
343 self.stuck.insert(res.cgroup.clone(), h.app);
344 }
345 }
346 if applied.is_some() {
347 self.stuck.remove(&res.cgroup);
348 }
349 }
350 Action::LiftCap { res } => {
353 self.held.remove(&res.cgroup);
354 if applied.is_some() {
355 self.stuck.remove(&res.cgroup);
356 }
357 }
358 }
359 }
360
361 pub fn end_tick(
366 &mut self,
367 now_ms: u64,
368 memory: Memory,
369 name: &mut dyn FnMut(&str, &str) -> String,
370 ) {
371 let thawed = std::mem::take(&mut self.thawed);
372 let exists = &self.cgroup_exists;
375 self.stuck.retain(|cg, _| exists(cg));
376 if !self.flags.notify {
377 return;
378 }
379
380 let mut held: Vec<(&String, &Held)> = self.held.iter().collect();
381 held.sort_by(|a, b| a.0.cmp(b.0));
382 for (cgroup, h) in &held {
383 if !self.names.contains_key(&h.app) {
384 self.names.insert(h.app.clone(), name(&h.app, cgroup));
385 }
386 }
387 let mut want: HashMap<String, (String, String)> = HashMap::new();
388 for (_, h) in held {
389 let display = self
390 .names
391 .get(&h.app)
392 .map_or(h.app.as_str(), |n| n.as_str());
393 let capped: u64 = self
394 .held
395 .values()
396 .filter(|o| o.app == h.app)
397 .filter_map(|o| o.cap_bytes)
398 .sum();
399 let any_capped = self
400 .held
401 .values()
402 .any(|o| o.app == h.app && o.cap_bytes.is_some());
403 let text = if any_capped {
404 slowed_text(display, capped)
405 } else {
406 paused_text(display, self.freeze_hold_secs)
407 };
408 want.entry(h.app.clone()).or_insert(text);
409 }
410 let mut stuck: Vec<(&String, &String)> = self.stuck.iter().collect();
411 stuck.sort();
412 for (cgroup, app) in stuck {
413 if !self.names.contains_key(app) {
414 self.names.insert(app.clone(), name(app, cgroup));
415 }
416 let display = self.names.get(app).map_or(app.as_str(), |n| n.as_str());
417 want.entry(app.clone())
418 .or_insert_with(|| failed_resume_text(display));
419 }
420
421 let mut gone: Vec<String> = self
422 .shown
423 .keys()
424 .filter(|app| !want.contains_key(*app) && !thawed.contains(*app))
425 .cloned()
426 .collect();
427 gone.sort();
428 for app in gone {
429 self.sink.close(&app);
430 self.shown.remove(&app);
431 }
432 let (held, shown, stuck) = (&self.held, &self.shown, &self.stuck);
433 self.names.retain(|app, _| {
434 shown.contains_key(app)
435 || held.values().any(|h| h.app == *app)
436 || stuck.values().any(|a| a == app)
437 });
438 let mut changed: Vec<(String, (String, String))> = want
439 .into_iter()
440 .filter(|(app, text)| self.shown.get(app) != Some(text))
441 .collect();
442 changed.sort();
443 for (app, (title, body)) in changed {
444 self.sink.show(&app, &title, &body);
445 self.shown.insert(app, (title, body));
446 }
447
448 let pressing = memory == Memory::Low;
449 if self.held.is_empty() && self.shown.is_empty() && pressing {
450 let due = self
451 .last_pressure_ms
452 .is_none_or(|last| now_ms.saturating_sub(last) >= PRESSURE_INTERVAL_MS);
453 if self.flags.pressure && due {
454 self.sink.show(PRESSURE_KEY, PRESSURE_TITLE, PRESSURE_BODY);
455 self.pressure_shown = true;
456 self.last_pressure_ms = Some(now_ms);
457 }
458 } else if memory != Memory::Unknown || !self.held.is_empty() {
459 self.close_pressure();
460 }
461 }
462
463 pub fn close_all(&mut self) {
465 self.stuck.clear();
466 let mut apps: Vec<String> = self.shown.drain().map(|(app, _)| app).collect();
467 apps.sort();
468 for app in apps {
469 self.sink.close(&app);
470 }
471 self.close_pressure();
472 }
473
474 fn close_pressure(&mut self) {
475 if self.pressure_shown {
476 self.sink.close(PRESSURE_KEY);
477 self.pressure_shown = false;
478 }
479 }
480}
481
482const CALL_TIMEOUT: Duration = Duration::from_secs(2);
484
485const EXPIRE_TIMEOUT: i32 = -1;
490const QUEUE: usize = 64;
492
493const NOTIFY_DEST: &str = "org.freedesktop.Notifications";
494const NOTIFY_PATH: &str = "/org/freedesktop/Notifications";
495
496enum Cmd {
497 Show {
498 key: String,
499 title: String,
500 body: String,
501 },
502 Close {
503 key: String,
504 },
505 Flush(mpsc::Sender<()>),
506}
507
508pub struct DesktopSink {
517 tx: Option<mpsc::SyncSender<Cmd>>,
518}
519
520impl DesktopSink {
521 pub fn spawn() -> Self {
524 let (tx, rx) = mpsc::sync_channel(QUEUE);
525 let started = std::thread::Builder::new()
526 .name("rlm-notify".into())
527 .spawn(move || sender(rx));
528 match started {
529 Ok(_) => Self { tx: Some(tx) },
530 Err(e) => {
531 tracing::debug!(error = %e, "cannot start the notification thread");
532 Self { tx: None }
533 }
534 }
535 }
536
537 pub fn flush(&self, timeout: Duration) -> bool {
540 let (done, wait) = mpsc::channel();
541 if !self.send(Cmd::Flush(done)) {
542 return false;
543 }
544 wait.recv_timeout(timeout).is_ok()
545 }
546
547 fn send(&self, cmd: Cmd) -> bool {
548 match &self.tx {
549 Some(tx) => match tx.try_send(cmd) {
550 Ok(()) => true,
551 Err(e) => {
552 tracing::debug!(error = %e, "notification dropped");
553 false
554 }
555 },
556 None => false,
557 }
558 }
559}
560
561impl NotifySink for DesktopSink {
562 fn show(&mut self, key: &str, title: &str, body: &str) {
563 self.send(Cmd::Show {
564 key: key.to_string(),
565 title: title.to_string(),
566 body: body.to_string(),
567 });
568 }
569
570 fn close(&mut self, key: &str) {
571 self.send(Cmd::Close {
572 key: key.to_string(),
573 });
574 }
575}
576
577fn sender(rx: mpsc::Receiver<Cmd>) {
580 let mut sender = Sender::new(SessionBus);
581 for cmd in rx {
582 sender.handle(cmd, Instant::now());
583 }
584}
585
586const RECONNECT_AFTER: Duration = Duration::from_secs(30);
589
590#[derive(Debug)]
592enum BusError {
593 Gone(String),
595 Call(String),
597}
598
599trait Bus {
601 fn notify(&self, replaces_id: u32, title: &str, body: &str) -> Result<u32, BusError>;
602 fn close(&self, id: u32) -> Result<(), BusError>;
603}
604
605trait Connector {
607 type Bus: Bus;
608 fn connect(&mut self) -> Option<Self::Bus>;
609 fn fallback(&mut self, title: &str, body: &str);
611}
612
613struct Sender<C: Connector> {
616 connector: C,
617 bus: Option<C::Bus>,
618 retry_at: Option<Instant>,
620 ids: HashMap<String, u32>,
621}
622
623impl<C: Connector> Sender<C> {
624 fn new(connector: C) -> Self {
625 Self {
626 connector,
627 bus: None,
628 retry_at: None,
629 ids: HashMap::new(),
630 }
631 }
632
633 fn ensure_bus(&mut self, now: Instant) {
635 if self.bus.is_some() || self.retry_at.is_some_and(|t| now < t) {
636 return;
637 }
638 self.bus = self.connector.connect();
639 self.retry_at = if self.bus.is_none() {
640 Some(now + RECONNECT_AFTER)
641 } else {
642 None
643 };
644 }
645
646 fn reset(&mut self, why: &str) {
649 tracing::debug!(error = why, "notification connection lost; reconnecting");
650 self.bus = None;
651 self.retry_at = None;
652 self.ids.clear();
653 }
654
655 fn handle(&mut self, cmd: Cmd, now: Instant) {
656 if let Cmd::Flush(done) = cmd {
657 let _ = done.send(());
658 return;
659 }
660 self.ensure_bus(now);
661 match cmd {
662 Cmd::Show { key, title, body } => {
663 let Some(bus) = &self.bus else {
664 self.connector.fallback(&title, &body);
665 return;
666 };
667 let replaces = self.ids.get(&key).copied().unwrap_or(0);
668 match bus.notify(replaces, &title, &body) {
669 Ok(id) => {
670 self.ids.insert(key, id);
671 }
672 Err(BusError::Gone(e)) => {
673 self.reset(&e);
674 self.ensure_bus(now);
676 match &self.bus {
677 Some(bus) => match bus.notify(0, &title, &body) {
678 Ok(id) => {
679 self.ids.insert(key, id);
680 }
681 Err(e) => tracing::debug!(error = ?e, "Notify failed"),
682 },
683 None => self.connector.fallback(&title, &body),
684 }
685 }
686 Err(BusError::Call(e)) => tracing::debug!(error = e, "Notify failed"),
687 }
688 }
689 Cmd::Close { key } => {
690 let (Some(bus), Some(id)) = (&self.bus, self.ids.remove(&key)) else {
691 return;
692 };
693 match bus.close(id) {
694 Ok(()) => {}
695 Err(BusError::Gone(e)) => self.reset(&e),
696 Err(BusError::Call(e)) => {
697 tracing::debug!(error = e, "CloseNotification failed")
698 }
699 }
700 }
701 Cmd::Flush(_) => {}
702 }
703 }
704}
705
706struct SessionBus;
708
709impl Connector for SessionBus {
710 type Bus = zbus::blocking::Connection;
711
712 fn connect(&mut self) -> Option<Self::Bus> {
713 connect()
714 }
715
716 fn fallback(&mut self, title: &str, body: &str) {
717 notify_send(title, body);
718 }
719}
720
721impl Bus for zbus::blocking::Connection {
722 fn notify(&self, replaces_id: u32, title: &str, body: &str) -> Result<u32, BusError> {
723 dbus_notify(self, replaces_id, title, body, EXPIRE_TIMEOUT).map_err(classify)
724 }
725
726 fn close(&self, id: u32) -> Result<(), BusError> {
727 self.call_method(
728 Some(NOTIFY_DEST),
729 NOTIFY_PATH,
730 Some(NOTIFY_DEST),
731 "CloseNotification",
732 &(id,),
733 )
734 .map(|_| ())
735 .map_err(classify)
736 }
737}
738
739fn classify(e: zbus::Error) -> BusError {
744 let gone = match &e {
745 zbus::Error::InputOutput(io) => io.kind() != std::io::ErrorKind::TimedOut,
746 zbus::Error::MethodError(name, _, _) => matches!(
747 name.as_str(),
748 "org.freedesktop.DBus.Error.ServiceUnknown"
749 | "org.freedesktop.DBus.Error.Disconnected"
750 | "org.freedesktop.DBus.Error.NoServer"
751 ),
752 _ => false,
753 };
754 if gone {
755 BusError::Gone(e.to_string())
756 } else {
757 BusError::Call(e.to_string())
758 }
759}
760
761fn connect() -> Option<zbus::blocking::Connection> {
764 let conn = within(CALL_TIMEOUT, || {
765 zbus::blocking::connection::Builder::session()
766 .map(|b| b.method_timeout(CALL_TIMEOUT))
767 .and_then(|b| b.build())
768 .map_err(|e| tracing::debug!(error = %e, "cannot connect to the session bus"))
769 .ok()
770 });
771 if conn.is_none() {
772 tracing::debug!("no session bus; notifications use notify-send");
773 }
774 conn
775}
776
777fn within<T: Send + 'static>(
780 timeout: Duration,
781 f: impl FnOnce() -> Option<T> + Send + 'static,
782) -> Option<T> {
783 let (tx, rx) = mpsc::channel();
784 std::thread::Builder::new()
785 .name("rlm-notify-connect".into())
786 .spawn(move || {
787 let _ = tx.send(f());
788 })
789 .ok()?;
790 rx.recv_timeout(timeout).ok().flatten()
791}
792
793fn dbus_notify(
794 conn: &zbus::blocking::Connection,
795 replaces_id: u32,
796 title: &str,
797 body: &str,
798 expire_timeout: i32,
799) -> zbus::Result<u32> {
800 use zbus::zvariant::Value;
801 let mut hints: HashMap<&str, Value<'_>> = HashMap::new();
802 hints.insert("desktop-entry", Value::from(APP_ID));
803 hints.insert("urgency", Value::U8(1));
805 let actions: Vec<&str> = Vec::new();
806 let reply = conn.call_method(
807 Some(NOTIFY_DEST),
808 NOTIFY_PATH,
809 Some(NOTIFY_DEST),
810 "Notify",
811 &(
812 APP_NAME,
813 replaces_id,
814 APP_ID,
815 title,
816 body,
817 actions,
818 hints,
819 expire_timeout,
820 ),
821 )?;
822 reply.body().deserialize::<u32>()
823}
824
825fn notify_send(title: &str, body: &str) {
828 match Command::new("notify-send")
829 .args(["-a", APP_NAME, "-i", APP_ID, title, body])
830 .spawn()
831 {
832 Ok(mut child) => {
833 std::thread::spawn(move || {
834 let _ = child.wait();
835 });
836 }
837 Err(e) => tracing::debug!(error = %e, "notify-send unavailable; skipping notification"),
838 }
839}
840
841#[cfg(test)]
842mod tests {
843 use super::super::resolve::{Coverage, Mechanism, Resolution, Verdict};
844 use super::*;
845
846 #[derive(Debug, Clone, PartialEq, Eq)]
847 enum Call {
848 Show(String, String, String),
849 Close(String),
850 }
851
852 #[derive(Default)]
853 struct Fake(Vec<Call>);
854
855 impl NotifySink for Fake {
856 fn show(&mut self, key: &str, title: &str, body: &str) {
857 self.0
858 .push(Call::Show(key.into(), title.into(), body.into()));
859 }
860 fn close(&mut self, key: &str) {
861 self.0.push(Call::Close(key.into()));
862 }
863 }
864
865 impl Notifier<Fake> {
866 fn take(&mut self) -> Vec<Call> {
867 std::mem::take(&mut self.sink.0)
868 }
869 }
870
871 const GB: u64 = 1_000_000_000;
872
873 fn res(cg: &str) -> Resolution {
874 Resolution {
875 cgroup: cg.into(),
876 unit: None,
877 verdict: Verdict::Freeze,
878 coverage: Coverage::Full,
879 mechanism: Mechanism::Raw,
880 }
881 }
882
883 fn freeze(cg: &str) -> Action {
884 Action::Freeze {
885 res: res(cg),
886 name: "firefox".into(),
887 }
888 }
889
890 fn cap(cg: &str) -> Action {
891 Action::Cap {
892 res: res(cg),
893 name: "firefox".into(),
894 }
895 }
896
897 fn ok() -> Option<Applied> {
898 Some(Applied { cap_bytes: None })
899 }
900
901 fn capped(bytes: u64) -> Option<Applied> {
902 Some(Applied {
903 cap_bytes: Some(bytes),
904 })
905 }
906
907 fn notifier(notify: bool, pressure: bool) -> Notifier<Fake> {
908 let cfg = GuardConfig {
909 notify,
910 notify_pressure: pressure,
911 ..GuardConfig::default()
912 };
913 let mut n = Notifier::new(Fake::default(), &cfg);
914 n.cgroup_exists = Box::new(|_| true);
915 n
916 }
917
918 fn names(key: &str, _cg: &str) -> String {
919 display_name(key, &HashMap::new(), None)
920 }
921
922 fn paused() -> Call {
923 let (t, b) = paused_text("Firefox", 5);
924 Call::Show("firefox".into(), t, b)
925 }
926
927 fn slowed(bytes: u64) -> Call {
928 let (t, b) = slowed_text("Firefox", bytes);
929 Call::Show("firefox".into(), t, b)
930 }
931
932 #[test]
933 fn texts_read_as_specified() {
934 assert_eq!(
935 paused_text("Firefox", 5),
936 (
937 "Firefox paused".to_string(),
938 "Paused for 5 seconds while memory is low.".to_string()
939 )
940 );
941 assert_eq!(
942 slowed_text("Firefox", 3_200_000_000),
943 (
944 "Firefox slowed down".to_string(),
945 "Held to about 3.2 GB until memory frees up.".to_string()
946 )
947 );
948 assert!(paused_text("X", 1).1.contains("for 1 second while"));
949 assert_eq!(format_size(512_000_000), "512 MB");
950 assert_eq!(format_size(268_435_456), "268 MB");
951 }
952
953 #[test]
954 fn freeze_shows_paused_once_for_all_of_an_apps_cgroups() {
955 let mut n = notifier(true, false);
956 n.record(&freeze("/a"), ok());
957 n.record(&freeze("/b"), ok());
958 n.end_tick(0, Memory::Low, &mut names);
959 assert_eq!(n.take(), vec![paused()]);
960 n.end_tick(1_000, Memory::Low, &mut names);
961 assert!(n.take().is_empty(), "nothing changed, nothing sent");
962 }
963
964 #[test]
965 fn thaw_then_cap_in_one_tick_replaces_without_close() {
966 let mut n = notifier(true, false);
967 n.record(&freeze("/a"), ok());
968 n.end_tick(0, Memory::Low, &mut names);
969 n.take();
970 n.record(&Action::Thaw { res: res("/a") }, ok());
971 n.record(&cap("/a"), capped(3 * GB));
972 n.end_tick(5_000, Memory::Low, &mut names);
973 assert_eq!(n.take(), vec![slowed(3 * GB)]);
974 }
975
976 #[test]
977 fn cap_on_the_tick_after_a_thaw_still_replaces() {
978 let mut n = notifier(true, false);
979 n.record(&freeze("/a"), ok());
980 n.end_tick(0, Memory::Low, &mut names);
981 n.take();
982 n.record(&Action::Thaw { res: res("/a") }, ok());
983 n.end_tick(5_000, Memory::Low, &mut names);
984 assert!(n.take().is_empty(), "the close waits a tick");
985 n.record(&cap("/a"), capped(2 * GB));
986 n.end_tick(6_000, Memory::Low, &mut names);
987 assert_eq!(n.take(), vec![slowed(2 * GB)]);
988 }
989
990 #[test]
991 fn thaw_alone_closes() {
992 let mut n = notifier(true, false);
993 n.record(&freeze("/a"), ok());
994 n.end_tick(0, Memory::Low, &mut names);
995 n.take();
996 n.record(&Action::Thaw { res: res("/a") }, ok());
997 n.end_tick(5_000, Memory::Fine, &mut names);
998 n.end_tick(6_000, Memory::Fine, &mut names);
999 assert_eq!(n.take(), vec![Call::Close("firefox".into())]);
1000 }
1001
1002 #[test]
1003 fn lift_closes_in_the_same_tick() {
1004 let mut n = notifier(true, false);
1005 n.record(&cap("/a"), capped(GB));
1006 n.end_tick(0, Memory::Low, &mut names);
1007 assert_eq!(n.take(), vec![slowed(GB)]);
1008 n.record(&Action::LiftCap { res: res("/a") }, ok());
1009 n.end_tick(40_000, Memory::Fine, &mut names);
1010 assert_eq!(n.take(), vec![Call::Close("firefox".into())]);
1011 }
1012
1013 #[test]
1014 fn a_release_that_reported_an_error_still_closes() {
1015 let mut n = notifier(true, false);
1016 n.record(&cap("/a"), capped(GB));
1017 n.end_tick(0, Memory::Low, &mut names);
1018 n.take();
1019 n.record(&Action::LiftCap { res: res("/a") }, None);
1020 n.end_tick(1_000, Memory::Fine, &mut names);
1021 assert_eq!(n.take(), vec![Call::Close("firefox".into())]);
1022 }
1023
1024 fn not_resumed() -> Call {
1025 let (t, b) = failed_resume_text("Firefox");
1026 Call::Show("firefox".into(), t, b)
1027 }
1028
1029 #[test]
1030 fn a_failed_thaw_replaces_the_notification_instead_of_closing() {
1031 assert_eq!(
1032 failed_resume_text("Firefox"),
1033 (
1034 "Firefox could not be resumed".to_string(),
1035 "It may stay paused. Run rlm guard status for details.".to_string()
1036 )
1037 );
1038 let mut n = notifier(true, false);
1039 n.record(&freeze("/a"), ok());
1040 n.end_tick(0, Memory::Low, &mut names);
1041 n.take();
1042 n.record(&Action::Thaw { res: res("/a") }, None);
1043 n.end_tick(5_000, Memory::Fine, &mut names);
1044 assert_eq!(n.take(), vec![not_resumed()]);
1045 n.end_tick(6_000, Memory::Fine, &mut names);
1046 assert!(n.take().is_empty(), "stays up");
1047 n.close_all();
1048 assert_eq!(n.take(), vec![Call::Close("firefox".into())]);
1049 }
1050
1051 #[test]
1052 fn a_failed_thaw_notice_closes_once_its_cgroup_is_gone() {
1053 let exists = std::rc::Rc::new(std::cell::Cell::new(true));
1054 let mut n = notifier(true, false);
1055 let e = exists.clone();
1056 n.cgroup_exists = Box::new(move |cg| cg != "/a" || e.get());
1057 n.record(&freeze("/a"), ok());
1058 n.end_tick(0, Memory::Low, &mut names);
1059 n.record(&Action::Thaw { res: res("/a") }, None);
1060 n.end_tick(5_000, Memory::Fine, &mut names);
1061 assert_eq!(n.take(), vec![paused(), not_resumed()]);
1062 exists.set(false);
1063 n.end_tick(6_000, Memory::Fine, &mut names);
1064 assert_eq!(n.take(), vec![Call::Close("firefox".into())]);
1065 n.end_tick(7_000, Memory::Fine, &mut names);
1066 assert!(n.take().is_empty());
1067 }
1068
1069 #[test]
1070 fn a_later_successful_release_closes_the_failed_thaw_notice() {
1071 let mut n = notifier(true, false);
1072 n.record(&freeze("/a"), ok());
1073 n.end_tick(0, Memory::Low, &mut names);
1074 n.record(&Action::Thaw { res: res("/a") }, None);
1075 n.end_tick(5_000, Memory::Low, &mut names);
1076 n.take();
1077 n.record(&freeze("/a"), ok());
1078 n.end_tick(6_000, Memory::Low, &mut names);
1079 assert_eq!(n.take(), vec![paused()]);
1080 n.record(&Action::Thaw { res: res("/a") }, ok());
1081 n.end_tick(11_000, Memory::Fine, &mut names);
1082 n.end_tick(12_000, Memory::Fine, &mut names);
1083 assert_eq!(n.take(), vec![Call::Close("firefox".into())]);
1084 }
1085
1086 #[test]
1087 fn failed_interventions_send_nothing() {
1088 let mut n = notifier(true, false);
1089 n.record(&freeze("/a"), None);
1090 n.record(&cap("/b"), None);
1091 n.end_tick(0, Memory::Low, &mut names);
1092 assert!(n.take().is_empty());
1093 }
1094
1095 #[test]
1096 fn caps_of_one_app_add_up() {
1097 let mut n = notifier(true, false);
1098 n.record(&cap("/a"), capped(GB));
1099 n.record(&cap("/b"), capped(2 * GB));
1100 n.end_tick(0, Memory::Low, &mut names);
1101 assert_eq!(n.take(), vec![slowed(3 * GB)]);
1102 n.record(&Action::LiftCap { res: res("/a") }, ok());
1103 n.end_tick(1_000, Memory::Low, &mut names);
1104 assert_eq!(n.take(), vec![slowed(2 * GB)], "updated, not closed");
1105 }
1106
1107 #[test]
1108 fn disabled_notify_sends_nothing() {
1109 let mut n = notifier(false, true);
1110 n.record(&freeze("/a"), ok());
1111 n.end_tick(0, Memory::Low, &mut names);
1112 n.record(&Action::Thaw { res: res("/a") }, ok());
1113 n.end_tick(5_000, Memory::Low, &mut names);
1114 n.end_tick(6_000, Memory::Low, &mut names);
1115 n.close_all();
1116 assert!(n.take().is_empty());
1117 }
1118
1119 #[test]
1120 fn turning_notify_off_clears_what_is_shown() {
1121 let mut n = notifier(true, false);
1122 n.record(&freeze("/a"), ok());
1123 n.end_tick(0, Memory::Low, &mut names);
1124 n.take();
1125 n.set_flags(NotifyFlags {
1126 notify: false,
1127 pressure: false,
1128 });
1129 assert_eq!(n.take(), vec![Call::Close("firefox".into())]);
1130 n.end_tick(1_000, Memory::Low, &mut names);
1131 assert!(n.take().is_empty());
1132 }
1133
1134 #[test]
1135 fn close_all_closes_every_app() {
1136 let mut n = notifier(true, false);
1137 n.record(&freeze("/a"), ok());
1138 n.end_tick(0, Memory::Low, &mut names);
1139 n.take();
1140 n.close_all();
1141 assert_eq!(n.take(), vec![Call::Close("firefox".into())]);
1142 }
1143
1144 fn warning() -> Call {
1145 Call::Show(
1146 PRESSURE_KEY.into(),
1147 PRESSURE_TITLE.into(),
1148 PRESSURE_BODY.into(),
1149 )
1150 }
1151
1152 #[test]
1153 fn pressure_warning_is_off_by_default() {
1154 let mut n = Notifier::new(Fake::default(), &GuardConfig::default());
1155 n.end_tick(0, Memory::Low, &mut names);
1156 assert!(n.take().is_empty());
1157 }
1158
1159 #[test]
1160 fn pressure_warning_is_rate_limited_and_only_at_high() {
1161 let mut n = notifier(true, true);
1162 n.end_tick(0, Memory::Fine, &mut names);
1163 assert!(n.take().is_empty(), "Warn is not enough");
1164 n.end_tick(1_000, Memory::Low, &mut names);
1165 assert_eq!(n.take(), vec![warning()]);
1166 n.end_tick(30_000, Memory::Low, &mut names);
1167 assert!(n.take().is_empty(), "at most once a minute");
1168 n.end_tick(61_000, Memory::Low, &mut names);
1169 assert_eq!(n.take(), vec![warning()]);
1170 }
1171
1172 #[test]
1173 fn memory_is_low_only_when_pressure_is_high_and_memory_short() {
1174 let t = common::GuardTrigger::default();
1175 let sample = |avail| Sample {
1176 some_avg10: 40.0,
1177 full_avg10: 0.0,
1178 mem_available_mb: avail,
1179 mem_total_mb: 16_000,
1180 source: super::super::types::PsiSource::AppSlice,
1181 };
1182 assert_eq!(memory_state(Level::High, &sample(1_000), &t), Memory::Low);
1183 assert_eq!(
1184 memory_state(Level::High, &sample(8_000), &t),
1185 Memory::Fine,
1186 "a stall with half the RAM free is not low memory"
1187 );
1188 assert_eq!(memory_state(Level::Warn, &sample(1_000), &t), Memory::Fine);
1189 assert_eq!(memory_state(Level::Critical, &sample(300), &t), Memory::Low);
1190 }
1191
1192 #[test]
1193 fn pressure_warning_gives_way_to_an_intervention() {
1194 let mut n = notifier(true, true);
1195 n.end_tick(0, Memory::Low, &mut names);
1196 n.take();
1197 n.record(&freeze("/a"), ok());
1198 n.end_tick(1_000, Memory::Low, &mut names);
1199 assert_eq!(n.take(), vec![paused(), Call::Close(PRESSURE_KEY.into())]);
1200 n.end_tick(70_000, Memory::Low, &mut names);
1201 assert!(n.take().is_empty(), "no warning while an app is held");
1202 }
1203
1204 #[test]
1205 fn pressure_warning_needs_notify() {
1206 let mut n = notifier(false, true);
1207 n.end_tick(0, Memory::Low, &mut names);
1208 assert!(n.take().is_empty());
1209 }
1210
1211 #[test]
1212 fn reload_takes_only_valid_notification_flags() {
1213 let current = NotifyFlags {
1214 notify: true,
1215 pressure: false,
1216 };
1217 let mut changed = GuardConfig {
1218 notify_pressure: true,
1219 enabled: false,
1220 ..GuardConfig::default()
1221 };
1222 changed.timing.freeze_hold_secs = 30;
1223 assert_eq!(
1224 flags_after_reload(current, Ok(&changed)),
1225 NotifyFlags {
1226 notify: true,
1227 pressure: true
1228 }
1229 );
1230 let err = common::Error::Config("bad".into());
1231 assert_eq!(flags_after_reload(current, Err(&err)), current);
1232 }
1233
1234 use std::cell::{Cell, RefCell};
1235 use std::rc::Rc;
1236
1237 #[derive(Default)]
1239 struct Log {
1240 calls: RefCell<Vec<String>>,
1241 connects: RefCell<Vec<bool>>,
1244 fail_next: RefCell<Option<BusError>>,
1246 next_id: Cell<u32>,
1247 }
1248
1249 struct FakeBus(Rc<Log>, u32);
1250
1251 impl Bus for FakeBus {
1252 fn notify(&self, replaces_id: u32, title: &str, _body: &str) -> Result<u32, BusError> {
1253 if let Some(e) = self.0.fail_next.borrow_mut().take() {
1254 return Err(e);
1255 }
1256 let id = if replaces_id == 0 {
1257 self.0.next_id.set(self.0.next_id.get() + 1);
1258 self.0.next_id.get()
1259 } else {
1260 replaces_id
1261 };
1262 self.0
1263 .calls
1264 .borrow_mut()
1265 .push(format!("conn{} notify {replaces_id}->{id} {title}", self.1));
1266 Ok(id)
1267 }
1268
1269 fn close(&self, id: u32) -> Result<(), BusError> {
1270 if let Some(e) = self.0.fail_next.borrow_mut().take() {
1271 return Err(e);
1272 }
1273 self.0
1274 .calls
1275 .borrow_mut()
1276 .push(format!("conn{} close {id}", self.1));
1277 Ok(())
1278 }
1279 }
1280
1281 struct FakeConnector(Rc<Log>, u32);
1282
1283 impl Connector for FakeConnector {
1284 type Bus = FakeBus;
1285
1286 fn connect(&mut self) -> Option<FakeBus> {
1287 let ok = {
1288 let mut answers = self.0.connects.borrow_mut();
1289 if answers.is_empty() {
1290 true
1291 } else {
1292 answers.remove(0)
1293 }
1294 };
1295 self.0
1296 .calls
1297 .borrow_mut()
1298 .push(format!("connect {}", if ok { "ok" } else { "failed" }));
1299 ok.then(|| {
1300 self.1 += 1;
1301 FakeBus(Rc::clone(&self.0), self.1)
1302 })
1303 }
1304
1305 fn fallback(&mut self, title: &str, _body: &str) {
1306 self.0
1307 .calls
1308 .borrow_mut()
1309 .push(format!("notify-send {title}"));
1310 }
1311 }
1312
1313 fn show(key: &str, title: &str) -> Cmd {
1314 Cmd::Show {
1315 key: key.into(),
1316 title: title.into(),
1317 body: String::new(),
1318 }
1319 }
1320
1321 fn sender_with(log: &Rc<Log>) -> Sender<FakeConnector> {
1322 Sender::new(FakeConnector(Rc::clone(log), 0))
1323 }
1324
1325 fn take(log: &Log) -> Vec<String> {
1326 std::mem::take(&mut *log.calls.borrow_mut())
1327 }
1328
1329 #[test]
1330 fn a_failed_connect_is_retried_after_the_backoff() {
1331 let log = Rc::new(Log::default());
1332 log.connects.borrow_mut().push(false);
1333 let mut s = sender_with(&log);
1334 let t0 = Instant::now();
1335 s.handle(show("a", "A paused"), t0);
1336 s.handle(show("a", "A slowed"), t0 + Duration::from_secs(10));
1337 assert_eq!(
1338 take(&log),
1339 [
1340 "connect failed",
1341 "notify-send A paused",
1342 "notify-send A slowed"
1343 ],
1344 "no new attempt during the backoff"
1345 );
1346 s.handle(show("a", "A slowed"), t0 + RECONNECT_AFTER);
1347 s.handle(show("a", "A slowed again"), t0 + RECONNECT_AFTER);
1348 s.handle(
1349 Cmd::Close { key: "a".into() },
1350 t0 + RECONNECT_AFTER + Duration::from_secs(1),
1351 );
1352 assert_eq!(
1353 take(&log),
1354 [
1355 "connect ok",
1356 "conn1 notify 0->1 A slowed",
1357 "conn1 notify 1->1 A slowed again",
1358 "conn1 close 1"
1359 ]
1360 );
1361 }
1362
1363 #[test]
1364 fn a_broken_connection_is_dropped_and_rebuilt() {
1365 let log = Rc::new(Log::default());
1366 let mut s = sender_with(&log);
1367 let t0 = Instant::now();
1368 s.handle(show("a", "A paused"), t0);
1369 take(&log);
1370 *log.fail_next.borrow_mut() = Some(BusError::Gone("broken pipe".into()));
1371 s.handle(show("a", "A slowed"), t0);
1372 assert_eq!(
1373 take(&log),
1374 ["connect ok", "conn2 notify 0->2 A slowed"],
1375 "reconnects and shows it as a new notification"
1376 );
1377 *log.fail_next.borrow_mut() = Some(BusError::Gone("gone".into()));
1378 s.handle(Cmd::Close { key: "a".into() }, t0);
1379 s.handle(show("b", "B paused"), t0);
1380 assert_eq!(
1381 take(&log),
1382 ["connect ok", "conn3 notify 0->3 B paused"],
1383 "a failed close also resets"
1384 );
1385 }
1386
1387 #[test]
1388 fn a_timed_out_call_keeps_the_connection() {
1389 let log = Rc::new(Log::default());
1390 let mut s = sender_with(&log);
1391 let t0 = Instant::now();
1392 s.handle(show("a", "A paused"), t0);
1393 *log.fail_next.borrow_mut() = Some(BusError::Call("timed out".into()));
1394 s.handle(show("a", "A slowed"), t0);
1395 s.handle(show("a", "A slowed"), t0);
1396 assert_eq!(
1397 take(&log),
1398 [
1399 "connect ok",
1400 "conn1 notify 0->1 A paused",
1401 "conn1 notify 1->1 A slowed"
1402 ]
1403 );
1404 }
1405
1406 #[test]
1407 fn io_errors_other_than_timeouts_mean_the_connection_is_gone() {
1408 let io = |kind| zbus::Error::InputOutput(std::sync::Arc::new(std::io::Error::from(kind)));
1409 assert!(matches!(
1410 classify(io(std::io::ErrorKind::BrokenPipe)),
1411 BusError::Gone(_)
1412 ));
1413 assert!(matches!(
1414 classify(io(std::io::ErrorKind::TimedOut)),
1415 BusError::Call(_)
1416 ));
1417 assert!(matches!(
1418 classify(zbus::Error::InvalidReply),
1419 BusError::Call(_)
1420 ));
1421 }
1422
1423 #[test]
1424 fn a_slow_connect_gives_up() {
1425 let start = std::time::Instant::now();
1426 let got = within(Duration::from_millis(50), || {
1427 std::thread::sleep(Duration::from_secs(5));
1428 Some(1)
1429 });
1430 assert_eq!(got, None);
1431 assert!(start.elapsed() < Duration::from_secs(2));
1432 assert_eq!(within(Duration::from_secs(2), || Some(7)), Some(7));
1433 }
1434
1435 #[test]
1436 fn notifications_use_the_server_default_lifetime() {
1437 assert_eq!(EXPIRE_TIMEOUT, -1);
1438 }
1439
1440 #[test]
1441 fn display_names() {
1442 let mut desktop = HashMap::new();
1443 desktop.insert("code".to_string(), "Visual Studio Code".to_string());
1444 desktop.insert("claude".to_string(), "Claude".to_string());
1445 assert_eq!(display_name("code", &desktop, None), "Visual Studio Code");
1446 assert_eq!(display_name("firefox", &desktop, None), "Firefox");
1447 assert_eq!(display_name("node@app-x.scope", &desktop, None), "Node");
1448 assert_eq!(
1449 display_name("python3@run-u12.service", &desktop, None),
1450 "Python3"
1451 );
1452 assert_eq!(
1453 display_name("2.1.283", &HashMap::new(), Some("claude")),
1454 "Claude"
1455 );
1456 assert_eq!(display_name("2.1.283", &desktop, Some("claude")), "Claude");
1457 assert_eq!(display_name("2.1.283", &desktop, None), "2.1.283");
1458 assert_eq!(
1459 display_name("firefox", &desktop, Some("Isolated Web Co")),
1460 "Firefox",
1461 "comm is only used when the basename has no letters"
1462 );
1463 assert_eq!(display_name("élan", &desktop, None), "Élan");
1464 assert_eq!(display_name("", &desktop, None), "An app");
1465 }
1466}