Skip to main content

rlmctl_core/guard/
notify.rs

1//! Desktop notifications for guard interventions.
2//!
3//! The daemon loop hands every applied [`Action`] to a [`Notifier`], which
4//! keeps one notification per app: "<App> paused" after a freeze, replaced
5//! in place by "<App> slowed down" after a cap, and closed when the app is
6//! released. A thaw that fails replaces it with "<App> could not be
7//! resumed", which stays until a later release of that cgroup succeeds, the
8//! cgroup is gone, or the guard shuts down. Failed freezes and caps show
9//! nothing (they are in the history and the journal). An optional early
10//! warning ("Memory is running low") is sent at most once a minute while
11//! pressure is High or Critical and no app is held.
12//!
13//! Notifications never feed back into guard decisions: the [`Notifier`]
14//! only reads what the effector already did. The desktop transport,
15//! [`DesktopSink`], runs on its own thread so a slow or missing
16//! notification server can never hold up the guard loop.
17
18use 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
27/// Icon and desktop entry the notifications carry (the GTK app's).
28pub const APP_ID: &str = "io.github.rlm.gtk";
29/// Application name shown by the notification server.
30pub const APP_NAME: &str = "rlm";
31
32/// Title and body of the early warning.
33pub const PRESSURE_TITLE: &str = "Memory is running low";
34pub const PRESSURE_BODY: &str = "rlm will step in if an app keeps growing.";
35
36/// At most one early warning per this many ms.
37pub const PRESSURE_INTERVAL_MS: u64 = 60_000;
38
39/// Sink key of the early warning. App keys are exe basenames, which never
40/// contain a `/`, so this cannot collide with one.
41pub const PRESSURE_KEY: &str = "/pressure";
42
43/// How the memory looked on one tick, for the early warning.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Memory {
46    /// The policy did not run this tick.
47    Unknown,
48    /// Not both under High or Critical pressure and short of memory.
49    Fine,
50    /// Pressure is High or Critical and memory is short, as the policy
51    /// judges it before acting ([`is_scarce`]).
52    Low,
53}
54
55/// [`Memory`] for a tick whose policy computed `level` from `sample`.
56pub 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
64/// Where notifications go. `key` names one notification: a second `show`
65/// with the same key replaces it in place, `close` removes it.
66pub trait NotifySink {
67    fn show(&mut self, key: &str, title: &str, body: &str);
68    fn close(&mut self, key: &str);
69}
70
71/// Title and body for an app that was just frozen for `hold_secs`.
72pub 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        // GNOME shows one line of body text in the popup, so keep it short;
77        // the title already names the app.
78        format!("Paused for {hold_secs} {unit} while memory is low."),
79    )
80}
81
82/// Title and body for an app held under a `memory.high` of `cap_bytes`.
83pub 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
93/// Title and body for an app whose thaw failed, so it may still be frozen.
94pub 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
101/// A byte count in the decimal units desktops show: "3.2 GB", "512 MB".
102pub 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
113/// A friendly name for the app key the guard uses (an exe basename, or
114/// `<basename>@<cgroup leaf>` for runtimes). In order: the `Name` of an
115/// installed desktop entry that runs this program (`desktop` maps program
116/// basename to name), else the process name `comm` when the basename has no
117/// letters (a versioned binary such as `2.1.283`), else the basename with its
118/// first letter upper-cased. The `@leaf` suffix is never shown.
119pub 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
139/// Resolves app keys to [`display_name`]s from the system: installed
140/// desktop entries (read once) and, for versioned binaries, the process name
141/// of a member of the cgroup.
142pub struct AppNames {
143    desktop: HashMap<String, String>,
144    /// Desktop entries still being read on a background thread.
145    loading: Option<mpsc::Receiver<HashMap<String, String>>>,
146}
147
148impl AppNames {
149    /// Read the desktop entries now.
150    pub fn new() -> Self {
151        Self {
152            desktop: crate::desktop::names_by_program(),
153            loading: None,
154        }
155    }
156
157    /// Read the desktop entries on a background thread, so the caller (the
158    /// guard loop) never waits on the disk. Until they are read, names fall
159    /// back to the basename rules of [`display_name`].
160    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    /// The display name of `key`, whose processes live under `cgroup`.
174    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
201/// The process name of the first process under `cgroup` running `exe`.
202fn 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/// The two notification settings, the only ones the guard applies live.
212#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub struct NotifyFlags {
214    /// `guard.notify`: send notifications at all.
215    pub notify: bool,
216    /// `guard.notify_pressure`: also send the early warning.
217    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
229/// The flags to use after the config file changed on disk: the reloaded
230/// file's two notification flags when it is valid, else the `current` ones.
231/// Nothing else in the reloaded config is used.
232pub 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/// One cgroup the guard holds, as far as notifications care.
243#[derive(Debug, Clone)]
244struct Held {
245    app: String,
246    /// `Some` once capped: the `memory.high` written, in bytes.
247    cap_bytes: Option<u64>,
248}
249
250/// Keeps one notification per held app in step with what the effector did.
251///
252/// Call [`record`](Self::record) for every applied action, then
253/// [`end_tick`](Self::end_tick) once per loop iteration; notifications are
254/// only sent from `end_tick`, so a thaw and a cap of the same app in one tick
255/// replace the notification instead of closing and reopening it. A release
256/// by thaw also waits one more tick before closing, since the policy caps a
257/// still-hot app on the tick after its thaw.
258pub struct Notifier<S: NotifySink> {
259    sink: S,
260    flags: NotifyFlags,
261    freeze_hold_secs: u64,
262    /// Held cgroups, keyed by cgroup path.
263    held: HashMap<String, Held>,
264    /// Display name of each held or shown app, keyed by app.
265    names: HashMap<String, String>,
266    /// What each app's notification shows now, keyed by app.
267    shown: HashMap<String, (String, String)>,
268    /// Apps released by a thaw during the current tick.
269    thawed: HashSet<String>,
270    /// Cgroups whose thaw failed, so they may still be frozen, with their app.
271    stuck: HashMap<String, String>,
272    /// Whether a cgroup directory still exists. A `stuck` entry whose
273    /// cgroup is gone is dropped: nothing is left that could stay frozen.
274    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    /// Apply new notification settings. Turning `notify` off closes what is
305    /// shown; turning the early warning off closes it.
306    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    /// Note one action the effector applied. `applied` is `None` when it
316    /// failed. Cheap: nothing is sent or looked up until
317    /// [`end_tick`](Self::end_tick).
318    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            // A failed thaw may leave the app frozen, so its notification
337            // says so instead of closing.
338            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            // A cap lift closes the notification even if it reported an
351            // error: the guard no longer holds the cgroup either way.
352            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    /// Send what changed this tick. `memory` is how the memory looked on this
362    /// tick (see [`memory_state`]). `name` maps an app key
363    /// and one of its cgroups to a display name; it is called once per newly
364    /// held app, after every action of the tick was applied.
365    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        // The policy has dropped a cgroup whose thaw failed, so no later
373        // release will clear it; stop saying it may be paused once it is gone.
374        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    /// Close every notification this notifier has open (guard shutdown).
464    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
482/// Longest wait for one call to the notification server.
483const CALL_TIMEOUT: Duration = Duration::from_secs(2);
484
485/// The `expire_timeout` sent with every notification: -1, the server's
486/// default. A finite value is the popup's on-screen lifetime on KDE, dunst,
487/// mako and xfce4-notifyd, and a cap can outlast any fixed limit; the guard
488/// closes its notifications itself when it releases an app.
489const EXPIRE_TIMEOUT: i32 = -1;
490/// Requests queued for the sender thread; more are dropped.
491const 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
508/// Sends notifications to the desktop from a background thread, through the
509/// freedesktop Notifications D-Bus API on the session bus (`Notify` with
510/// `replaces_id` to update in place, `CloseNotification` to clear). The
511/// thread keeps the server's notification id per key. Without a session
512/// bus it falls back to `notify-send`, which can neither update nor close,
513/// and tries the bus again after 30 s; a connection that breaks is dropped
514/// and rebuilt on the next request. Requests never block the caller: they are queued, and dropped when the
515/// queue is full. Every failure is logged at debug and otherwise ignored.
516pub struct DesktopSink {
517    tx: Option<mpsc::SyncSender<Cmd>>,
518}
519
520impl DesktopSink {
521    /// Start the sender thread. It connects to the session bus on the first
522    /// request, so a guard that never notifies never connects.
523    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    /// Wait up to `timeout` for every queued request to be sent. Returns
538    /// false on timeout.
539    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
577/// The sender thread: hands every request to a [`Sender`] over the real
578/// session bus.
579fn 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
586/// How long a failed connect is remembered before the next request tries
587/// again. Requests in between use `notify-send`.
588const RECONNECT_AFTER: Duration = Duration::from_secs(30);
589
590/// Why a call to the notification server failed.
591#[derive(Debug)]
592enum BusError {
593    /// The connection or the server is gone: reconnect, and forget the ids.
594    Gone(String),
595    /// Only this call failed (for example it timed out).
596    Call(String),
597}
598
599/// One live connection to the notification server.
600trait 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
605/// Makes [`Bus`] connections, and sends without one.
606trait Connector {
607    type Bus: Bus;
608    fn connect(&mut self) -> Option<Self::Bus>;
609    /// Show a notification without a bus (it cannot be updated or closed).
610    fn fallback(&mut self, title: &str, body: &str);
611}
612
613/// The sender's state: the connection (or when to retry one) and the
614/// server's id for each shown notification.
615struct Sender<C: Connector> {
616    connector: C,
617    bus: Option<C::Bus>,
618    /// After a failed connect: no new attempt before this instant.
619    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    /// Connect if there is no connection and the retry delay has passed.
634    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    /// Drop a connection that is gone. The ids belonged to it (or to a
647    /// server that is gone), so they are forgotten too.
648    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                        // Show it anyway, on a new connection if one comes up.
675                        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
706/// The real [`Connector`]: the session bus, with `notify-send` as fallback.
707struct 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
739/// Sort a zbus error: an I/O failure other than a timeout means the
740/// connection is broken, and a missing or disconnected service means the
741/// notification server went away; both are [`BusError::Gone`]. Anything
742/// else, including a call that timed out, fails only that call.
743fn 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
761/// Connect to the session bus, giving up after [`CALL_TIMEOUT`] so a
762/// wedged bus cannot stall the sender; `None` means use `notify-send`.
763fn 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
777/// Run `f` on a helper thread and wait at most `timeout` for it. On timeout
778/// the thread is left to finish on its own and its result is dropped.
779fn 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    // Urgency normal.
804    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
825/// Fallback without a session bus: a plain `notify-send`, reaped on its own
826/// thread so a hung one cannot stall the sender.
827fn 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    /// What the fake bus and connector saw, shared with the test.
1238    #[derive(Default)]
1239    struct Log {
1240        calls: RefCell<Vec<String>>,
1241        /// Answers for the next connect attempts; `true` connects. Empty
1242        /// means connect.
1243        connects: RefCell<Vec<bool>>,
1244        /// When set, the next bus call fails with this.
1245        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}