Skip to main content

oxios_kernel/
daemon.rs

1//! Daemon lifecycle management — PID file, start/stop, system service install.
2//!
3//! On macOS: launchd (`~/Library/LaunchAgents/com.a7garden.oxios.plist`)
4//! On Linux: systemd (`/etc/systemd/system/oxiosd.service`)
5
6use anyhow::{Context, Result};
7use std::path::{Path, PathBuf};
8
9/// Maximum time `stop` waits after SIGTERM before escalating to SIGKILL.
10/// Generous enough for a graceful agent/MCP drain; not so long that a hung
11/// daemon makes `stop` feel stuck. SIGKILL cannot be caught — it is the
12/// absolute last resort.
13const SIGTERM_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
14
15/// Daemon status.
16#[derive(Debug, Clone)]
17pub enum DaemonStatus {
18    /// Daemon is running.
19    Running {
20        /// Process ID.
21        pid: u32,
22    },
23    /// PID file exists but process is dead (stale).
24    Stale {
25        /// Process ID of the dead process.
26        pid: u32,
27    },
28    /// Daemon is not running.
29    Stopped,
30    /// Port is held by an oxios-shaped process that left no pidfile
31    /// (e.g. a `--foreground` debug instance launched directly).
32    /// Caller doesn't have a PID to report because there is no
33    /// pidfile/lockfile, just a port.
34    Orphaned {
35        /// Port that appears to be held by an oxios instance.
36        port: u16,
37    },
38}
39
40impl std::fmt::Display for DaemonStatus {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            DaemonStatus::Running { pid } => write!(f, "running (PID {pid})"),
44            DaemonStatus::Stale { pid } => write!(f, "stale (PID {pid} dead)"),
45            DaemonStatus::Stopped => write!(f, "stopped"),
46            DaemonStatus::Orphaned { port } => {
47                write!(f, "orphaned (no pidfile, port {port} in use)")
48            }
49        }
50    }
51}
52
53/// Manages the oxios background daemon.
54pub struct DaemonManager {
55    pid_file: PathBuf,
56    log_dir: PathBuf,
57    /// Port to consult when both pidfile and lockfile fail to identify a
58    /// live daemon. Set by the CLI (`main.rs`) from the gateway config so
59    /// `status`/`stop` can detect `--foreground` orphans that bypassed the
60    /// pidfile. `None` disables the orphan-detection path.
61    probe_port: Option<u16>,
62}
63
64impl DaemonManager {
65    /// Create a daemon manager from config paths.
66    pub fn new(pid_file: &str, log_dir: &str) -> Self {
67        Self {
68            pid_file: crate::config::expand_home(pid_file),
69            log_dir: crate::config::expand_home(log_dir),
70            probe_port: None,
71        }
72    }
73
74    /// Set the port used by orphan-detection probes. Called by the CLI
75    /// after assembling a manager; without it `status`/`stop` cannot catch
76    /// `--foreground` debug instances that escape the pidfile.
77    pub fn set_probe_port(&mut self, port: u16) {
78        self.probe_port = Some(port);
79    }
80
81    /// Consuming builder for the probe port. Lets CLI sites write
82    /// `DaemonManager::new(..).with_probe_port(port)` without binding to
83    /// `mut`, which is awkward in expression position.
84    pub fn with_probe_port(mut self, port: u16) -> Self {
85        self.probe_port = Some(port);
86        self
87    }
88
89    /// Check daemon status by reading every liveness source we have.
90    ///
91    /// Resolution order — every source that can prove an oxios daemon is
92    /// alive is consulted in turn, because each one alone is unreliable:
93    /// the pidfile can be stale or absent (a direct `--foreground` run),
94    /// the lockfile-based daemon can be invisible to a future daemon's
95    /// own write, and the launchd-managed daemon can be invisible to
96    /// both. We surface the strongest signal.
97    pub fn status(&self) -> DaemonStatus {
98        // 1. Instance-lock holder: cross-binary, cross-launcher truth.
99        if let Some(pid) = self.read_lock_pid().filter(|&p| self.is_alive(p)) {
100            return DaemonStatus::Running { pid };
101        }
102        // 2. Legacy pidfile (kept for backwards compat with daemons that
103        //    weren't updated to take the instance lock).
104        if let Some(pid) = self.read_pid() {
105            if self.is_alive(pid) {
106                return DaemonStatus::Running { pid };
107            }
108            return DaemonStatus::Stale { pid };
109        }
110        // 3. Port probe: catches an orphan. This is the path that would
111        //    have caught the user's debug-instance scenario (--foreground
112        //    without a pidfile). Note: we don't surface a PID because
113        //    there isn't one to trust — port-based attribution is best-
114        //    effort, and `stop` will re-derive it via `lsof` at kill time.
115        if let Some(port) = self.probe_port
116            && self.port_in_use(port)
117        {
118            return DaemonStatus::Orphaned { port };
119        }
120        DaemonStatus::Stopped
121    }
122
123    /// Start the daemon in the background and wait for it to begin accepting
124    /// connections on `port` (RFC-024 SP4: verifies the listener came up so
125    /// a port-bind failure is reported immediately instead of masked by a
126    /// `started` message that never resolves).
127    pub fn start(&self, config_path: &Path, port: u16) -> Result<()> {
128        match self.status() {
129            DaemonStatus::Running { pid } => {
130                anyhow::bail!("oxios is already running (PID {pid})");
131            }
132            DaemonStatus::Stale { .. } => {
133                self.cleanup()?;
134            }
135            DaemonStatus::Stopped | DaemonStatus::Orphaned { .. } => {
136                // Orphaned means "something on the port is in the way" —
137                // fall through to the port-in-use guard, which gives the
138                // user an actionable error.
139            }
140        }
141
142        // Pre-spawn port guard: catches an orphaned oxios process that still
143        // holds the port even though the pidfile is stale or missing (e.g. a
144        // prior `oxios stop` removed the pidfile but the process refused to
145        // die). Without this the spawned daemon's bind fails silently while
146        // the post-spawn readiness probe connects to the *old* listener and
147        // reports success — leaving the broken daemon running undetected.
148        if self.port_in_use(port) {
149            anyhow::bail!(
150                "port {port} is already in use — another oxios instance is \
151                 likely still running. Run `oxios stop`, or find and kill the \
152                 process with `lsof -i :{port}` then retry."
153            );
154        }
155
156        // Ensure log directory exists
157        std::fs::create_dir_all(&self.log_dir).context("failed to create log directory")?;
158
159        let log_file = self.log_dir.join("oxios.log");
160        let exe = std::env::current_exe().context("failed to locate oxios binary")?;
161
162        // Append-mode shared handle for stdout+stderr: O_APPEND writes land
163        // atomically at EOF and never truncate previous runs, so a panic
164        // message (written synchronously to stderr by the panic hook before
165        // abort) survives a restart and stays diagnosable. Two separate
166        // `File::create` handles (O_TRUNC, offset 0) used to clobber each
167        // other and wipe the evidence on every restart.
168        let log_handle = std::fs::OpenOptions::new()
169            .create(true)
170            .append(true)
171            .open(&log_file)
172            .with_context(|| format!("failed to open log file {}", log_file.display()))?;
173        let stderr_handle = log_handle
174            .try_clone()
175            .context("failed to duplicate log handle for stderr")?;
176        let child = std::process::Command::new(&exe)
177            .arg("--foreground")
178            .arg("--config")
179            .arg(config_path)
180            .stdout(log_handle)
181            .stderr(stderr_handle)
182            .spawn()
183            .context("failed to spawn oxios daemon")?;
184
185        let pid = child.id();
186        self.write_pid(pid)?;
187
188        println!("⬡ oxios started (PID {pid})");
189        println!("  Logs: {}", log_file.display());
190        println!("  Dashboard: http://127.0.0.1:{port}");
191
192        // RFC-024 SP4: verify the daemon is actually accepting connections.
193        // A misconfigured bind (TIME_WAIT, port in use) used to be invisible
194        // here — the user saw `started` but `curl` got connection refused.
195        match self.wait_until_listening(port, std::time::Duration::from_secs(15)) {
196            Ok(()) => println!("  Status:   ready (listening on :{port})"),
197            Err(_) => {
198                // The spawned daemon never accepted a connection — almost
199                // always a fatal startup error (web UI unavailable, config
200                // problem) or a bind failure we failed to anticipate.
201                // Surface the log tail so the user sees *why* instead of a
202                // misleading "started", and fail the start.
203                println!("  Status:   FAILED to start (no listener on :{port} within 15s)");
204                let log_path = self.log_dir.join("oxios.log");
205                if let Ok(content) = std::fs::read_to_string(&log_path) {
206                    let lines: Vec<&str> = content.lines().collect();
207                    let start = lines.len().saturating_sub(30);
208                    if start < lines.len() {
209                        println!("  ── recent log (last {} lines) ──", lines.len() - start);
210                        for line in &lines[start..] {
211                            println!("  {line}");
212                        }
213                    }
214                }
215                println!("  Full log: {}", log_path.display());
216                anyhow::bail!(
217                    "daemon failed to start listening on :{port} \
218                     (see the log above and {})",
219                    log_path.display()
220                );
221            }
222        }
223        Ok(())
224    }
225
226    /// Poll `127.0.0.1:port` until a TCP connect succeeds or `timeout` elapses.
227    fn wait_until_listening(&self, port: u16, timeout: std::time::Duration) -> Result<()> {
228        use std::net::ToSocketAddrs;
229        let addr = format!("127.0.0.1:{port}")
230            .to_socket_addrs()?
231            .next()
232            .ok_or_else(|| anyhow::anyhow!("invalid bind address 127.0.0.1:{port}"))?;
233        let start = std::time::Instant::now();
234        let interval = std::time::Duration::from_millis(200);
235        while start.elapsed() < timeout {
236            if std::net::TcpStream::connect_timeout(&addr, interval).is_ok() {
237                return Ok(());
238            }
239            std::thread::sleep(interval);
240        }
241        anyhow::bail!("daemon did not start listening on :{port} within {timeout:?}")
242    }
243
244    /// Whether anything is currently accepting connections on `127.0.0.1:port`.
245    ///
246    /// Pre-spawn guard used by [`start`](Self::start) and the orphan-
247    /// detection path in [`status`](Self::status) to detect a stray daemon
248    /// that escaped the pidfile.
249    fn port_in_use(&self, port: u16) -> bool {
250        use std::net::{TcpStream, ToSocketAddrs};
251        let Some(addr) = format!("127.0.0.1:{port}")
252            .to_socket_addrs()
253            .ok()
254            .and_then(|mut a| a.next())
255        else {
256            return false;
257        };
258        TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200)).is_ok()
259    }
260
261    /// Stop the daemon.
262    ///
263    /// Resolution order — every source that can prove an oxios daemon is
264    /// alive is consulted, because each one alone is unreliable:
265    ///
266    /// 1. **Instance lock** (`<pid_file>.lock`): held by `flock` by every
267    ///    daemon (`cmd_serve`/`--foreground` AND `start()`-spawned). The PID
268    ///    recorded in the file is the authoritative owner.
269    /// 2. **PID file** (`<pid_file>`): the legacy spawn-and-fork channel.
270    ///    Kept for backwards compat with prior daemons that wrote the
271    ///    pidfile but never took the instance lock.
272    /// 3. **Orphan port probe**: when neither (1) nor (2) identifies a
273    ///    daemon, `lsof -ti tcp:PORT -sTCP:LISTEN` is consulted. This path
274    ///    catches a `--foreground` debug instance launched without writing
275    ///    a pidfile — the exact symptom the user reported.
276    /// 4. **System service**: launchd plist or systemd unit. If loaded,
277    ///    unload it for this session; otherwise KeepAlive (macOS) or
278    ///    Restart=on-failure (Linux) will silently undo our kill within
279    ///    milliseconds. We do NOT remove the plist/unit here; that's
280    ///    `uninstall_service`'s job.
281    ///
282    /// User contract: an explicit `daemon install` is the only way to opt
283    /// into the supervisor; in that mode `stop` must reverse it for this
284    /// session, otherwise the daemon respawns within seconds and `stop`
285    /// lies. A user who never ran `daemon install` and never spawned a
286    /// daemon in another shell simply gets "not running".
287    pub fn stop(&self) -> Result<()> {
288        let service_loaded = self.is_service_loaded();
289
290        // 1. Resolve a live PID from any source we can find it.
291        let lock_pid = self.read_lock_pid().filter(|&p| self.is_alive(p));
292        let file_pid = self.read_pid().filter(|&p| self.is_alive(p));
293        let orphan_pid = self.orphan_pid_from_port();
294
295        match (lock_pid, file_pid, orphan_pid) {
296            (Some(pid), _, _) | (None, Some(pid), _) | (None, None, Some(pid)) => {
297                self.kill_pid(pid)?;
298            }
299            (None, None, None) => {
300                if service_loaded {
301                    self.unload_service()?;
302                    std::thread::sleep(std::time::Duration::from_millis(300));
303                } else {
304                    println!("⬡ oxios is not running");
305                    return Ok(());
306                }
307            }
308        }
309
310        // Always unload if registered. Otherwise KeepAlive (macOS) /
311        // Restart=on-failure (Linux) will silently undo our kill.
312        if service_loaded {
313            self.unload_service()?;
314        }
315
316        self.cleanup()?;
317        println!("⬡ oxios stopped");
318        Ok(())
319    }
320
321    /// Send SIGTERM to `pid`, escalating to SIGKILL after `SIGTERM_GRACE`
322    /// if the process hasn't exited. SIGKILL cannot be caught — last resort.
323    ///
324    /// The actual SIGTERM delivery is handled by `cmd_serve`'s supervisor
325    /// (it sees the signal and runs its graceful-drain path). From this
326    /// side we just wait up to `SIGTERM_GRACE` for the process to vanish;
327    /// if it doesn't, SIGKILL finishes the job.
328    fn kill_pid(&self, pid: u32) -> Result<()> {
329        #[cfg(unix)]
330        unsafe {
331            let send_ret = libc::kill(pid as i32, libc::SIGTERM);
332            if send_ret != 0 {
333                let e = std::io::Error::last_os_error();
334                // ESRCH: process died between our liveness check and now.
335                if e.raw_os_error() != Some(libc::ESRCH) {
336                    anyhow::bail!("failed to send SIGTERM to PID {pid}: {e}");
337                }
338            }
339        }
340        #[cfg(not(unix))]
341        {
342            let _ = std::process::Command::new("taskkill")
343                .args(["/PID", &pid.to_string(), "/F"])
344                .output();
345        }
346
347        let poll_start = std::time::Instant::now();
348        let interval = std::time::Duration::from_millis(200);
349        while poll_start.elapsed() < SIGTERM_GRACE {
350            std::thread::sleep(interval);
351            if !self.is_alive(pid) {
352                return Ok(());
353            }
354        }
355
356        // Process ignored SIGTERM within `SIGTERM_GRACE` — escalate.
357        #[cfg(unix)]
358        unsafe {
359            let send_ret = libc::kill(pid as i32, libc::SIGKILL);
360            if send_ret != 0 {
361                let e = std::io::Error::last_os_error();
362                if e.raw_os_error() != Some(libc::ESRCH) {
363                    anyhow::bail!("failed to send SIGKILL to PID {pid}: {e}");
364                }
365            }
366        }
367        for _ in 0..10 {
368            std::thread::sleep(std::time::Duration::from_millis(200));
369            if !self.is_alive(pid) {
370                return Ok(());
371            }
372        }
373        anyhow::bail!(
374            "PID {pid} ignored SIGTERM and SIGKILL; cannot stop. Kill it manually: `kill -9 {pid}`"
375        )
376    }
377
378    /// Restart the daemon.
379    pub fn restart(&self, config_path: &Path, port: u16) -> Result<()> {
380        if matches!(self.status(), DaemonStatus::Running { .. }) {
381            self.stop()?;
382            std::thread::sleep(std::time::Duration::from_millis(500));
383        }
384        self.start(config_path, port)
385    }
386
387    /// Install as a system service (launchd on macOS, systemd on Linux).
388    ///
389    /// After this returns successfully, `stop()` MUST be used to halt the
390    /// daemon in this session — a plain `kill -TERM` will be undone by
391    /// `KeepAlive` (macOS) / `Restart=on-failure` (Linux) within
392    /// milliseconds.
393    pub fn install_service(&self) -> Result<()> {
394        let exe = std::env::current_exe().context("failed to locate oxios binary")?;
395
396        #[cfg(target_os = "macos")]
397        {
398            let plist_dir = dirs::home_dir()
399                .map(|h| h.join("Library/LaunchAgents"))
400                .context("failed to locate LaunchAgents directory")?;
401            std::fs::create_dir_all(&plist_dir)?;
402            let plist_path = plist_dir.join("com.a7garden.oxios.plist");
403
404            let home = dirs::home_dir().context("failed to get HOME")?;
405            let log_path = self.log_dir.join("oxiosd.log");
406
407            let plist = format!(
408                r#"<?xml version="1.0" encoding="UTF-8"?>
409<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
410<plist version="1.0">
411<dict>
412    <key>Label</key>
413    <string>com.a7garden.oxios</string>
414    <key>ProgramArguments</key>
415    <array>
416        <string>{exe}</string>
417        <string>--foreground</string>
418    </array>
419    <key>RunAtLoad</key>
420    <true/>
421    <key>KeepAlive</key>
422    <dict>
423        <key>SuccessfulExit</key>
424        <false/>
425    </dict>
426    <key>ThrottleInterval</key>
427    <integer>10</integer>
428    <key>StandardOutPath</key>
429    <string>{log}</string>
430    <key>StandardErrorPath</key>
431    <string>{log}</string>
432    <key>WorkingDirectory</key>
433    <string>{home}</string>
434</dict>
435</plist>
436"#,
437                exe = escape_xml(&exe.display().to_string()),
438                log = escape_xml(&log_path.display().to_string()),
439                home = escape_xml(&home.display().to_string()),
440            );
441
442            std::fs::write(&plist_path, &plist)?;
443            println!("✓ Installed launchd service");
444            println!("  {}", plist_path.display());
445            println!();
446            println!("  Loaded at boot by macOS launchd (KeepAlive=true).");
447            println!("  Stop with `oxios stop` (it will unload launchd), or:");
448            println!("    launchctl bootout gui/$UID/com.a7garden.oxios");
449            println!("  Disable auto-start on next boot:");
450            println!("    oxios daemon uninstall");
451        }
452
453        #[cfg(target_os = "linux")]
454        {
455            let unit_dir = PathBuf::from("/etc/systemd/system");
456            let unit_path = unit_dir.join("oxiosd.service");
457
458            // Validate the binary path before embedding it in ExecStart. systemd
459            // ExecStart parsing has its own quoting rules; rather than implement
460            // full escaping, refuse paths containing shell/systemd metacharacters.
461            let exe_str = exe.display().to_string();
462            if exe_str.chars().any(|c| {
463                matches!(
464                    c,
465                    '"' | '\''
466                        | '\\'
467                        | '$'
468                        | '`'
469                        | ';'
470                        | '&'
471                        | '|'
472                        | '*'
473                        | '?'
474                        | '<'
475                        | '>'
476                        | '('
477                        | ')'
478                )
479            }) {
480                anyhow::bail!(
481                    "Refusing to install systemd unit: binary path '{exe_str}' contains shell/systemd metacharacters"
482                );
483            }
484
485            let unit = format!(
486                r#"[Unit]
487Description=Oxios Agent Operating System
488After=network.target
489StartLimitBurst=5
490StartLimitIntervalSec=60
491
492[Service]
493Type=simple
494ExecStart={exe} --foreground
495Restart=on-failure
496RestartSec=5s
497
498[Install]
499WantedBy=multi-user.target
500"#,
501                exe = exe_str,
502            );
503
504            // Try to write — may fail without sudo
505            if let Err(e) = std::fs::write(&unit_path, &unit) {
506                anyhow::bail!(
507                    "Failed to write {} — run with sudo: {}",
508                    unit_path.display(),
509                    e
510                );
511            }
512
513            println!("✓ Installed systemd service");
514            println!("  {}", unit_path.display());
515            println!();
516            println!("  Reload:  sudo systemctl daemon-reload");
517            println!("  Start:   sudo systemctl start oxiosd");
518            println!("  Enable:  sudo systemctl enable oxiosd");
519        }
520
521        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
522        {
523            anyhow::bail!("daemon install only supported on macOS and Linux");
524        }
525
526        Ok(())
527    }
528
529    /// Uninstall the system service.
530    ///
531    /// Tears down any live supervisor registration BEFORE removing the
532    /// file. On macOS, removing a loaded plist has no effect on the
533    /// running job — bootout must run first.
534    pub fn uninstall_service(&self) -> Result<()> {
535        let _ = self.unload_service();
536
537        #[cfg(target_os = "macos")]
538        {
539            let plist_path = dirs::home_dir()
540                .map(|h| h.join("Library/LaunchAgents/com.a7garden.oxios.plist"))
541                .context("failed to locate plist")?;
542
543            if plist_path.exists() {
544                std::fs::remove_file(&plist_path)?;
545                println!("✓ Removed launchd service (will not auto-start on next boot)");
546            } else {
547                println!("  Service not installed");
548            }
549        }
550
551        #[cfg(target_os = "linux")]
552        {
553            let unit_path = PathBuf::from("/etc/systemd/system/oxiosd.service");
554            if unit_path.exists() {
555                if let Err(e) = std::fs::remove_file(&unit_path) {
556                    anyhow::bail!(
557                        "Failed to remove {} — run with sudo: {}",
558                        unit_path.display(),
559                        e
560                    );
561                }
562                println!("✓ Removed systemd service");
563            } else {
564                println!("  Service not installed");
565            }
566        }
567
568        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
569        {
570            anyhow::bail!("daemon uninstall only supported on macOS and Linux");
571        }
572
573        Ok(())
574    }
575
576    // ── Internal helpers ─────────────────────────────────────────────
577
578    fn read_pid(&self) -> Option<u32> {
579        let content = std::fs::read_to_string(&self.pid_file).ok()?;
580        content.trim().parse().ok()
581    }
582
583    fn write_pid(&self, pid: u32) -> Result<()> {
584        if let Some(parent) = self.pid_file.parent() {
585            std::fs::create_dir_all(parent)?;
586        }
587        std::fs::write(&self.pid_file, pid.to_string())?;
588        Ok(())
589    }
590
591    pub fn cleanup(&self) -> Result<()> {
592        if self.pid_file.exists() {
593            std::fs::remove_file(&self.pid_file)?;
594        }
595        Ok(())
596    }
597
598    fn is_alive(&self, pid: u32) -> bool {
599        #[cfg(unix)]
600        {
601            // Signal 0 = check if process exists.
602            unsafe { libc::kill(pid as i32, 0) == 0 }
603        }
604        #[cfg(not(unix))]
605        {
606            // On non-Unix, always return false (conservative)
607            let _ = pid;
608            false
609        }
610    }
611
612    // ── Service + lock + orphan introspection ────────────────────────
613
614    /// Path of the `flock`-based single-instance lock file.
615    fn lock_path(&self) -> PathBuf {
616        self.pid_file.with_extension("lock")
617    }
618
619    /// Read the PID stored in the instance-lock file. Diagnostic only —
620    /// the flock is the real truth. Callers MUST verify `is_alive(pid)`.
621    fn read_lock_pid(&self) -> Option<u32> {
622        let path = self.lock_path();
623        std::fs::read_to_string(&path)
624            .ok()
625            .and_then(|s| s.trim().parse::<u32>().ok())
626    }
627
628    /// Probe `self.probe_port` for an orphaned oxios-shaped PID.
629    ///
630    /// Returns `None` when:
631    ///   - no probe port was configured
632    ///   - the port is free
633    ///   - `lsof` is missing or returns nothing parseable
634    ///
635    /// Returns `Some(pid)` only for processes that BOTH listen on
636    /// `tcp:PORT` AND have a `comm` matching `oxios`. Without the
637    /// comm check, port 4200 (the daemon's default) is also Angular's
638    /// dev-server default and other tools'; SIGKILL of a PID surfaced
639    /// from lsof alone could murder an unrelated listener — a contract
640    /// `oxios stop` must not break. `ps -o comm= -p PID` reads the
641    /// kernel-resident process name; a substring match on `oxios`
642    /// covers both release binaries and `target/debug/oxios` builds.
643    fn orphan_pid_from_port(&self) -> Option<u32> {
644        let port = self.probe_port?;
645        if !self.port_in_use(port) {
646            return None;
647        }
648        // `lsof -ti tcp:PORT -sTCP:LISTEN` returns listening PIDs.
649        let out = std::process::Command::new("lsof")
650            .args(["-ti", &format!("tcp:{port}"), "-sTCP:LISTEN"])
651            .output()
652            .ok()?;
653        if !out.status.success() {
654            return None;
655        }
656        let s = String::from_utf8(out.stdout).ok()?;
657        let pid: u32 = s
658            .lines()
659            .find_map(|line| line.split_whitespace().find_map(|t| t.parse().ok()))?;
660
661        // Identity check: refuse to kill anything that doesn't look like
662        // oxios. Cheaper than walking /proc, just calls `ps` for the
663        // short process name. Match is substring — `oxios` covers both
664        // `./target/debug/oxios` (comm truncation preserves the basename
665        // tail) and `oxiosd` if anyone renames it.
666        let comm = std::process::Command::new("ps")
667            .args(["-o", "comm=", "-p", &pid.to_string()])
668            .output()
669            .ok()
670            .and_then(|o| String::from_utf8(o.stdout).ok())
671            .map(|s| s.trim().to_string())
672            .unwrap_or_default();
673        if !comm.contains("oxios") {
674            eprintln!(
675                "  ⚠ port {port} held by PID {pid} ({comm}), not oxios — \
676                 not killing; resolve manually (`lsof -i :{port}`)."
677            );
678            return None;
679        }
680        Some(pid)
681    }
682
683    /// Whether the OS-managed supervisor (launchd LaunchAgent or systemd
684    /// unit) currently considers oxios loaded.
685    fn is_service_loaded(&self) -> bool {
686        #[cfg(target_os = "macos")]
687        {
688            let uid = current_uid_str();
689            if uid.is_empty() {
690                return false;
691            }
692            let target = format!("gui/{uid}/com.a7garden.oxios");
693            std::process::Command::new("launchctl")
694                .args(["print", &target])
695                .output()
696                .map(|o| o.status.success())
697                .unwrap_or(false)
698        }
699        #[cfg(target_os = "linux")]
700        {
701            // `systemctl is-active` exits 0 only for `active` state.
702            if std::process::Command::new("systemctl")
703                .args(["--user", "is-active", "oxiosd.service"])
704                .output()
705                .map(|o| o.status.success())
706                .unwrap_or(false)
707            {
708                return true;
709            }
710            std::process::Command::new("systemctl")
711                .args(["is-active", "oxiosd.service"])
712                .output()
713                .map(|o| o.status.success())
714                .unwrap_or(false)
715        }
716        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
717        {
718            let _ = self;
719            false
720        }
721    }
722
723    /// Unload the OS-level supervisor registration WITHOUT deleting the
724    /// plist/unit file. Reverses KeepAlive for the current session; the
725    /// file remains so `start`/`launchctl load` can re-arm it.
726    fn unload_service(&self) -> Result<()> {
727        #[cfg(target_os = "macos")]
728        {
729            let uid = current_uid_str();
730            if uid.is_empty() {
731                return Ok(());
732            }
733            let target = format!("gui/{uid}/com.a7garden.oxios");
734            // bootout (10.11+) is preferred; fall back to legacy `unload`.
735            let bootout_ok = std::process::Command::new("launchctl")
736                .args(["bootout", &target])
737                .output()
738                .map(|o| o.status.success())
739                .unwrap_or(false);
740            if !bootout_ok {
741                let plist = dirs::home_dir()
742                    .map(|h| h.join("Library/LaunchAgents/com.a7garden.oxios.plist"));
743                if let Some(p) = plist.filter(|p| p.exists()) {
744                    let _ = std::process::Command::new("launchctl")
745                        .args(["unload", &p.to_string_lossy()])
746                        .output();
747                }
748            }
749        }
750        #[cfg(target_os = "linux")]
751        {
752            let _ = std::process::Command::new("systemctl")
753                .args(["--user", "stop", "oxiosd.service"])
754                .output();
755            let _ = std::process::Command::new("systemctl")
756                .args(["stop", "oxiosd.service"])
757                .output();
758        }
759        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
760        {
761            let _ = self;
762        }
763        Ok(())
764    }
765}
766
767#[cfg(target_os = "macos")]
768/// Return the current user's numeric UID as a string.
769///
770/// Used to construct the launchd target path (`gui/$UID/<label>`), which
771/// is the only stable handle we have for a job loaded into the user
772/// domain. Returns an empty string on probe failure so callers can fall
773/// back gracefully.
774fn current_uid_str() -> String {
775    std::process::Command::new("id")
776        .arg("-u")
777        .output()
778        .ok()
779        .and_then(|o| String::from_utf8(o.stdout).ok())
780        .map(|s| s.trim().to_string())
781        .unwrap_or_default()
782}
783
784#[cfg(target_os = "macos")]
785/// Escape a string for safe inclusion in an XML plist text node.
786///
787/// Replaces the five XML-predefined entities (`&`, `<`, `>`, `"`, `'`). Paths
788/// inserted into the launchd plist are usually trusted system paths, but a
789/// HOME or install path containing `<`, `&`, etc. would produce malformed XML
790/// that launchd refuses to load — and would be a defense-in-depth gap.
791fn escape_xml(s: &str) -> String {
792    let mut out = String::with_capacity(s.len());
793    for c in s.chars() {
794        match c {
795            '&' => out.push_str("&amp;"),
796            '<' => out.push_str("&lt;"),
797            '>' => out.push_str("&gt;"),
798            '"' => out.push_str("&quot;"),
799            '\'' => out.push_str("&apos;"),
800            _ => out.push(c),
801        }
802    }
803    out
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    #[test]
811    fn port_in_use_detects_a_live_listener() {
812        // Bind an ephemeral port and confirm port_in_use reports it in use.
813        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
814        let port = listener.local_addr().unwrap().port();
815        let dm = DaemonManager::new("/tmp/oxios-test.pid", "/tmp");
816        assert!(
817            dm.port_in_use(port),
818            "port should be reported in use while a listener is bound"
819        );
820    }
821
822    #[test]
823    fn port_in_use_false_for_unused_port() {
824        let dm = DaemonManager::new("/tmp/oxios-test.pid", "/tmp");
825        // Obtain a port that was just free by binding and dropping, then
826        // confirm port_in_use no longer sees a listener.
827        let port = {
828            let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
829            l.local_addr().unwrap().port()
830        };
831        assert!(
832            !dm.port_in_use(port),
833            "port should be reported free once the listener is dropped"
834        );
835    }
836
837    #[test]
838    fn status_reports_orphaned_when_only_port_responds() {
839        // Bind a listener and verify that with no pidfile/lockfile but a
840        // configured probe port, status classifies the system as
841        // `Orphaned` rather than `Stopped` — the exact case that bit
842        // the user when `--foreground` was launched directly.
843        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
844        let port = listener.local_addr().unwrap().port();
845        let mut dm = DaemonManager::new("/tmp/oxios-orphan-test.pid", "/tmp");
846        dm.set_probe_port(port);
847        // Hold the listener — don't drop until after status() returns.
848        let status = dm.status();
849        drop(listener);
850        match status {
851            DaemonStatus::Orphaned { port: p } => assert_eq!(p, port),
852            other => panic!("expected Orphaned, got {other:?}"),
853        }
854    }
855
856    #[test]
857    fn status_stopped_with_no_signal() {
858        let mut dm = DaemonManager::new("/tmp/oxios-stopped-test.pid", "/tmp");
859        dm.set_probe_port(1); // privileged port, never bound in tests.
860        assert!(matches!(dm.status(), DaemonStatus::Stopped));
861    }
862}