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}