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, ServiceSelection};
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, services: &ServiceSelection) -> Result<String> {
133    let exe = validate_path(exe, "executable")?;
134    let socket = validate_path(socket, "socket")?;
135    // Bake the service subset onto `ExecStart` so it survives systemd's
136    // fixed-command, minimal-env exec. `All` appends nothing, keeping a
137    // full-registry unit byte-identical to before (#1318). The CSV is fixed
138    // ASCII service names (no spaces), but route it through `exec_arg` for
139    // uniformity with the other arguments.
140    let services_arg = match services.to_csv() {
141        Some(csv) => format!(" --services {}", exec_arg(&csv)),
142        None => String::new(),
143    };
144    Ok(format!(
145        r"[Unit]
146Description=omni-dev daemon
147Requires={socket_unit}
148After={socket_unit}
149
150[Service]
151ExecStart={exe} daemon run --socket {socket}{services_arg}
152",
153        socket_unit = SOCKET_UNIT,
154        exe = exec_arg(exe),
155        socket = exec_arg(socket),
156    ))
157}
158
159/// Renders the demand-activating `.socket` unit.
160///
161/// `WantedBy=sockets.target` is what `systemctl --user enable` hooks into the
162/// login startup sequence; `SocketMode=0600` makes systemd create the control
163/// socket owner-private from birth (the analogue of launchd's `SockPathMode`
164/// 384). systemd owns and creates the socket at `ListenStream` before the daemon
165/// process runs.
166fn render_socket(socket: &Path) -> Result<String> {
167    let socket = validate_path(socket, "socket")?;
168    Ok(format!(
169        r"[Unit]
170Description=omni-dev daemon control socket
171
172[Socket]
173ListenStream={socket}
174SocketMode=0600
175
176[Install]
177WantedBy=sockets.target
178",
179        socket = escape_value(socket),
180    ))
181}
182
183/// A `systemctl --user <args>` command builder.
184fn systemctl(args: &[&str]) -> Command {
185    let mut command = Command::new("systemctl");
186    command.arg("--user").args(args);
187    command
188}
189
190/// A runner for `systemctl --user <args>`: the args after `--user` in, the
191/// process output out. Abstracted behind a function type so the
192/// result-handling logic (`daemon_reload` / `enable_now` / `unload`) is
193/// unit-testable against a fake runner without spawning `systemctl`.
194type Systemctl<'a> = dyn Fn(&[&str]) -> std::io::Result<std::process::Output> + 'a;
195
196/// The production [`Systemctl`] runner: actually spawns the command.
197fn spawn_systemctl(args: &[&str]) -> std::io::Result<std::process::Output> {
198    systemctl(args).output()
199}
200
201/// Runs `systemctl --user daemon-reload` so a freshly written unit is picked up.
202fn daemon_reload(run: &Systemctl) -> Result<()> {
203    let output =
204        run(&["daemon-reload"]).context("failed to run `systemctl --user daemon-reload`")?;
205    if !output.status.success() {
206        bail!(
207            "`systemctl --user daemon-reload` failed: {}",
208            String::from_utf8_lossy(&output.stderr).trim()
209        );
210    }
211    Ok(())
212}
213
214/// Enables and starts the `.socket` unit (auto-start at login via
215/// `sockets.target`, plus arming the demand socket now), retrying a few times on
216/// a transient failure the way launchd's `bootstrap` does.
217fn enable_now(run: &Systemctl) -> Result<()> {
218    let mut last_err = String::new();
219    for attempt in 0..5 {
220        if attempt > 0 {
221            std::thread::sleep(Duration::from_millis(200));
222        }
223        let output = run(&["enable", "--now", SOCKET_UNIT])
224            .context("failed to run `systemctl --user enable --now`")?;
225        if output.status.success() {
226            return Ok(());
227        }
228        last_err = String::from_utf8_lossy(&output.stderr).trim().to_string();
229    }
230    bail!("`systemctl --user enable --now {SOCKET_UNIT}` failed: {last_err}");
231}
232
233/// Writes the `.service` + `.socket` unit files into `dir` (creating it if
234/// absent). Split from [`install_and_load`] so the rendering and on-disk layout
235/// are testable without invoking `systemctl`.
236fn write_units(dir: &Path, exe: &Path, socket: &Path, services: &ServiceSelection) -> Result<()> {
237    std::fs::create_dir_all(dir).with_context(|| format!("failed to create {}", dir.display()))?;
238    let service_unit = dir.join(SERVICE_UNIT);
239    let socket_unit = dir.join(SOCKET_UNIT);
240    std::fs::write(&service_unit, render_service(exe, socket, services)?)
241        .with_context(|| format!("failed to write {}", service_unit.display()))?;
242    std::fs::write(&socket_unit, render_socket(socket)?)
243        .with_context(|| format!("failed to write {}", socket_unit.display()))?;
244    Ok(())
245}
246
247/// Writes the unit files and enables the socket so systemd listens on the demand
248/// socket and spawns the daemon on the first client connect (and at login).
249pub fn install_and_load(socket: &Path, services: &ServiceSelection) -> Result<()> {
250    let exe = std::env::current_exe().context("could not resolve the current executable")?;
251    write_units(&unit_dir()?, &exe, socket, services)?;
252
253    // systemd creates the demand socket at `ListenStream` when the socket unit
254    // starts — *before* our process runs — and does not create missing parent
255    // directories. Ensure the owner-only (`0700`) runtime directory exists now so
256    // that socket creation succeeds (the same reason launchd needs it, #1081).
257    if let Some(parent) = socket.parent() {
258        paths::ensure_dir_0700(parent)?;
259    }
260
261    daemon_reload(&spawn_systemctl)?;
262    enable_now(&spawn_systemctl)
263}
264
265/// Best-effort `systemctl --user …`: a non-zero exit is ignored (the common
266/// "already stopped / not enabled" case), but a *spawn* failure (systemd absent)
267/// is logged rather than discarded so `stop` does not silently claim to have
268/// disabled auto-start when it had not — the same posture as launchd's `bootout`
269/// (issue #996).
270fn run_best_effort(run: &Systemctl, args: &[&str]) {
271    if let Err(e) = run(args) {
272        tracing::warn!("failed to run `systemctl --user {}`: {e}", args.join(" "));
273    }
274}
275
276/// Stops and disables the socket (and stops any running daemon) so it is not
277/// re-activated on the next client connect or at the next login.
278///
279/// The systemd analogue of launchd's `bootout`. Best-effort: a not-installed
280/// unit is not an error. The unit files are left on disk (a disabled unit does
281/// not auto-start); a later `daemon start` rewrites and re-enables them.
282pub fn unload() -> Result<()> {
283    unload_with(&spawn_systemctl)
284}
285
286/// [`unload`] against an injectable runner, so the stop/disable sequence is
287/// unit-testable without touching a real systemd manager.
288fn unload_with(run: &Systemctl) -> Result<()> {
289    // Stop the socket first so it is disarmed before the service is torn down;
290    // then drop the login-time enablement.
291    run_best_effort(run, &["stop", SOCKET_UNIT, SERVICE_UNIT]);
292    run_best_effort(run, &["disable", SOCKET_UNIT]);
293    Ok(())
294}
295
296/// Sets `FD_CLOEXEC` on `fd` so the inherited listening socket is not leaked into
297/// the child processes the daemon later spawns (git, `claude` CLI, `code`).
298///
299/// `fcntl(F_SETFD)` operates on the single descriptor and is thread-safe (no
300/// process-global state), unlike the `LISTEN_*` environment variables which we
301/// deliberately leave in place — clearing them mid-runtime would be an unsafe
302/// process-global env write, and our children ignore `LISTEN_PID` (it names our
303/// pid) regardless.
304#[allow(unsafe_code)]
305fn set_cloexec(fd: RawFd) -> Result<()> {
306    use nix::libc;
307
308    // SAFETY: `F_GETFD`/`F_SETFD` on a valid descriptor are simple integer
309    // syscalls with no memory effects; we check the -1 error return each time.
310    let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
311    if flags == -1 {
312        return Err(std::io::Error::last_os_error())
313            .context("F_GETFD on the systemd-activated socket failed");
314    }
315    let rc = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
316    if rc == -1 {
317        return Err(std::io::Error::last_os_error())
318            .context("setting FD_CLOEXEC on the systemd-activated socket failed");
319    }
320    Ok(())
321}
322
323/// Adopts the listening socket systemd created for us when the daemon was
324/// **socket-activated** (the `.socket` unit's single `ListenStream`), via the
325/// `sd_listen_fds` protocol reimplemented directly (no libsystemd dependency).
326///
327/// Returns `Ok(None)` when the process was *not* launched by systemd with an
328/// activation socket — the manual / dev / CI case (`omni-dev daemon run` from a
329/// shell) or the detached-spawn fallback — where the caller binds the socket
330/// itself via [`single_instance::bind_or_reclaim`](super::single_instance::bind_or_reclaim).
331///
332/// systemd passes activation fds starting at descriptor `3`, sets `LISTEN_FDS` to
333/// the count, and sets `LISTEN_PID` to the service's main pid so an inherited-but-
334/// stale value from a parent is ignored. We declare exactly one `ListenStream`,
335/// so we adopt descriptor `3`.
336#[allow(unsafe_code)]
337pub(crate) fn systemd_listener() -> Result<Option<UnixListener>> {
338    /// The first descriptor systemd passes for socket activation.
339    const SD_LISTEN_FDS_START: RawFd = 3;
340
341    // The activation fds are ours only if `LISTEN_PID` names this process.
342    let for_us = std::env::var("LISTEN_PID")
343        .ok()
344        .and_then(|pid| pid.parse::<u32>().ok())
345        .is_some_and(|pid| pid == std::process::id());
346    if !for_us {
347        return Ok(None);
348    }
349    let count = std::env::var("LISTEN_FDS")
350        .ok()
351        .and_then(|n| n.parse::<i32>().ok())
352        .unwrap_or(0);
353    if count < 1 {
354        return Ok(None);
355    }
356
357    let raw = SD_LISTEN_FDS_START;
358    set_cloexec(raw)?;
359
360    // SAFETY: `raw` (descriptor 3) is a listening Unix-domain socket handed off by
361    // systemd and now owned solely by us; adopting it into a std listener transfers
362    // ownership (closed on drop). It is converted to a Tokio listener after being
363    // set non-blocking, as the runtime requires.
364    let std_listener = unsafe { StdUnixListener::from_raw_fd(raw) };
365    std_listener
366        .set_nonblocking(true)
367        .context("failed to set the systemd socket non-blocking")?;
368    let listener = UnixListener::from_std(std_listener)
369        .context("failed to adopt the systemd socket into the Tokio runtime")?;
370    Ok(Some(listener))
371}
372
373#[cfg(test)]
374#[allow(clippy::unwrap_used, clippy::expect_used)]
375mod tests {
376    use super::*;
377    use crate::daemon::DaemonServiceKind;
378
379    /// Asserts a rendered unit is structurally well-formed: every non-blank line
380    /// is a `[Section]` header or a `Key=Value` directive. This is the property
381    /// that matters — a stray newline smuggled in from a path would produce a
382    /// line that is neither, corrupting the unit.
383    fn assert_well_formed(unit: &str) {
384        for line in unit.lines() {
385            if line.is_empty() {
386                continue;
387            }
388            let is_section = line.starts_with('[') && line.ends_with(']');
389            let is_directive = line.contains('=');
390            assert!(
391                is_section || is_directive,
392                "malformed systemd unit line: {line:?}\n---\n{unit}"
393            );
394        }
395    }
396
397    #[test]
398    fn renders_plain_paths_verbatim() {
399        let service = render_service(
400            Path::new("/usr/local/bin/omni-dev"),
401            Path::new("/tmp/omni-dev/daemon.sock"),
402            &ServiceSelection::All,
403        )
404        .expect("ASCII paths render");
405
406        assert!(
407            service.contains(
408                "ExecStart=\"/usr/local/bin/omni-dev\" daemon run --socket \"/tmp/omni-dev/daemon.sock\""
409            ),
410            "{service}"
411        );
412        assert!(
413            service.contains(&format!("Requires={SOCKET_UNIT}")),
414            "{service}"
415        );
416        assert_well_formed(&service);
417    }
418
419    #[test]
420    fn bakes_a_service_subset_onto_exec_start() {
421        // A subset selection appends `--services a,b` to `ExecStart` so it
422        // survives systemd's fixed-command exec (#1318). The CSV has no spaces,
423        // so `exec_arg` still wraps it in quotes for uniformity.
424        let service = render_service(
425            Path::new("/usr/local/bin/omni-dev"),
426            Path::new("/tmp/omni-dev/daemon.sock"),
427            &ServiceSelection::Only(vec![
428                DaemonServiceKind::Worktrees,
429                DaemonServiceKind::Sessions,
430            ]),
431        )
432        .expect("ASCII paths render");
433
434        assert!(
435            service.contains(
436                "daemon run --socket \"/tmp/omni-dev/daemon.sock\" --services \"worktrees,sessions\""
437            ),
438            "{service}"
439        );
440        assert_well_formed(&service);
441    }
442
443    #[test]
444    fn omits_services_when_hosting_all() {
445        // `All` appends no `--services`, keeping the full-registry unit identical
446        // to the pre-#1318 output.
447        let service = render_service(
448            Path::new("/usr/local/bin/omni-dev"),
449            Path::new("/tmp/omni-dev/daemon.sock"),
450            &ServiceSelection::All,
451        )
452        .expect("ASCII paths render");
453        assert!(!service.contains("--services"), "{service}");
454    }
455
456    #[test]
457    fn service_has_no_install_or_restart() {
458        // Activation is the socket's job: the service must not restart on its own
459        // or install itself into a login target.
460        let service = render_service(
461            Path::new("/usr/local/bin/omni-dev"),
462            Path::new("/tmp/omni-dev/daemon.sock"),
463            &ServiceSelection::All,
464        )
465        .expect("ASCII paths render");
466
467        assert!(!service.contains("Restart"), "{service}");
468        assert!(!service.contains("KeepAlive"), "{service}");
469        assert!(!service.contains("[Install]"), "{service}");
470    }
471
472    #[test]
473    fn exec_start_quotes_paths_with_spaces() {
474        // A space in a path must be quoted or systemd would split it into two
475        // arguments.
476        let service = render_service(
477            Path::new("/opt/A B/omni-dev"),
478            Path::new("/tmp/omni-dev/daemon.sock"),
479            &ServiceSelection::All,
480        )
481        .expect("spaced path renders");
482
483        assert!(
484            service.contains("ExecStart=\"/opt/A B/omni-dev\" daemon run"),
485            "{service}"
486        );
487        assert_well_formed(&service);
488    }
489
490    #[test]
491    fn escapes_percent_specifier() {
492        // `%` is systemd's specifier prefix and must be doubled everywhere.
493        let service = render_service(
494            Path::new("/usr/local/bin/omni-dev"),
495            Path::new("/tmp/50%/daemon.sock"),
496            &ServiceSelection::All,
497        )
498        .expect("percent path renders");
499        assert!(
500            service.contains("--socket \"/tmp/50%%/daemon.sock\""),
501            "{service}"
502        );
503
504        let socket = render_socket(Path::new("/tmp/50%/daemon.sock")).expect("renders");
505        assert!(
506            socket.contains("ListenStream=/tmp/50%%/daemon.sock"),
507            "{socket}"
508        );
509        assert!(!socket.contains("50%/"), "{socket}");
510        assert_well_formed(&socket);
511    }
512
513    #[test]
514    fn passes_xml_metacharacters_through_literally() {
515        // Unlike launchd's plist, systemd units are not XML — `&`, `<`, `>` are
516        // ordinary path characters and must survive verbatim.
517        let service = render_service(
518            Path::new("/usr/local/bin/omni-dev"),
519            Path::new("/tmp/a&b<c>d/daemon.sock"),
520            &ServiceSelection::All,
521        )
522        .expect("renders");
523        assert!(service.contains("/tmp/a&b<c>d/daemon.sock"), "{service}");
524        assert!(!service.contains("&amp;"), "{service}");
525        assert_well_formed(&service);
526    }
527
528    #[test]
529    fn renders_a_socket_activated_unit() {
530        let socket = render_socket(Path::new("/tmp/omni-dev/daemon.sock")).expect("renders");
531        assert!(socket.contains("[Socket]"), "{socket}");
532        assert!(
533            socket.contains("ListenStream=/tmp/omni-dev/daemon.sock"),
534            "{socket}"
535        );
536        assert!(socket.contains("SocketMode=0600"), "{socket}");
537        assert!(socket.contains("WantedBy=sockets.target"), "{socket}");
538        assert_well_formed(&socket);
539    }
540
541    #[test]
542    fn rejects_non_utf8_paths() {
543        use std::ffi::OsStr;
544        use std::os::unix::ffi::OsStrExt;
545
546        let bad = Path::new(OsStr::from_bytes(b"/tmp/\xFF/omni-dev"));
547        assert!(
548            render_service(bad, Path::new("/tmp/daemon.sock"), &ServiceSelection::All).is_err()
549        );
550        assert!(
551            render_service(Path::new("/usr/bin/omni-dev"), bad, &ServiceSelection::All).is_err()
552        );
553        assert!(render_socket(bad).is_err());
554    }
555
556    #[test]
557    fn rejects_newline_in_path() {
558        let bad = Path::new("/tmp/a\nb/daemon.sock");
559        assert!(
560            render_service(Path::new("/usr/bin/omni-dev"), bad, &ServiceSelection::All).is_err()
561        );
562        assert!(render_socket(bad).is_err());
563    }
564
565    /// The unit-test binary is not socket-activated, so the activation lookup
566    /// must report "no inherited socket" rather than error, letting the daemon
567    /// fall back to self-binding. (`LISTEN_PID` is unset, so this never touches
568    /// the reactor.)
569    #[test]
570    fn systemd_listener_is_none_when_not_activated() {
571        let result = systemd_listener();
572        assert!(
573            matches!(result, Ok(None)),
574            "expected Ok(None) outside systemd activation, got {result:?}"
575        );
576    }
577
578    #[test]
579    fn unit_dir_sits_under_systemd_user() {
580        let dir = unit_dir().expect("config dir resolves in test");
581        assert!(dir.ends_with("systemd/user"), "{}", dir.display());
582    }
583
584    #[test]
585    fn write_units_lays_down_both_unit_files() {
586        let tmp = tempfile::tempdir().unwrap();
587        let unit_dir = tmp.path().join("systemd").join("user");
588        write_units(
589            &unit_dir,
590            Path::new("/usr/local/bin/omni-dev"),
591            Path::new("/tmp/omni-dev/daemon.sock"),
592            &ServiceSelection::All,
593        )
594        .expect("units are written");
595
596        let service = std::fs::read_to_string(unit_dir.join(SERVICE_UNIT)).unwrap();
597        let socket = std::fs::read_to_string(unit_dir.join(SOCKET_UNIT)).unwrap();
598        assert!(
599            service.contains("ExecStart=\"/usr/local/bin/omni-dev\" daemon run --socket \"/tmp/omni-dev/daemon.sock\""),
600            "{service}"
601        );
602        assert!(
603            socket.contains("ListenStream=/tmp/omni-dev/daemon.sock"),
604            "{socket}"
605        );
606        assert!(socket.contains("WantedBy=sockets.target"), "{socket}");
607    }
608
609    #[test]
610    fn systemctl_targets_the_user_manager() {
611        let cmd = systemctl(&["daemon-reload"]);
612        assert_eq!(cmd.get_program().to_str(), Some("systemctl"));
613        let args: Vec<_> = cmd.get_args().map(|a| a.to_str().unwrap()).collect();
614        assert_eq!(args, ["--user", "daemon-reload"]);
615    }
616
617    /// A canned `systemctl` output for the runner-injection tests. `raw` is a
618    /// Unix wait-status: `0` = success, `256` = exited with code 1.
619    fn fake_output(raw: i32, stderr: &str) -> std::process::Output {
620        use std::os::unix::process::ExitStatusExt;
621        std::process::Output {
622            status: std::process::ExitStatus::from_raw(raw),
623            stdout: Vec::new(),
624            stderr: stderr.as_bytes().to_vec(),
625        }
626    }
627
628    #[test]
629    fn daemon_reload_reports_success_and_failure() {
630        daemon_reload(&|args| {
631            assert_eq!(args, ["daemon-reload"]);
632            Ok(fake_output(0, ""))
633        })
634        .expect("a zero-exit reload succeeds");
635
636        let err =
637            daemon_reload(&|_| Ok(fake_output(256, "boom"))).expect_err("a non-zero reload fails");
638        assert!(err.to_string().contains("boom"), "{err}");
639
640        assert!(
641            daemon_reload(&|_| Err(std::io::Error::other("no systemctl"))).is_err(),
642            "a spawn error must surface"
643        );
644    }
645
646    #[test]
647    fn enable_now_succeeds_on_the_first_attempt() {
648        let calls = std::cell::Cell::new(0);
649        enable_now(&|args| {
650            calls.set(calls.get() + 1);
651            assert_eq!(args, ["enable", "--now", SOCKET_UNIT]);
652            Ok(fake_output(0, ""))
653        })
654        .expect("first-attempt success");
655        assert_eq!(calls.get(), 1, "no retries on success");
656    }
657
658    #[test]
659    fn enable_now_retries_then_bails() {
660        let calls = std::cell::Cell::new(0);
661        let err = enable_now(&|_| {
662            calls.set(calls.get() + 1);
663            Ok(fake_output(256, "still failing"))
664        })
665        .expect_err("all attempts fail");
666        assert_eq!(calls.get(), 5, "retries the full budget");
667        assert!(err.to_string().contains("still failing"), "{err}");
668    }
669
670    #[test]
671    fn unload_stops_then_disables_best_effort() {
672        let seen: std::cell::RefCell<Vec<Vec<String>>> = std::cell::RefCell::new(Vec::new());
673        unload_with(&|args| {
674            seen.borrow_mut()
675                .push(args.iter().map(|s| (*s).to_string()).collect());
676            Ok(fake_output(0, ""))
677        })
678        .expect("unload is infallible");
679        let seen = seen.into_inner();
680        assert_eq!(seen[0], ["stop", SOCKET_UNIT, SERVICE_UNIT]);
681        assert_eq!(seen[1], ["disable", SOCKET_UNIT]);
682    }
683
684    #[test]
685    fn unload_swallows_a_missing_systemctl() {
686        // A spawn failure (systemd absent) must not turn `stop` into an error.
687        unload_with(&|_| Err(std::io::Error::other("no systemctl")))
688            .expect("best-effort teardown never fails");
689    }
690
691    #[test]
692    fn flag_is_truthy_recognises_on_and_off_values() {
693        for on in ["1", "true", "TRUE", " yes ", "On"] {
694            assert!(flag_is_truthy(on), "{on:?} should be truthy");
695        }
696        for off in ["0", "false", "no", "", "off", "disable"] {
697            assert!(!flag_is_truthy(off), "{off:?} should be falsey");
698        }
699    }
700
701    #[test]
702    fn is_available_is_a_total_bool() {
703        // Environment-dependent, so we only assert it never panics — exercising the
704        // disabled/booted/runtime-socket probe path in whatever env the test runs.
705        let _ = is_available();
706    }
707
708    /// `set_cloexec` must actually set the flag: clear it first, then confirm the
709    /// call turns it back on.
710    #[allow(unsafe_code)]
711    #[test]
712    fn set_cloexec_sets_the_flag() {
713        use std::os::unix::io::AsRawFd;
714
715        use nix::libc;
716
717        let dir = tempfile::tempdir().unwrap();
718        let listener = StdUnixListener::bind(dir.path().join("s.sock")).unwrap();
719        let fd = listener.as_raw_fd();
720
721        // Rust sets `FD_CLOEXEC` on sockets it creates, so clear it first to make
722        // the assertion meaningful.
723        // SAFETY: `F_SETFD`/`F_GETFD` on a live owned descriptor are simple flag
724        // syscalls with no memory effects.
725        let cleared = unsafe { libc::fcntl(fd, libc::F_SETFD, 0) };
726        assert_eq!(cleared, 0, "clearing FD_CLOEXEC failed");
727        assert_eq!(
728            unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC,
729            0,
730            "precondition: FD_CLOEXEC should be cleared"
731        );
732
733        set_cloexec(fd).expect("set_cloexec succeeds on a valid fd");
734
735        assert_ne!(
736            unsafe { libc::fcntl(fd, libc::F_GETFD) } & libc::FD_CLOEXEC,
737            0,
738            "FD_CLOEXEC should be set after set_cloexec"
739        );
740    }
741
742    #[test]
743    fn set_cloexec_errors_on_a_bad_fd() {
744        // -1 is never a valid descriptor, so `F_GETFD` fails and the error is
745        // surfaced rather than ignored.
746        assert!(set_cloexec(-1).is_err());
747    }
748}