Skip to main content

rlmctl_core/guard/
systemd.rs

1//! Blocking systemd user-bus (`org.freedesktop.systemd1`) client used on the
2//! freeze-guard's storm path as an alternative to fork+exec'ing `systemctl`.
3//!
4//! The connection is established once at daemon startup (pre-storm, when a
5//! slow session-bus handshake doesn't matter) and reused for every call. Each
6//! individual call still gets its own hard timeout: zbus's own blocking calls
7//! default to a 25s method-call timeout, which is far too long for a path that
8//! exists specifically to react before the system locks up. We enforce our own
9//! deadline by running the call on a spawned thread and waiting on it via
10//! `mpsc::Receiver::recv_timeout`. If the deadline passes first we return
11//! `Err` immediately; the spawned thread is left to finish (or never finish,
12//! if the bus is wedged) in the background and its result is dropped. This is
13//! a bounded leak — one thread per timed-out call — accepted because the
14//! storm path always falls back to raw cgroup writes on `Err`, so a wedged
15//! D-Bus call never blocks the daemon itself.
16
17use std::sync::mpsc;
18use std::thread;
19use std::time::Duration;
20
21use common::{Error, Result};
22use zbus::blocking::Connection;
23
24const DESTINATION: &str = "org.freedesktop.systemd1";
25const PATH: &str = "/org/freedesktop/systemd1";
26const INTERFACE: &str = "org.freedesktop.systemd1.Manager";
27
28/// Blocking client for the systemd user-session D-Bus manager.
29pub struct SystemdUser {
30    conn: Connection,
31}
32
33impl SystemdUser {
34    /// Connect to the session bus at daemon startup. Returns `None` if no
35    /// session bus is available (e.g. headless, no `DBUS_SESSION_BUS_ADDRESS`)
36    /// — callers then fall back to raw cgroup operations everywhere.
37    pub fn connect() -> Option<Self> {
38        Connection::session().ok().map(|conn| Self { conn })
39    }
40
41    /// `FreezeUnit(name)`. Returns `Err` on failure OR timeout; the caller
42    /// falls back to a raw cgroup freeze.
43    pub fn freeze_unit(&self, unit: &str, timeout: Duration) -> Result<()> {
44        let unit = unit.to_string();
45        self.call_with_timeout(timeout, move |conn| {
46            conn.call_method(
47                Some(DESTINATION),
48                PATH,
49                Some(INTERFACE),
50                "FreezeUnit",
51                &(unit.as_str(),),
52            )
53            .map(|_| ())
54            .map_err(|e| Error::Cgroup(format!("FreezeUnit({unit}) failed: {e}")))
55        })
56    }
57
58    /// `ThawUnit(name)`. Returns `Err` on failure OR timeout; the caller falls
59    /// back to a raw cgroup thaw.
60    pub fn thaw_unit(&self, unit: &str, timeout: Duration) -> Result<()> {
61        let unit = unit.to_string();
62        self.call_with_timeout(timeout, move |conn| {
63            conn.call_method(
64                Some(DESTINATION),
65                PATH,
66                Some(INTERFACE),
67                "ThawUnit",
68                &(unit.as_str(),),
69            )
70            .map(|_| ())
71            .map_err(|e| Error::Cgroup(format!("ThawUnit({unit}) failed: {e}")))
72        })
73    }
74
75    /// Run `f` against a clone of the connection on a spawned thread, and
76    /// enforce `timeout` ourselves rather than trusting zbus's own (25s)
77    /// default. See [`run_with_timeout`] for the timeout mechanics.
78    fn call_with_timeout(
79        &self,
80        timeout: Duration,
81        f: impl FnOnce(&Connection) -> Result<()> + Send + 'static,
82    ) -> Result<()> {
83        let conn = self.conn.clone();
84        run_with_timeout(timeout, move || f(&conn))
85    }
86}
87
88/// Run `f` on a spawned thread and wait for it via
89/// `mpsc::Receiver::recv_timeout(timeout)` instead of trusting the callee's
90/// own notion of a deadline. On timeout, returns `Err` immediately; the
91/// spawned thread is detached and left to finish (or never finish) in the
92/// background, with its eventual result silently dropped on send. This is a
93/// bounded leak — one thread per timed-out call — accepted because callers
94/// always treat `Err` as "fall back to raw cgroup ops", so a wedged call
95/// never blocks the daemon itself.
96fn run_with_timeout<T: Send + 'static>(
97    timeout: Duration,
98    f: impl FnOnce() -> Result<T> + Send + 'static,
99) -> Result<T> {
100    let (tx, rx) = mpsc::channel();
101    thread::spawn(move || {
102        // Ignore send errors: the receiver may already have timed out and
103        // been dropped, in which case there's nothing left to notify.
104        let _ = tx.send(f());
105    });
106    rx.recv_timeout(timeout).unwrap_or_else(|_| {
107        Err(Error::Cgroup(format!(
108            "D-Bus call timed out after {timeout:?}"
109        )))
110    })
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    /// Pure test of the timeout wrapper: a closure that outlives the deadline
118    /// must cause `run_with_timeout` to return `Err` promptly, without
119    /// requiring a real session bus.
120    #[test]
121    fn run_with_timeout_returns_err_on_timeout() {
122        let result: Result<()> = run_with_timeout(Duration::from_millis(50), || {
123            thread::sleep(Duration::from_secs(5));
124            Ok(())
125        });
126        assert!(result.is_err(), "expected a timeout error");
127    }
128
129    /// A closure that finishes well within the deadline should return its
130    /// own `Ok` value through unchanged.
131    #[test]
132    fn run_with_timeout_returns_ok_when_fast() {
133        let result: Result<u32> = run_with_timeout(Duration::from_secs(2), || Ok(42));
134        assert_eq!(result.unwrap(), 42);
135    }
136
137    #[test]
138    #[ignore = "requires a session bus and a running user unit; run manually"]
139    fn freeze_thaw_transient_unit_roundtrip() {
140        // systemd-run --user --unit=rlm-dbus-test sleep 30 must be running.
141        let s = SystemdUser::connect().expect("session bus");
142        s.freeze_unit("rlm-dbus-test.service", Duration::from_secs(2))
143            .expect("freeze");
144        s.thaw_unit("rlm-dbus-test.service", Duration::from_secs(2))
145            .expect("thaw");
146    }
147}