Skip to main content

omni_dev/daemon/
systemd.rs

1//! Linux systemd **user**-unit integration for `omni-dev daemon start` / `stop`
2//! and socket activation — the Linux mirror of the macOS `launchd` module.
3//!
4//! `start` writes a socket-activated `.socket` + `.service` pair under
5//! `~/.config/systemd/user/` and enables the socket. Like the launchd model this
6//! is **socket-activated**: systemd owns the control socket (declared in the
7//! `.socket` unit's `ListenStream`) and spawns `omni-dev daemon run` the first
8//! time a client connects, handing it the listening file descriptor via the
9//! `sd_listen_fds` protocol ([`systemd_listener`]). There is no `Restart=` —
10//! on-demand activation *is* the model, so a crashed daemon is re-activated on
11//! the next connect for free, and `enable`-ing the socket into `sockets.target`
12//! is what makes it come up at login. A clean `daemon stop` stops and disables
13//! the socket so the daemon stays down. See ADR-0039 and issue #1174.
14//!
15//! When systemd is not managing the session (containers, non-systemd distros, or
16//! the `OMNI_DEV_DAEMON_DISABLE_SYSTEMD` escape hatch), [`is_available`] returns
17//! `false` and the caller falls back to the detached-spawn launcher.
18
19use std::os::unix::io::{FromRawFd, RawFd};
20use std::os::unix::net::UnixListener as StdUnixListener;
21use std::path::{Path, PathBuf};
22use std::process::Command;
23use std::time::Duration;
24
25use anyhow::{bail, Context, Result};
26use tokio::net::UnixListener;
27
28use super::paths;
29
30/// The socket-activated `.service` unit filename.
31pub const SERVICE_UNIT: &str = "omni-dev-daemon.service";
32
33/// The demand-activating `.socket` unit filename.
34pub const SOCKET_UNIT: &str = "omni-dev-daemon.socket";
35
36/// When set to a truthy value (`1`/`true`/`yes`/`on`), forces the detached-spawn
37/// launcher even where systemd *is* available. Used by tests (so `daemon start`
38/// does not install a real unit under the developer's `~/.config`) and by users
39/// who prefer the old behavior.
40const DISABLE_ENV: &str = "OMNI_DEV_DAEMON_DISABLE_SYSTEMD";
41
42/// The per-user systemd unit directory (`~/.config/systemd/user`, honoring
43/// `XDG_CONFIG_HOME`).
44fn unit_dir() -> Result<PathBuf> {
45    let base = dirs::config_dir().context("could not determine the user config directory")?;
46    Ok(base.join("systemd").join("user"))
47}
48
49/// Whether `OMNI_DEV_DAEMON_DISABLE_SYSTEMD` is set to a truthy value.
50fn systemd_disabled() -> bool {
51    std::env::var(DISABLE_ENV).is_ok_and(|v| flag_is_truthy(&v))
52}
53
54/// Whether an environment-flag string counts as "on" (`1`/`true`/`yes`/`on`,
55/// case- and surrounding-whitespace-insensitive).
56fn flag_is_truthy(v: &str) -> bool {
57    matches!(
58        v.trim().to_ascii_lowercase().as_str(),
59        "1" | "true" | "yes" | "on"
60    )
61}
62
63/// Whether a systemd **user** manager is running and can host the daemon.
64///
65/// A fast, subprocess-free probe: the system must be booted with systemd
66/// (`/run/systemd/system` exists) *and* the current user's manager control
67/// socket (`$XDG_RUNTIME_DIR/systemd/private`, defaulting to
68/// `/run/user/<uid>/systemd/private`) must be present. Returns `false` under the
69/// `OMNI_DEV_DAEMON_DISABLE_SYSTEMD` escape hatch. When this is `false` the
70/// caller falls back to a detached `daemon run` spawn.
71pub fn is_available() -> bool {
72    if systemd_disabled() {
73        return false;
74    }
75    if !Path::new("/run/systemd/system").is_dir() {
76        return false;
77    }
78    let runtime = std::env::var_os("XDG_RUNTIME_DIR").map_or_else(
79        || PathBuf::from(format!("/run/user/{}", nix::unistd::getuid())),
80        PathBuf::from,
81    );
82    runtime.join("systemd").join("private").exists()
83}
84
85/// Validates that a path can be faithfully placed in a UTF-8 systemd unit file
86/// on a single directive line, returning it as `&str`.
87///
88/// A non-UTF-8 path cannot be represented in the unit file, and a path with an
89/// embedded newline would corrupt the single-line `ExecStart=`/`ListenStream=`
90/// directive — both are rejected here rather than silently mangled, mirroring
91/// launchd's non-UTF-8 rejection (issue #991).
92fn validate_path<'a>(path: &'a Path, what: &str) -> Result<&'a str> {
93    let s = path
94        .to_str()
95        .with_context(|| format!("{what} path is not valid UTF-8: {}", path.display()))?;
96    if s.contains(['\n', '\r']) {
97        bail!("{what} path contains a newline, which cannot be placed in a systemd unit: {s}");
98    }
99    Ok(s)
100}
101
102/// Escapes a value for a systemd command line (`ExecStart=`) argument: wraps it
103/// in double quotes and escapes every character systemd would otherwise
104/// interpret. `ExecStart` is whitespace-split into arguments, so a path with a
105/// space *must* be quoted; inside double quotes systemd applies C-style
106/// backslash unescaping (`\\`, `\"`), textual specifier expansion (`%%` → `%`),
107/// and environment expansion (`$$` → `$`).
108fn exec_arg(s: &str) -> String {
109    let escaped = s
110        .replace('\\', "\\\\") // backslash — the double-quote C-unescape metachar
111        .replace('"', "\\\"") // embedded double quote
112        .replace('%', "%%") // systemd specifier
113        .replace('$', "$$"); // environment expansion
114    format!("\"{escaped}\"")
115}
116
117/// Escapes a systemd config value taken literally to end-of-line (e.g.
118/// `ListenStream=`): only the `%` specifier is special there (no word-splitting,
119/// no `$` expansion).
120fn escape_value(s: &str) -> String {
121    s.replace('%', "%%")
122}
123
124/// Renders the socket-activated `.service` unit that execs
125/// `omni-dev daemon run --socket <socket>`.
126///
127/// It carries no `[Install]` section and no `Restart=`: the daemon is activated
128/// solely by the companion `.socket` unit (the systemd analogue of launchd's
129/// no-`KeepAlive` socket-activation model). The `--socket` argument mirrors the
130/// `.socket` unit's `ListenStream` path so the daemon resolves the same path for
131/// its co-located bridge token file even though it binds the inherited fd.
132fn render_service(exe: &Path, socket: &Path) -> Result<String> {
133    let exe = validate_path(exe, "executable")?;
134    let socket = validate_path(socket, "socket")?;
135    Ok(format!(
136        r"[Unit]
137Description=omni-dev daemon
138Requires={socket_unit}
139After={socket_unit}
140
141[Service]
142ExecStart={exe} daemon run --socket {socket}
143",
144        socket_unit = SOCKET_UNIT,
145        exe = exec_arg(exe),
146        socket = exec_arg(socket),
147    ))
148}
149
150/// Renders the demand-activating `.socket` unit.
151///
152/// `WantedBy=sockets.target` is what `systemctl --user enable` hooks into the
153/// login startup sequence; `SocketMode=0600` makes systemd create the control
154/// socket owner-private from birth (the analogue of launchd's `SockPathMode`
155/// 384). systemd owns and creates the socket at `ListenStream` before the daemon
156/// process runs.
157fn render_socket(socket: &Path) -> Result<String> {
158    let socket = validate_path(socket, "socket")?;
159    Ok(format!(
160        r"[Unit]
161Description=omni-dev daemon control socket
162
163[Socket]
164ListenStream={socket}
165SocketMode=0600
166
167[Install]
168WantedBy=sockets.target
169",
170        socket = escape_value(socket),
171    ))
172}
173
174/// A `systemctl --user <args>` command builder.
175fn systemctl(args: &[&str]) -> Command {
176    let mut command = Command::new("systemctl");
177    command.arg("--user").args(args);
178    command
179}
180
181/// A runner for `systemctl --user <args>`: the args after `--user` in, the
182/// process output out. Abstracted behind a function type so the
183/// result-handling logic (`daemon_reload` / `enable_now` / `unload`) is
184/// unit-testable against a fake runner without spawning `systemctl`.
185type Systemctl<'a> = dyn Fn(&[&str]) -> std::io::Result<std::process::Output> + 'a;
186
187/// The production [`Systemctl`] runner: actually spawns the command.
188fn spawn_systemctl(args: &[&str]) -> std::io::Result<std::process::Output> {
189    systemctl(args).output()
190}
191
192/// Runs `systemctl --user daemon-reload` so a freshly written unit is picked up.
193fn daemon_reload(run: &Systemctl) -> Result<()> {
194    let output =
195        run(&["daemon-reload"]).context("failed to run `systemctl --user daemon-reload`")?;
196    if !output.status.success() {
197        bail!(
198            "`systemctl --user daemon-reload` failed: {}",
199            String::from_utf8_lossy(&output.stderr).trim()
200        );
201    }
202    Ok(())
203}
204
205/// Enables and starts the `.socket` unit (auto-start at login via
206/// `sockets.target`, plus arming the demand socket now), retrying a few times on
207/// a transient failure the way launchd's `bootstrap` does.
208fn enable_now(run: &Systemctl) -> Result<()> {
209    let mut last_err = String::new();
210    for attempt in 0..5 {
211        if attempt > 0 {
212            std::thread::sleep(Duration::from_millis(200));
213        }
214        let output = run(&["enable", "--now", SOCKET_UNIT])
215            .context("failed to run `systemctl --user enable --now`")?;
216        if output.status.success() {
217            return Ok(());
218        }
219        last_err = String::from_utf8_lossy(&output.stderr).trim().to_string();
220    }
221    bail!("`systemctl --user enable --now {SOCKET_UNIT}` failed: {last_err}");
222}
223
224/// Writes the `.service` + `.socket` unit files into `dir` (creating it if
225/// absent). Split from [`install_and_load`] so the rendering and on-disk layout
226/// are testable without invoking `systemctl`.
227fn write_units(dir: &Path, exe: &Path, socket: &Path) -> Result<()> {
228    std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
229    let service_unit = dir.join(SERVICE_UNIT);
230    let socket_unit = dir.join(SOCKET_UNIT);
231    std::fs::write(&service_unit, render_service(exe, socket)?)
232        .with_context(|| format!("failed to write {}", service_unit.display()))?;
233    std::fs::write(&socket_unit, render_socket(socket)?)
234        .with_context(|| format!("failed to write {}", socket_unit.display()))?;
235    Ok(())
236}
237
238/// Writes the unit files and enables the socket so systemd listens on the demand
239/// socket and spawns the daemon on the first client connect (and at login).
240pub fn install_and_load(socket: &Path) -> Result<()> {
241    let exe = std::env::current_exe().context("could not resolve the current executable")?;
242    write_units(&unit_dir()?, &exe, socket)?;
243
244    // systemd creates the demand socket at `ListenStream` when the socket unit
245    // starts — *before* our process runs — and does not create missing parent
246    // directories. Ensure the owner-only (`0700`) runtime directory exists now so
247    // that socket creation succeeds (the same reason launchd needs it, #1081).
248    if let Some(parent) = socket.parent() {
249        paths::ensure_dir_0700(parent)?;
250    }
251
252    daemon_reload(&spawn_systemctl)?;
253    enable_now(&spawn_systemctl)
254}
255
256/// Best-effort `systemctl --user …`: a non-zero exit is ignored (the common
257/// "already stopped / not enabled" case), but a *spawn* failure (systemd absent)
258/// is logged rather than discarded so `stop` does not silently claim to have
259/// disabled auto-start when it had not — the same posture as launchd's `bootout`
260/// (issue #996).
261fn run_best_effort(run: &Systemctl, args: &[&str]) {
262    if let Err(e) = run(args) {
263        tracing::warn!("failed to run `systemctl --user {}`: {e}", args.join(" "));
264    }
265}
266
267/// Stops and disables the socket (and stops any running daemon) so it is not
268/// re-activated on the next client connect or at the next login.
269///
270/// The systemd analogue of launchd's `bootout`. Best-effort: a not-installed
271/// unit is not an error. The unit files are left on disk (a disabled unit does
272/// not auto-start); a later `daemon start` rewrites and re-enables them.
273pub fn unload() -> Result<()> {
274    unload_with(&spawn_systemctl)
275}
276
277/// [`unload`] against an injectable runner, so the stop/disable sequence is
278/// unit-testable without touching a real systemd manager.
279fn unload_with(run: &Systemctl) -> Result<()> {
280    // Stop the socket first so it is disarmed before the service is torn down;
281    // then drop the login-time enablement.
282    run_best_effort(run, &["stop", SOCKET_UNIT, SERVICE_UNIT]);
283    run_best_effort(run, &["disable", SOCKET_UNIT]);
284    Ok(())
285}
286
287/// Sets `FD_CLOEXEC` on `fd` so the inherited listening socket is not leaked into
288/// the child processes the daemon later spawns (git, `claude` CLI, `code`).
289///
290/// `fcntl(F_SETFD)` operates on the single descriptor and is thread-safe (no
291/// process-global state), unlike the `LISTEN_*` environment variables which we
292/// deliberately leave in place — clearing them mid-runtime would be an unsafe
293/// process-global env write, and our children ignore `LISTEN_PID` (it names our
294/// pid) regardless.
295#[allow(unsafe_code)]
296fn set_cloexec(fd: RawFd) -> Result<()> {
297    use nix::libc;
298
299    // SAFETY: `F_GETFD`/`F_SETFD` on a valid descriptor are simple integer
300    // syscalls with no memory effects; we check the -1 error return each time.
301    let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
302    if flags == -1 {
303        return Err(std::io::Error::last_os_error())
304            .context("F_GETFD on the systemd-activated socket failed");
305    }
306    let rc = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
307    if rc == -1 {
308        return Err(std::io::Error::last_os_error())
309            .context("setting FD_CLOEXEC on the systemd-activated socket failed");
310    }
311    Ok(())
312}
313
314/// Adopts the listening socket systemd created for us when the daemon was
315/// **socket-activated** (the `.socket` unit's single `ListenStream`), via the
316/// `sd_listen_fds` protocol reimplemented directly (no libsystemd dependency).
317///
318/// Returns `Ok(None)` when the process was *not* launched by systemd with an
319/// activation socket — the manual / dev / CI case (`omni-dev daemon run` from a
320/// shell) or the detached-spawn fallback — where the caller binds the socket
321/// itself via [`single_instance::bind_or_reclaim`](super::single_instance::bind_or_reclaim).
322///
323/// systemd passes activation fds starting at descriptor `3`, sets `LISTEN_FDS` to
324/// the count, and sets `LISTEN_PID` to the service's main pid so an inherited-but-
325/// stale value from a parent is ignored. We declare exactly one `ListenStream`,
326/// so we adopt descriptor `3`.
327#[allow(unsafe_code)]
328pub(crate) fn systemd_listener() -> Result<Option<UnixListener>> {
329    /// The first descriptor systemd passes for socket activation.
330    const SD_LISTEN_FDS_START: RawFd = 3;
331
332    // The activation fds are ours only if `LISTEN_PID` names this process.
333    let for_us = std::env::var("LISTEN_PID")
334        .ok()
335        .and_then(|pid| pid.parse::<u32>().ok())
336        .is_some_and(|pid| pid == std::process::id());
337    if !for_us {
338        return Ok(None);
339    }
340    let count = std::env::var("LISTEN_FDS")
341        .ok()
342        .and_then(|n| n.parse::<i32>().ok())
343        .unwrap_or(0);
344    if count < 1 {
345        return Ok(None);
346    }
347
348    let raw = SD_LISTEN_FDS_START;
349    set_cloexec(raw)?;
350
351    // SAFETY: `raw` (descriptor 3) is a listening Unix-domain socket handed off by
352    // systemd and now owned solely by us; adopting it into a std listener transfers
353    // ownership (closed on drop). It is converted to a Tokio listener after being
354    // set non-blocking, as the runtime requires.
355    let std_listener = unsafe { StdUnixListener::from_raw_fd(raw) };
356    std_listener
357        .set_nonblocking(true)
358        .context("failed to set the systemd socket non-blocking")?;
359    let listener = UnixListener::from_std(std_listener)
360        .context("failed to adopt the systemd socket into the Tokio runtime")?;
361    Ok(Some(listener))
362}
363
364#[cfg(test)]
365#[allow(clippy::unwrap_used, clippy::expect_used)]
366mod tests {
367    use super::*;
368
369    /// Asserts a rendered unit is structurally well-formed: every non-blank line
370    /// is a `[Section]` header or a `Key=Value` directive. This is the property
371    /// that matters — a stray newline smuggled in from a path would produce a
372    /// line that is neither, corrupting the unit.
373    fn assert_well_formed(unit: &str) {
374        for line in unit.lines() {
375            if line.is_empty() {
376                continue;
377            }
378            let is_section = line.starts_with('[') && line.ends_with(']');
379            let is_directive = line.contains('=');
380            assert!(
381                is_section || is_directive,
382                "malformed systemd unit line: {line:?}\n---\n{unit}"
383            );
384        }
385    }
386
387    #[test]
388    fn renders_plain_paths_verbatim() {
389        let service = render_service(
390            Path::new("/usr/local/bin/omni-dev"),
391            Path::new("/tmp/omni-dev/daemon.sock"),
392        )
393        .expect("ASCII paths render");
394
395        assert!(
396            service.contains(
397                "ExecStart=\"/usr/local/bin/omni-dev\" daemon run --socket \"/tmp/omni-dev/daemon.sock\""
398            ),
399            "{service}"
400        );
401        assert!(
402            service.contains(&format!("Requires={SOCKET_UNIT}")),
403            "{service}"
404        );
405        assert_well_formed(&service);
406    }
407
408    #[test]
409    fn service_has_no_install_or_restart() {
410        // Activation is the socket's job: the service must not restart on its own
411        // or install itself into a login target.
412        let service = render_service(
413            Path::new("/usr/local/bin/omni-dev"),
414            Path::new("/tmp/omni-dev/daemon.sock"),
415        )
416        .expect("ASCII paths render");
417
418        assert!(!service.contains("Restart"), "{service}");
419        assert!(!service.contains("KeepAlive"), "{service}");
420        assert!(!service.contains("[Install]"), "{service}");
421    }
422
423    #[test]
424    fn exec_start_quotes_paths_with_spaces() {
425        // A space in a path must be quoted or systemd would split it into two
426        // arguments.
427        let service = render_service(
428            Path::new("/opt/A B/omni-dev"),
429            Path::new("/tmp/omni-dev/daemon.sock"),
430        )
431        .expect("spaced path renders");
432
433        assert!(
434            service.contains("ExecStart=\"/opt/A B/omni-dev\" daemon run"),
435            "{service}"
436        );
437        assert_well_formed(&service);
438    }
439
440    #[test]
441    fn escapes_percent_specifier() {
442        // `%` is systemd's specifier prefix and must be doubled everywhere.
443        let service = render_service(
444            Path::new("/usr/local/bin/omni-dev"),
445            Path::new("/tmp/50%/daemon.sock"),
446        )
447        .expect("percent path renders");
448        assert!(
449            service.contains("--socket \"/tmp/50%%/daemon.sock\""),
450            "{service}"
451        );
452
453        let socket = render_socket(Path::new("/tmp/50%/daemon.sock")).expect("renders");
454        assert!(
455            socket.contains("ListenStream=/tmp/50%%/daemon.sock"),
456            "{socket}"
457        );
458        assert!(!socket.contains("50%/"), "{socket}");
459        assert_well_formed(&socket);
460    }
461
462    #[test]
463    fn passes_xml_metacharacters_through_literally() {
464        // Unlike launchd's plist, systemd units are not XML — `&`, `<`, `>` are
465        // ordinary path characters and must survive verbatim.
466        let service = render_service(
467            Path::new("/usr/local/bin/omni-dev"),
468            Path::new("/tmp/a&b<c>d/daemon.sock"),
469        )
470        .expect("renders");
471        assert!(service.contains("/tmp/a&b<c>d/daemon.sock"), "{service}");
472        assert!(!service.contains("&amp;"), "{service}");
473        assert_well_formed(&service);
474    }
475
476    #[test]
477    fn renders_a_socket_activated_unit() {
478        let socket = render_socket(Path::new("/tmp/omni-dev/daemon.sock")).expect("renders");
479        assert!(socket.contains("[Socket]"), "{socket}");
480        assert!(
481            socket.contains("ListenStream=/tmp/omni-dev/daemon.sock"),
482            "{socket}"
483        );
484        assert!(socket.contains("SocketMode=0600"), "{socket}");
485        assert!(socket.contains("WantedBy=sockets.target"), "{socket}");
486        assert_well_formed(&socket);
487    }
488
489    #[test]
490    fn rejects_non_utf8_paths() {
491        use std::ffi::OsStr;
492        use std::os::unix::ffi::OsStrExt;
493
494        let bad = Path::new(OsStr::from_bytes(b"/tmp/\xFF/omni-dev"));
495        assert!(render_service(bad, Path::new("/tmp/daemon.sock")).is_err());
496        assert!(render_service(Path::new("/usr/bin/omni-dev"), bad).is_err());
497        assert!(render_socket(bad).is_err());
498    }
499
500    #[test]
501    fn rejects_newline_in_path() {
502        let bad = Path::new("/tmp/a\nb/daemon.sock");
503        assert!(render_service(Path::new("/usr/bin/omni-dev"), bad).is_err());
504        assert!(render_socket(bad).is_err());
505    }
506
507    /// The unit-test binary is not socket-activated, so the activation lookup
508    /// must report "no inherited socket" rather than error, letting the daemon
509    /// fall back to self-binding. (`LISTEN_PID` is unset, so this never touches
510    /// the reactor.)
511    #[test]
512    fn systemd_listener_is_none_when_not_activated() {
513        let result = systemd_listener();
514        assert!(
515            matches!(result, Ok(None)),
516            "expected Ok(None) outside systemd activation, got {result:?}"
517        );
518    }
519
520    #[test]
521    fn unit_dir_sits_under_systemd_user() {
522        let dir = unit_dir().expect("config dir resolves in test");
523        assert!(dir.ends_with("systemd/user"), "{}", dir.display());
524    }
525
526    #[test]
527    fn write_units_lays_down_both_unit_files() {
528        let tmp = tempfile::tempdir().unwrap();
529        let unit_dir = tmp.path().join("systemd").join("user");
530        write_units(
531            &unit_dir,
532            Path::new("/usr/local/bin/omni-dev"),
533            Path::new("/tmp/omni-dev/daemon.sock"),
534        )
535        .expect("units are written");
536
537        let service = std::fs::read_to_string(unit_dir.join(SERVICE_UNIT)).unwrap();
538        let socket = std::fs::read_to_string(unit_dir.join(SOCKET_UNIT)).unwrap();
539        assert!(
540            service.contains("ExecStart=\"/usr/local/bin/omni-dev\" daemon run --socket \"/tmp/omni-dev/daemon.sock\""),
541            "{service}"
542        );
543        assert!(
544            socket.contains("ListenStream=/tmp/omni-dev/daemon.sock"),
545            "{socket}"
546        );
547        assert!(socket.contains("WantedBy=sockets.target"), "{socket}");
548    }
549
550    #[test]
551    fn systemctl_targets_the_user_manager() {
552        let cmd = systemctl(&["daemon-reload"]);
553        assert_eq!(cmd.get_program().to_str(), Some("systemctl"));
554        let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
555        assert_eq!(args, ["--user", "daemon-reload"]);
556    }
557
558    /// A canned `systemctl` output for the runner-injection tests. `raw` is a
559    /// Unix wait-status: `0` = success, `256` = exited with code 1.
560    fn fake_output(raw: i32, stderr: &str) -> std::process::Output {
561        use std::os::unix::process::ExitStatusExt;
562        std::process::Output {
563            status: std::process::ExitStatus::from_raw(raw),
564            stdout: Vec::new(),
565            stderr: stderr.as_bytes().to_vec(),
566        }
567    }
568
569    #[test]
570    fn daemon_reload_reports_success_and_failure() {
571        daemon_reload(&|args| {
572            assert_eq!(args, ["daemon-reload"]);
573            Ok(fake_output(0, ""))
574        })
575        .expect("a zero-exit reload succeeds");
576
577        let err =
578            daemon_reload(&|_| Ok(fake_output(256, "boom"))).expect_err("a non-zero reload fails");
579        assert!(err.to_string().contains("boom"), "{err}");
580
581        assert!(
582            daemon_reload(&|_| Err(std::io::Error::other("no systemctl"))).is_err(),
583            "a spawn error must surface"
584        );
585    }
586
587    #[test]
588    fn enable_now_succeeds_on_the_first_attempt() {
589        let calls = std::cell::Cell::new(0);
590        enable_now(&|args| {
591            calls.set(calls.get() + 1);
592            assert_eq!(args, ["enable", "--now", SOCKET_UNIT]);
593            Ok(fake_output(0, ""))
594        })
595        .expect("first-attempt success");
596        assert_eq!(calls.get(), 1, "no retries on success");
597    }
598
599    #[test]
600    fn enable_now_retries_then_bails() {
601        let calls = std::cell::Cell::new(0);
602        let err = enable_now(&|_| {
603            calls.set(calls.get() + 1);
604            Ok(fake_output(256, "still failing"))
605        })
606        .expect_err("all attempts fail");
607        assert_eq!(calls.get(), 5, "retries the full budget");
608        assert!(err.to_string().contains("still failing"), "{err}");
609    }
610
611    #[test]
612    fn unload_stops_then_disables_best_effort() {
613        let seen: std::cell::RefCell<Vec<Vec<String>>> = std::cell::RefCell::new(Vec::new());
614        unload_with(&|args| {
615            seen.borrow_mut()
616                .push(args.iter().map(|s| (*s).to_string()).collect());
617            Ok(fake_output(0, ""))
618        })
619        .expect("unload is infallible");
620        let seen = seen.into_inner();
621        assert_eq!(seen[0], ["stop", SOCKET_UNIT, SERVICE_UNIT]);
622        assert_eq!(seen[1], ["disable", SOCKET_UNIT]);
623    }
624
625    #[test]
626    fn unload_swallows_a_missing_systemctl() {
627        // A spawn failure (systemd absent) must not turn `stop` into an error.
628        unload_with(&|_| Err(std::io::Error::other("no systemctl")))
629            .expect("best-effort teardown never fails");
630    }
631
632    #[test]
633    fn flag_is_truthy_recognises_on_and_off_values() {
634        for on in ["1", "true", "TRUE", " yes ", "On"] {
635            assert!(flag_is_truthy(on), "{on:?} should be truthy");
636        }
637        for off in ["0", "false", "no", "", "off", "disable"] {
638            assert!(!flag_is_truthy(off), "{off:?} should be falsey");
639        }
640    }
641
642    #[test]
643    fn is_available_is_a_total_bool() {
644        // Environment-dependent, so we only assert it never panics — exercising the
645        // disabled/booted/runtime-socket probe path in whatever env the test runs.
646        let _ = is_available();
647    }
648
649    /// `set_cloexec` must actually set the flag: clear it first, then confirm the
650    /// call turns it back on.
651    #[allow(unsafe_code)]
652    #[test]
653    fn set_cloexec_sets_the_flag() {
654        use std::os::unix::io::AsRawFd;
655
656        use nix::libc;
657
658        let dir = tempfile::tempdir().unwrap();
659        let listener = StdUnixListener::bind(dir.path().join("s.sock")).unwrap();
660        let fd = listener.as_raw_fd();
661
662        // Rust sets `FD_CLOEXEC` on sockets it creates, so clear it first to make
663        // the assertion meaningful.
664        // SAFETY: `F_SETFD`/`F_GETFD` on a live owned descriptor are simple flag
665        // syscalls with no memory effects.
666        let cleared = unsafe { libc::fcntl(fd, libc::F_SETFD, 0) };
667        assert_eq!(cleared, 0, "clearing FD_CLOEXEC failed");
668        assert_eq!(
669            unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC,
670            0,
671            "precondition: FD_CLOEXEC should be cleared"
672        );
673
674        set_cloexec(fd).expect("set_cloexec succeeds on a valid fd");
675
676        assert_ne!(
677            unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC,
678            0,
679            "FD_CLOEXEC should be set after set_cloexec"
680        );
681    }
682
683    #[test]
684    fn set_cloexec_errors_on_a_bad_fd() {
685        // -1 is never a valid descriptor, so `F_GETFD` fails and the error is
686        // surfaced rather than ignored.
687        assert!(set_cloexec(-1).is_err());
688    }
689}