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/// Daemon status.
10#[derive(Debug, Clone)]
11pub enum DaemonStatus {
12    /// Daemon is running.
13    Running {
14        /// Process ID.
15        pid: u32,
16    },
17    /// PID file exists but process is dead (stale).
18    Stale {
19        /// Process ID of the dead process.
20        pid: u32,
21    },
22    /// Daemon is not running.
23    Stopped,
24}
25
26impl std::fmt::Display for DaemonStatus {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        match self {
29            DaemonStatus::Running { pid } => write!(f, "running (PID {pid})"),
30            DaemonStatus::Stale { pid } => write!(f, "stale (PID {pid} dead)"),
31            DaemonStatus::Stopped => write!(f, "stopped"),
32        }
33    }
34}
35
36/// Manages the oxios background daemon.
37pub struct DaemonManager {
38    pid_file: PathBuf,
39    log_dir: PathBuf,
40}
41
42impl DaemonManager {
43    /// Create a daemon manager from config paths.
44    pub fn new(pid_file: &str, log_dir: &str) -> Self {
45        Self {
46            pid_file: crate::config::expand_home(pid_file),
47            log_dir: crate::config::expand_home(log_dir),
48        }
49    }
50
51    /// Check daemon status by reading the PID file.
52    pub fn status(&self) -> DaemonStatus {
53        match self.read_pid() {
54            Some(pid) => {
55                if self.is_alive(pid) {
56                    DaemonStatus::Running { pid }
57                } else {
58                    DaemonStatus::Stale { pid }
59                }
60            }
61            None => DaemonStatus::Stopped,
62        }
63    }
64
65    /// Start the daemon in the background and wait for it to begin accepting
66    /// connections on `port` (RFC-024 SP4: verifies the listener came up so
67    /// a port-bind failure is reported immediately instead of masked by a
68    /// `started` message that never resolves).
69    pub fn start(&self, config_path: &Path, port: u16) -> Result<()> {
70        match self.status() {
71            DaemonStatus::Running { pid } => {
72                anyhow::bail!("oxios is already running (PID {pid})");
73            }
74            DaemonStatus::Stale { .. } => {
75                self.cleanup()?;
76            }
77            DaemonStatus::Stopped => {}
78        }
79
80        // Pre-spawn port guard: catches an orphaned oxios process that still
81        // holds the port even though the pidfile is stale or missing (e.g. a
82        // prior `oxios stop` removed the pidfile but the process refused to
83        // die). Without this the spawned daemon's bind fails silently while
84        // the post-spawn readiness probe connects to the *old* listener and
85        // reports success — leaving the broken daemon running undetected.
86        if self.port_in_use(port) {
87            anyhow::bail!(
88                "port {port} is already in use — another oxios instance is \
89                 likely still running. Run `oxios stop`, or find and kill the \
90                 process with `lsof -i :{port}` then retry."
91            );
92        }
93
94        // Ensure log directory exists
95        std::fs::create_dir_all(&self.log_dir).context("failed to create log directory")?;
96
97        let log_file = self.log_dir.join("oxios.log");
98        let exe = std::env::current_exe().context("failed to locate oxios binary")?;
99
100        // Append-mode shared handle for stdout+stderr: O_APPEND writes land
101        // atomically at EOF and never truncate previous runs, so a panic
102        // message (written synchronously to stderr by the panic hook before
103        // abort) survives a restart and stays diagnosable. Two separate
104        // `File::create` handles (O_TRUNC, offset 0) used to clobber each
105        // other and wipe the evidence on every restart.
106        let log_handle = std::fs::OpenOptions::new()
107            .create(true)
108            .append(true)
109            .open(&log_file)
110            .with_context(|| format!("failed to open log file {}", log_file.display()))?;
111        let stderr_handle = log_handle
112            .try_clone()
113            .context("failed to duplicate log handle for stderr")?;
114        let child = std::process::Command::new(&exe)
115            .arg("--foreground")
116            .arg("--config")
117            .arg(config_path)
118            .stdout(log_handle)
119            .stderr(stderr_handle)
120            .spawn()
121            .context("failed to spawn oxios daemon")?;
122
123        let pid = child.id();
124        self.write_pid(pid)?;
125
126        println!("⬡ oxios started (PID {pid})");
127        println!("  Logs: {}", log_file.display());
128        println!("  Dashboard: http://127.0.0.1:{port}");
129
130        // RFC-024 SP4: verify the daemon is actually accepting connections.
131        // A misconfigured bind (TIME_WAIT, port in use) used to be invisible
132        // here — the user saw `started` but `curl` got connection refused.
133        match self.wait_until_listening(port, std::time::Duration::from_secs(15)) {
134            Ok(()) => println!("  Status:   ready (listening on :{port})"),
135            Err(_) => {
136                // The spawned daemon never accepted a connection — almost
137                // always a fatal startup error (web UI unavailable, config
138                // problem) or a bind failure we failed to anticipate.
139                // Surface the log tail so the user sees *why* instead of a
140                // misleading "started", and fail the start.
141                println!("  Status:   FAILED to start (no listener on :{port} within 15s)");
142                let log_path = self.log_dir.join("oxios.log");
143                if let Ok(content) = std::fs::read_to_string(&log_path) {
144                    let lines: Vec<&str> = content.lines().collect();
145                    let start = lines.len().saturating_sub(30);
146                    if start < lines.len() {
147                        println!("  ── recent log (last {} lines) ──", lines.len() - start);
148                        for line in &lines[start..] {
149                            println!("  {line}");
150                        }
151                    }
152                }
153                println!("  Full log: {}", log_path.display());
154                anyhow::bail!(
155                    "daemon failed to start listening on :{port} \
156                     (see the log above and {})",
157                    log_path.display()
158                );
159            }
160        }
161        Ok(())
162    }
163
164    /// Poll `127.0.0.1:port` until a TCP connect succeeds or `timeout` elapses.
165    fn wait_until_listening(&self, port: u16, timeout: std::time::Duration) -> Result<()> {
166        use std::net::ToSocketAddrs;
167        let addr = format!("127.0.0.1:{port}")
168            .to_socket_addrs()?
169            .next()
170            .ok_or_else(|| anyhow::anyhow!("invalid bind address 127.0.0.1:{port}"))?;
171        let start = std::time::Instant::now();
172        let interval = std::time::Duration::from_millis(200);
173        while start.elapsed() < timeout {
174            if std::net::TcpStream::connect_timeout(&addr, interval).is_ok() {
175                return Ok(());
176            }
177            std::thread::sleep(interval);
178        }
179        anyhow::bail!("daemon did not start listening on :{port} within {timeout:?}")
180    }
181
182    /// Whether anything is currently accepting connections on `127.0.0.1:port`.
183    ///
184    /// Pre-spawn guard used by [`start`](Self::start) to detect an orphaned
185    /// daemon that escaped the pidfile — the pidfile was removed but the
186    /// process kept the port.
187    fn port_in_use(&self, port: u16) -> bool {
188        use std::net::{TcpStream, ToSocketAddrs};
189        let Some(addr) = format!("127.0.0.1:{port}")
190            .to_socket_addrs()
191            .ok()
192            .and_then(|mut a| a.next())
193        else {
194            return false;
195        };
196        TcpStream::connect_timeout(&addr, std::time::Duration::from_millis(200)).is_ok()
197    }
198
199    /// Stop the daemon by sending SIGTERM.
200    pub fn stop(&self) -> Result<()> {
201        match self.status() {
202            DaemonStatus::Running { pid } => {
203                #[cfg(unix)]
204                {
205                    let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
206                    if ret != 0 {
207                        anyhow::bail!("failed to send SIGTERM to PID {pid}");
208                    }
209                }
210                #[cfg(not(unix))]
211                {
212                    // On non-Unix, just kill the process
213                    let _ = std::process::Command::new("taskkill")
214                        .args(["/PID", &pid.to_string(), "/F"])
215                        .output();
216                }
217
218                // Wait briefly for process to die
219                for _ in 0..10 {
220                    std::thread::sleep(std::time::Duration::from_millis(200));
221                    if !self.is_alive(pid) {
222                        break;
223                    }
224                }
225
226                self.cleanup()?;
227                println!("⬡ oxios stopped");
228                Ok(())
229            }
230            DaemonStatus::Stale { .. } => {
231                self.cleanup()?;
232                println!("⬡ cleaned up stale PID file");
233                Ok(())
234            }
235            DaemonStatus::Stopped => {
236                println!("⬡ oxios is not running");
237                Ok(())
238            }
239        }
240    }
241
242    /// Restart the daemon.
243    pub fn restart(&self, config_path: &Path, port: u16) -> Result<()> {
244        if matches!(self.status(), DaemonStatus::Running { .. }) {
245            self.stop()?;
246            std::thread::sleep(std::time::Duration::from_millis(500));
247        }
248        self.start(config_path, port)
249    }
250
251    /// Install as a system service (launchd on macOS, systemd on Linux).
252    pub fn install_service(&self) -> Result<()> {
253        let exe = std::env::current_exe().context("failed to locate oxios binary")?;
254
255        #[cfg(target_os = "macos")]
256        {
257            let plist_dir = dirs::home_dir()
258                .map(|h| h.join("Library/LaunchAgents"))
259                .context("failed to locate LaunchAgents directory")?;
260            std::fs::create_dir_all(&plist_dir)?;
261            let plist_path = plist_dir.join("com.a7garden.oxios.plist");
262
263            let home = dirs::home_dir().context("failed to get HOME")?;
264            let log_path = self.log_dir.join("oxiosd.log");
265
266            let plist = format!(
267                r#"<?xml version="1.0" encoding="UTF-8"?>
268<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
269<plist version="1.0">
270<dict>
271    <key>Label</key>
272    <string>com.a7garden.oxios</string>
273    <key>ProgramArguments</key>
274    <array>
275        <string>{exe}</string>
276        <string>--foreground</string>
277    </array>
278    <key>RunAtLoad</key>
279    <true/>
280    <key>KeepAlive</key>
281    <true/>
282    <key>StandardOutPath</key>
283    <string>{log}</string>
284    <key>StandardErrorPath</key>
285    <string>{log}</string>
286    <key>WorkingDirectory</key>
287    <string>{home}</string>
288</dict>
289</plist>
290"#,
291                exe = escape_xml(&exe.display().to_string()),
292                log = escape_xml(&log_path.display().to_string()),
293                home = escape_xml(&home.display().to_string()),
294            );
295
296            std::fs::write(&plist_path, &plist)?;
297            println!("✓ Installed launchd service");
298            println!("  {}", plist_path.display());
299            println!();
300            println!("  Start with:   launchctl load {}", plist_path.display());
301            println!("  Stop with:    launchctl unload {}", plist_path.display());
302            println!("  Or simply:    oxios start / oxios stop");
303        }
304
305        #[cfg(target_os = "linux")]
306        {
307            let unit_dir = PathBuf::from("/etc/systemd/system");
308            let unit_path = unit_dir.join("oxiosd.service");
309
310            // Validate the binary path before embedding it in ExecStart. systemd
311            // ExecStart parsing has its own quoting rules; rather than implement
312            // full escaping, refuse paths containing shell/systemd metacharacters.
313            let exe_str = exe.display().to_string();
314            if exe_str.chars().any(|c| {
315                matches!(
316                    c,
317                    '"' | '\''
318                        | '\\'
319                        | '$'
320                        | '`'
321                        | ';'
322                        | '&'
323                        | '|'
324                        | '*'
325                        | '?'
326                        | '<'
327                        | '>'
328                        | '('
329                        | ')'
330                )
331            }) {
332                anyhow::bail!(
333                    "Refusing to install systemd unit: binary path '{exe_str}' contains shell/systemd metacharacters"
334                );
335            }
336
337            let unit = format!(
338                r#"[Unit]
339Description=Oxios Agent Operating System
340After=network.target
341
342[Service]
343Type=simple
344ExecStart={exe} --foreground
345Restart=on-failure
346RestartSec=5s
347
348[Install]
349WantedBy=multi-user.target
350"#,
351                exe = exe_str,
352            );
353
354            // Try to write — may fail without sudo
355            if let Err(e) = std::fs::write(&unit_path, &unit) {
356                anyhow::bail!(
357                    "Failed to write {} — run with sudo: {}",
358                    unit_path.display(),
359                    e
360                );
361            }
362
363            println!("✓ Installed systemd service");
364            println!("  {}", unit_path.display());
365            println!();
366            println!("  Reload:  sudo systemctl daemon-reload");
367            println!("  Start:   sudo systemctl start oxiosd");
368            println!("  Enable:  sudo systemctl enable oxiosd");
369        }
370
371        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
372        {
373            anyhow::bail!("daemon install only supported on macOS and Linux");
374        }
375
376        Ok(())
377    }
378
379    /// Uninstall the system service.
380    pub fn uninstall_service(&self) -> Result<()> {
381        #[cfg(target_os = "macos")]
382        {
383            let plist_path = dirs::home_dir()
384                .map(|h| h.join("Library/LaunchAgents/com.a7garden.oxios.plist"))
385                .context("failed to locate plist")?;
386
387            if plist_path.exists() {
388                std::fs::remove_file(&plist_path)?;
389                println!("✓ Removed launchd service");
390            } else {
391                println!("  Service not installed");
392            }
393        }
394
395        #[cfg(target_os = "linux")]
396        {
397            let unit_path = PathBuf::from("/etc/systemd/system/oxiosd.service");
398            if unit_path.exists() {
399                if let Err(e) = std::fs::remove_file(&unit_path) {
400                    anyhow::bail!(
401                        "Failed to remove {} — run with sudo: {}",
402                        unit_path.display(),
403                        e
404                    );
405                }
406                println!("✓ Removed systemd service");
407            } else {
408                println!("  Service not installed");
409            }
410        }
411
412        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
413        {
414            anyhow::bail!("daemon uninstall only supported on macOS and Linux");
415        }
416
417        Ok(())
418    }
419
420    // ── Internal helpers ──
421
422    fn read_pid(&self) -> Option<u32> {
423        let content = std::fs::read_to_string(&self.pid_file).ok()?;
424        content.trim().parse().ok()
425    }
426
427    fn write_pid(&self, pid: u32) -> Result<()> {
428        if let Some(parent) = self.pid_file.parent() {
429            std::fs::create_dir_all(parent)?;
430        }
431        std::fs::write(&self.pid_file, pid.to_string())?;
432        Ok(())
433    }
434
435    fn cleanup(&self) -> Result<()> {
436        if self.pid_file.exists() {
437            std::fs::remove_file(&self.pid_file)?;
438        }
439        Ok(())
440    }
441
442    fn is_alive(&self, pid: u32) -> bool {
443        #[cfg(unix)]
444        {
445            // Signal 0 = check if process exists
446            unsafe { libc::kill(pid as i32, 0) == 0 }
447        }
448        #[cfg(not(unix))]
449        {
450            // On non-Unix, always return false (conservative)
451            let _ = pid;
452            false
453        }
454    }
455}
456
457#[cfg(target_os = "macos")]
458/// Escape a string for safe inclusion in an XML plist text node.
459///
460/// Replaces the five XML-predefined entities (`&`, `<`, `>`, `"`, `'`). Paths
461/// inserted into the launchd plist are usually trusted system paths, but a
462/// HOME or install path containing `<`, `&`, etc. would produce malformed XML
463/// that launchd refuses to load — and would be a defense-in-depth gap.
464fn escape_xml(s: &str) -> String {
465    let mut out = String::with_capacity(s.len());
466    for c in s.chars() {
467        match c {
468            '&' => out.push_str("&amp;"),
469            '<' => out.push_str("&lt;"),
470            '>' => out.push_str("&gt;"),
471            '"' => out.push_str("&quot;"),
472            '\'' => out.push_str("&apos;"),
473            _ => out.push(c),
474        }
475    }
476    out
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn port_in_use_detects_a_live_listener() {
485        // Bind an ephemeral port and confirm port_in_use reports it in use.
486        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
487        let port = listener.local_addr().unwrap().port();
488        let dm = DaemonManager::new("/tmp/oxios-test.pid", "/tmp");
489        assert!(
490            dm.port_in_use(port),
491            "port should be reported in use while a listener is bound"
492        );
493    }
494
495    #[test]
496    fn port_in_use_false_for_unused_port() {
497        let dm = DaemonManager::new("/tmp/oxios-test.pid", "/tmp");
498        // Obtain a port that was just free by binding and dropping, then
499        // confirm port_in_use no longer sees a listener.
500        let port = {
501            let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
502            l.local_addr().unwrap().port()
503        };
504        assert!(
505            !dm.port_in_use(port),
506            "port should be reported free once the listener is dropped"
507        );
508    }
509}