Skip to main content

rlmctl_core/guard/
service.rs

1//! Reads the `rlm-guard` systemd user service's own active/enabled state, so
2//! `rlm guard status` can say whether the daemon is actually running instead
3//! of only reporting what it would do.
4
5use std::io::Read;
6use std::process::{Child, Command, Stdio};
7use std::thread;
8use std::time::{Duration, Instant};
9
10/// How long we give each `systemctl --user is-active`/`is-enabled` call
11/// before giving up on it. Both the CLI (`rlm guard status`) and the GUI's
12/// Guard page call [`query`] from a thread that must never hang
13/// indefinitely: the CLI would otherwise never return, and the GUI would
14/// otherwise freeze its whole main loop if systemd or its D-Bus is wedged.
15const SYSTEMCTL_TIMEOUT: Duration = Duration::from_secs(1);
16
17/// How often [`run_with_timeout`] polls a spawned child for completion.
18const POLL_INTERVAL: Duration = Duration::from_millis(20);
19
20/// Sentinel `active`/`enabled` value meaning the systemctl call didn't
21/// finish within [`SYSTEMCTL_TIMEOUT`] (a wedged systemd or D-Bus),
22/// distinct from `"unknown"` (systemctl could not even be spawned: missing
23/// binary, no user session). [`describe`] gives each its own wording.
24const TIMED_OUT: &str = "timeout";
25
26/// The two independent systemd states relevant to the guard: whether it is
27/// currently running (`is-active`) and whether it starts at login
28/// (`is-enabled`).
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ServiceState {
31    pub active: String,
32    pub enabled: String,
33}
34
35/// Query `systemctl --user is-active`/`is-enabled` for `rlm-guard`, each
36/// bounded by [`SYSTEMCTL_TIMEOUT`]. Both commands print their state word to
37/// stdout even when they exit non-zero (e.g. `inactive` exits 3), so their
38/// exit status is ignored; only their output matters. If `systemctl` cannot
39/// even be spawned (missing binary, no user session), both fields read
40/// `"unknown"` rather than the per-field fallback `state_word` would
41/// otherwise apply, since neither command ran at all. If either call times
42/// out, both fields read [`TIMED_OUT`] instead, so [`describe`] can report
43/// the wedge rather than a plain "not available".
44pub fn query() -> ServiceState {
45    match (
46        run_with_timeout(systemctl_command(&["is-active"]), SYSTEMCTL_TIMEOUT),
47        run_with_timeout(systemctl_command(&["is-enabled"]), SYSTEMCTL_TIMEOUT),
48    ) {
49        (RunOutcome::Output(a), RunOutcome::Output(e)) => ServiceState {
50            active: state_word(&a, "unknown"),
51            enabled: state_word(&e, "not-found"),
52        },
53        (RunOutcome::TimedOut, _) | (_, RunOutcome::TimedOut) => ServiceState {
54            active: TIMED_OUT.to_string(),
55            enabled: TIMED_OUT.to_string(),
56        },
57        _ => ServiceState {
58            active: "unknown".to_string(),
59            enabled: "unknown".to_string(),
60        },
61    }
62}
63
64/// Build `systemctl --user <verb> rlm-guard`.
65fn systemctl_command(verb_args: &[&str]) -> Command {
66    let mut cmd = Command::new("systemctl");
67    cmd.arg("--user");
68    cmd.args(verb_args);
69    cmd.arg("rlm-guard");
70    cmd
71}
72
73/// What running a [`Command`] under [`run_with_timeout`] produced.
74enum RunOutcome {
75    /// The process exited (any status) within the deadline; its stdout.
76    Output(String),
77    /// The process didn't exit within the deadline. It has already been
78    /// killed and reaped.
79    TimedOut,
80    /// The process could not even be spawned.
81    Failed,
82}
83
84/// Spawn `cmd` and wait for it to exit, polling [`Child::try_wait`] at
85/// [`POLL_INTERVAL`] rather than blocking on `Command::output()` (which has
86/// no timeout of its own). If `cmd` hasn't exited by `timeout`, it is killed
87/// and reaped so a wedged systemd/D-Bus never leaves a zombie behind on
88/// every repeated poll (the GUI calls this via [`query`] on a timer), and
89/// [`RunOutcome::TimedOut`] is returned immediately.
90fn run_with_timeout(mut cmd: Command, timeout: Duration) -> RunOutcome {
91    let Ok(mut child) = cmd.stdout(Stdio::piped()).stderr(Stdio::null()).spawn() else {
92        return RunOutcome::Failed;
93    };
94    let deadline = Instant::now() + timeout;
95    loop {
96        match child.try_wait() {
97            Ok(Some(_status)) => return RunOutcome::Output(read_child_stdout(&mut child)),
98            Ok(None) => {
99                if Instant::now() >= deadline {
100                    let _ = child.kill();
101                    let _ = child.wait();
102                    return RunOutcome::TimedOut;
103                }
104                thread::sleep(POLL_INTERVAL);
105            }
106            Err(_) => return RunOutcome::Failed,
107        }
108    }
109}
110
111/// Read whatever the child already wrote to its (now-closed) stdout pipe.
112/// Only called after `try_wait` confirms the child has exited, so this never
113/// blocks waiting for more output.
114fn read_child_stdout(child: &mut Child) -> String {
115    let mut out = String::new();
116    if let Some(mut stdout) = child.stdout.take() {
117        let _ = stdout.read_to_string(&mut out);
118    }
119    out
120}
121
122/// The first trimmed line of `stdout`, or `fallback` when it's empty.
123/// `systemctl --user is-enabled` prints nothing on stdout for a unit that
124/// isn't installed at all, hence callers pass `"not-found"` as that fallback.
125pub fn state_word(stdout: &str, fallback: &str) -> String {
126    match stdout.lines().next().map(str::trim) {
127        Some(s) if !s.is_empty() => s.to_string(),
128        _ => fallback.to_string(),
129    }
130}
131
132/// A one-line, human-readable summary of a [`ServiceState`] for `rlm guard status`.
133pub fn describe(s: &ServiceState) -> String {
134    if s.active == TIMED_OUT {
135        return "unknown (systemctl did not answer)".to_string();
136    }
137    if s.active == "unknown" {
138        return "unknown (systemctl --user is not available)".to_string();
139    }
140    if s.active == "failed" {
141        return "failed (see: journalctl --user -u rlm-guard -n 20)".to_string();
142    }
143    if s.enabled == "not-found" {
144        return "not installed (run: rlm guard enable)".to_string();
145    }
146    let running = if s.active == "active" {
147        "running"
148    } else {
149        "stopped"
150    };
151    let login = if s.enabled == "enabled" {
152        "starts at login"
153    } else {
154        "not started at login"
155    };
156    format!("{running} ({login})")
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn state_words_and_descriptions() {
165        assert_eq!(state_word("active\n", "unknown"), "active");
166        assert_eq!(state_word("", "not-found"), "not-found");
167        let d = |a: &str, e: &str| {
168            describe(&ServiceState {
169                active: a.into(),
170                enabled: e.into(),
171            })
172        };
173        assert_eq!(d("active", "enabled"), "running (starts at login)");
174        assert_eq!(d("inactive", "disabled"), "stopped (not started at login)");
175        assert_eq!(
176            d("inactive", "not-found"),
177            "not installed (run: rlm guard enable)"
178        );
179        assert!(d("failed", "enabled").starts_with("failed (see: journalctl --user -u rlm-guard"));
180        assert_eq!(
181            d("unknown", "unknown"),
182            "unknown (systemctl --user is not available)"
183        );
184        assert_eq!(
185            d(TIMED_OUT, TIMED_OUT),
186            "unknown (systemctl did not answer)"
187        );
188    }
189
190    /// Fix round 1, R19a: a wedged child (standing in for a hung `systemctl`
191    /// talking to a wedged systemd/D-Bus) must not be waited on past its
192    /// deadline. `sleep 5` run through the same [`run_with_timeout`] helper
193    /// `query` uses, with a deadline far shorter than the sleep, must return
194    /// promptly (well under the 5s the child would otherwise run for) and
195    /// leave no zombie behind.
196    #[test]
197    fn run_with_timeout_kills_and_reaps_a_hung_child() {
198        let mut cmd = Command::new("sleep");
199        cmd.arg("5");
200        let start = Instant::now();
201        let outcome = run_with_timeout(cmd, Duration::from_millis(100));
202        assert!(
203            matches!(outcome, RunOutcome::TimedOut),
204            "expected a timeout, not a completed run"
205        );
206        assert!(
207            start.elapsed() < Duration::from_secs(2),
208            "must not wait anywhere near the full 5s sleep"
209        );
210    }
211
212    /// A command that finishes well within the deadline returns its stdout
213    /// through unchanged.
214    #[test]
215    fn run_with_timeout_returns_output_when_fast() {
216        let mut cmd = Command::new("echo");
217        cmd.arg("hello");
218        let outcome = run_with_timeout(cmd, Duration::from_secs(1));
219        match outcome {
220            RunOutcome::Output(s) => assert_eq!(s.trim(), "hello"),
221            _ => panic!("expected Output, got a failure or timeout"),
222        }
223    }
224}