Skip to main content

stackless_daemon/
launchd.rs

1//! Boot persistence on macOS (ARCHITECTURE.md §3): register the daemon
2//! as a launchd user agent so the lease is a system guarantee across
3//! reboots and crashes. If registration is refused, stackless degrades
4//! loudly — `status`/`list` read the reason from `daemon.persistence`
5//! and warn that leases hold only while the daemon happens to run.
6//!
7//! KeepAlive is `{ SuccessfulExit = false }`: launchd restarts a crash
8//! but honors a clean drain, so `daemon stop` and the upgrade handshake
9//! (§3) still take the daemon down for good.
10
11use std::path::PathBuf;
12use std::process::Command;
13
14use stackless_core::paths::Paths;
15
16pub const LABEL: &str = "dev.stackless.daemon";
17
18/// The one-line file `status`/`list` read: "registered" on success, the
19/// failure reason otherwise.
20pub fn persistence_status_path(paths: &Paths) -> PathBuf {
21    paths.persistence_marker()
22}
23
24fn plist_path() -> Option<PathBuf> {
25    std::env::var_os("HOME").map(PathBuf::from).map(|home| {
26        home.join("Library/LaunchAgents")
27            .join(format!("{LABEL}.plist"))
28    })
29}
30
31/// Hand-written plist (a crate would be overkill for four keys). The
32/// binary path is the current exe — if the plist names a different one
33/// (the binary moved), it is rewritten.
34fn plist_xml(exe: &str) -> String {
35    let exe = xml_escape(exe);
36    format!(
37        r#"<?xml version="1.0" encoding="UTF-8"?>
38<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
39<plist version="1.0">
40<dict>
41    <key>Label</key>
42    <string>{LABEL}</string>
43    <key>ProgramArguments</key>
44    <array>
45        <string>{exe}</string>
46        <string>daemon</string>
47        <string>run</string>
48    </array>
49    <key>RunAtLoad</key>
50    <true/>
51    <key>KeepAlive</key>
52    <dict>
53        <key>SuccessfulExit</key>
54        <false/>
55    </dict>
56</dict>
57</plist>
58"#
59    )
60}
61
62fn xml_escape(s: &str) -> String {
63    s.replace('&', "&amp;")
64        .replace('<', "&lt;")
65        .replace('>', "&gt;")
66}
67
68/// Ensure the LaunchAgent exists and is bootstrapped. Records the
69/// outcome to `daemon.persistence` and never fails the daemon: a refused
70/// registration degrades loudly rather than aborting (§3).
71pub fn ensure_registered(paths: &Paths) {
72    let outcome = register();
73    let text = match &outcome {
74        Ok(()) => "registered".to_owned(),
75        Err(why) => why.clone(),
76    };
77    let marker = persistence_status_path(paths);
78    if let Some(dir) = marker.parent() {
79        let _ = std::fs::create_dir_all(dir);
80    }
81    let _ = std::fs::write(marker, text);
82}
83
84/// The actual registration work, returning a human reason on failure.
85fn register() -> Result<(), String> {
86    let exe = std::env::current_exe()
87        .map(|p| p.display().to_string())
88        .map_err(|err| format!("cannot resolve the stackless binary path: {err}"))?;
89    let plist =
90        plist_path().ok_or_else(|| "HOME is unset; cannot locate LaunchAgents".to_owned())?;
91    if let Some(dir) = plist.parent() {
92        std::fs::create_dir_all(dir)
93            .map_err(|err| format!("cannot create {}: {err}", dir.display()))?;
94    }
95
96    // Rewrite only when the content differs — a moved binary or a first
97    // install. Comparing avoids churning the file (and re-bootstrap) on
98    // every start.
99    let want = plist_xml(&exe);
100    let current = std::fs::read_to_string(&plist).ok();
101    if current.as_deref() != Some(want.as_str()) {
102        std::fs::write(&plist, &want)
103            .map_err(|err| format!("cannot write {}: {err}", plist.display()))?;
104    }
105
106    let uid = nix_getuid();
107    let domain = format!("gui/{uid}");
108    // Already bootstrapped is success — that is the steady state, and
109    // re-bootstrapping a loaded service is what launchd rejects (with an
110    // unhelpful "5: Input/output error", not a string we can match). So
111    // ask first: a service `launchctl print` can see is registered, full
112    // stop. Only bootstrap when it is genuinely absent.
113    if service_loaded(&domain) {
114        return Ok(());
115    }
116    let output = bootstrap(&domain, &plist)?;
117    if output.status.success() || service_loaded(&domain) {
118        return Ok(());
119    }
120    // A lingering disable record (from a past `launchctl bootout` or
121    // `disable`) makes bootstrap fail with an opaque "5: Input/output
122    // error". Enable and retry once before giving up.
123    if service_disabled(&domain) {
124        let _ = Command::new("launchctl")
125            .args(["enable", &format!("{domain}/{LABEL}")])
126            .output();
127        let retry = bootstrap(&domain, &plist)?;
128        if retry.status.success() || service_loaded(&domain) {
129            return Ok(());
130        }
131        let stderr = String::from_utf8_lossy(&retry.stderr);
132        return Err(format!(
133            "launchctl bootstrap {domain} failed after enabling the disabled service {LABEL}: {}",
134            stderr.trim()
135        ));
136    }
137    let stderr = String::from_utf8_lossy(&output.stderr);
138    Err(format!(
139        "launchctl bootstrap {domain} failed: {}",
140        stderr.trim()
141    ))
142}
143
144fn bootstrap(domain: &str, plist: &PathBuf) -> Result<std::process::Output, String> {
145    Command::new("launchctl")
146        .args(["bootstrap", domain])
147        .arg(plist)
148        .output()
149        .map_err(|err| format!("cannot run launchctl: {err}"))
150}
151
152/// Whether launchd holds a disable record for our label in `domain`.
153/// `print-disabled` lists one entry per line; the value reads `disabled`
154/// on current macOS and `true` on older releases.
155fn service_disabled(domain: &str) -> bool {
156    Command::new("launchctl")
157        .args(["print-disabled", domain])
158        .output()
159        .ok()
160        .map(|out| parse_disabled(&String::from_utf8_lossy(&out.stdout)))
161        .unwrap_or(false)
162}
163
164fn parse_disabled(print_disabled: &str) -> bool {
165    print_disabled.lines().any(|line| {
166        let Some(rest) = line.trim().strip_prefix(&format!("\"{LABEL}\"")) else {
167            return false;
168        };
169        let Some(value) = rest.trim().strip_prefix("=>") else {
170            return false;
171        };
172        matches!(value.trim().trim_end_matches(';'), "disabled" | "true")
173    })
174}
175
176/// Whether launchd already knows our service in `domain`. `print`
177/// exiting zero means the service is registered — the one fact we need.
178fn service_loaded(domain: &str) -> bool {
179    Command::new("launchctl")
180        .arg("print")
181        .arg(format!("{domain}/{LABEL}"))
182        .output()
183        .map(|out| out.status.success())
184        .unwrap_or(false)
185}
186
187/// Start the daemon under launchd supervision, returning `true` only when
188/// it actually ran the service via `launchctl kickstart`.
189///
190/// The CLI calls this from `spawn_daemon` before falling back to a direct
191/// `Command` spawn. A direct spawn produces an *unsupervised* daemon:
192/// `launchctl print` reports `state = not running` and KeepAlive never
193/// fires, so a `kill -9` is never restarted. Kickstarting an already
194/// bootstrapped service is what makes KeepAlive({SuccessfulExit=false})
195/// genuinely protect the steady-state daemon.
196///
197/// Returns `false` — and the caller spawns directly — unless all hold:
198///   * the plist exists at `~/Library/LaunchAgents/<LABEL>.plist`, and
199///   * its `ProgramArguments` binary equals the current exe (no stale
200///     path left behind by a moved/upgraded binary), and
201///   * the service is bootstrapped (`launchctl print` succeeds).
202///
203/// Those guards keep the first-ever run (no plist yet) and the
204/// post-upgrade run (plist still names the old binary) on the direct-spawn
205/// path. That spawned daemon's own `ensure_registered` then rewrites the
206/// plist to the current exe and re-bootstraps, so the *next* spawn sees a
207/// fresh, loaded service and converges onto this supervised path.
208pub fn kickstart_if_supervised() -> bool {
209    let Ok(exe) = std::env::current_exe() else {
210        return false;
211    };
212    let exe = exe.display().to_string();
213    let Some(plist) = plist_path() else {
214        return false;
215    };
216    // Plist must exist and name *this* binary; a stale path means a direct
217    // spawn is needed so the new daemon can rewrite + re-bootstrap.
218    match plist_program_binary(&plist) {
219        Some(named) if named == exe => {}
220        _ => return false,
221    }
222    let domain = format!("gui/{}", nix_getuid());
223    if !service_loaded(&domain) {
224        return false;
225    }
226    Command::new("launchctl")
227        .args(["kickstart", &format!("{domain}/{LABEL}")])
228        .output()
229        .map(|out| out.status.success())
230        .unwrap_or(false)
231}
232
233/// The first `<string>` inside the plist's `ProgramArguments` array — the
234/// binary launchd would exec. Returns `None` if the file is missing or the
235/// array cannot be located. A minimal scan: our plist is hand-written by
236/// `plist_xml`, so the first array entry is always the binary path.
237fn plist_program_binary(plist: &PathBuf) -> Option<String> {
238    let xml = std::fs::read_to_string(plist).ok()?;
239    let after_key = xml.split("<key>ProgramArguments</key>").nth(1)?;
240    let array = after_key.split("<array>").nth(1)?;
241    let array = array.split("</array>").next()?;
242    let open = array.find("<string>")? + "<string>".len();
243    let close = array[open..].find("</string>")? + open;
244    Some(xml_unescape(array[open..close].trim()))
245}
246
247fn xml_unescape(s: &str) -> String {
248    s.replace("&lt;", "<")
249        .replace("&gt;", ">")
250        .replace("&amp;", "&")
251}
252
253/// The caller's real uid. `launchctl`'s `gui/<uid>` domain wants the
254/// numeric uid; libc's `getuid` is the portable source without a crate.
255fn nix_getuid() -> u32 {
256    // SAFETY is enforced by the workspace `unsafe_code = "forbid"`; we
257    // shell out instead to stay within it.
258    Command::new("id")
259        .arg("-u")
260        .output()
261        .ok()
262        .and_then(|out| String::from_utf8(out.stdout).ok())
263        .and_then(|s| s.trim().parse().ok())
264        .unwrap_or(0)
265}
266
267/// Whether launchd currently knows our service in the caller's gui
268/// domain — the live fact, unlike the `daemon.persistence` file, which
269/// only records the outcome of the last daemon startup.
270pub fn service_registered() -> bool {
271    service_loaded(&format!("gui/{}", nix_getuid()))
272}
273
274/// The degradation warning for `status`/`list` — `None` when persistence
275/// is registered (the steady state), `Some(line)` when it is degraded.
276pub fn degradation_warning(paths: &Paths) -> Option<String> {
277    let status = std::fs::read_to_string(persistence_status_path(paths)).ok()?;
278    let status = status.trim();
279    if status.is_empty() || status == "registered" {
280        return None;
281    }
282    Some(format!(
283        "leases enforced only while the daemon happens to be running: {status}"
284    ))
285}
286
287#[cfg(test)]
288mod tests {
289    use super::parse_disabled;
290
291    #[test]
292    fn disable_record_current_macos() {
293        let out = "disabled services = {\n\t\t\"com.example.other\" => enabled\n\t\t\"dev.stackless.daemon\" => disabled\n\t}";
294        assert!(parse_disabled(out));
295    }
296
297    #[test]
298    fn disable_record_legacy_bool() {
299        let out = "\t\"dev.stackless.daemon\" => true;";
300        assert!(parse_disabled(out));
301    }
302
303    #[test]
304    fn enabled_or_absent_is_not_disabled() {
305        assert!(!parse_disabled("\t\"dev.stackless.daemon\" => enabled"));
306        assert!(!parse_disabled("\t\"com.example.other\" => disabled"));
307        assert!(!parse_disabled(""));
308    }
309}