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 {} dead)", pid),
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.
66    pub fn start(&self, config_path: &Path) -> Result<()> {
67        match self.status() {
68            DaemonStatus::Running { pid } => {
69                anyhow::bail!("oxios is already running (PID {})", pid);
70            }
71            DaemonStatus::Stale { .. } => {
72                self.cleanup()?;
73            }
74            DaemonStatus::Stopped => {}
75        }
76
77        // Ensure log directory exists
78        std::fs::create_dir_all(&self.log_dir).context("failed to create log directory")?;
79
80        let log_file = self.log_dir.join("oxios.log");
81        let exe = std::env::current_exe().context("failed to locate oxios binary")?;
82
83        let child = std::process::Command::new(&exe)
84            .arg("--foreground")
85            .arg("--config")
86            .arg(config_path)
87            .stdout(std::fs::File::create(&log_file)?)
88            .stderr(std::fs::File::create(&log_file)?)
89            .spawn()
90            .context("failed to spawn oxios daemon")?;
91
92        let pid = child.id();
93        self.write_pid(pid)?;
94
95        println!("⬡ oxios started (PID {})", pid);
96        println!("  Logs: {}", log_file.display());
97        println!("  Dashboard: http://127.0.0.1:4200");
98        Ok(())
99    }
100
101    /// Stop the daemon by sending SIGTERM.
102    pub fn stop(&self) -> Result<()> {
103        match self.status() {
104            DaemonStatus::Running { pid } => {
105                #[cfg(unix)]
106                {
107                    let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
108                    if ret != 0 {
109                        anyhow::bail!("failed to send SIGTERM to PID {}", pid);
110                    }
111                }
112                #[cfg(not(unix))]
113                {
114                    // On non-Unix, just kill the process
115                    let _ = std::process::Command::new("taskkill")
116                        .args(["/PID", &pid.to_string(), "/F"])
117                        .output();
118                }
119
120                // Wait briefly for process to die
121                for _ in 0..10 {
122                    std::thread::sleep(std::time::Duration::from_millis(200));
123                    if !self.is_alive(pid) {
124                        break;
125                    }
126                }
127
128                self.cleanup()?;
129                println!("⬡ oxios stopped");
130                Ok(())
131            }
132            DaemonStatus::Stale { .. } => {
133                self.cleanup()?;
134                println!("⬡ cleaned up stale PID file");
135                Ok(())
136            }
137            DaemonStatus::Stopped => {
138                println!("⬡ oxios is not running");
139                Ok(())
140            }
141        }
142    }
143
144    /// Restart the daemon.
145    pub fn restart(&self, config_path: &Path) -> Result<()> {
146        if matches!(self.status(), DaemonStatus::Running { .. }) {
147            self.stop()?;
148            std::thread::sleep(std::time::Duration::from_millis(500));
149        }
150        self.start(config_path)
151    }
152
153    /// Install as a system service (launchd on macOS, systemd on Linux).
154    pub fn install_service(&self) -> Result<()> {
155        let exe = std::env::current_exe().context("failed to locate oxios binary")?;
156
157        #[cfg(target_os = "macos")]
158        {
159            let plist_dir = dirs::home_dir()
160                .map(|h| h.join("Library/LaunchAgents"))
161                .context("failed to locate LaunchAgents directory")?;
162            std::fs::create_dir_all(&plist_dir)?;
163            let plist_path = plist_dir.join("com.a7garden.oxios.plist");
164
165            let home = dirs::home_dir().context("failed to get HOME")?;
166            let log_path = self.log_dir.join("oxiosd.log");
167
168            let plist = format!(
169                r#"<?xml version="1.0" encoding="UTF-8"?>
170<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
171<plist version="1.0">
172<dict>
173    <key>Label</key>
174    <string>com.a7garden.oxios</string>
175    <key>ProgramArguments</key>
176    <array>
177        <string>{exe}</string>
178        <string>--foreground</string>
179    </array>
180    <key>RunAtLoad</key>
181    <true/>
182    <key>KeepAlive</key>
183    <true/>
184    <key>StandardOutPath</key>
185    <string>{log}</string>
186    <key>StandardErrorPath</key>
187    <string>{log}</string>
188    <key>WorkingDirectory</key>
189    <string>{home}</string>
190</dict>
191</plist>
192"#,
193                exe = exe.display(),
194                log = log_path.display(),
195                home = home.display(),
196            );
197
198            std::fs::write(&plist_path, &plist)?;
199            println!("✓ Installed launchd service");
200            println!("  {}", plist_path.display());
201            println!();
202            println!("  Start with:   launchctl load {}", plist_path.display());
203            println!("  Stop with:    launchctl unload {}", plist_path.display());
204            println!("  Or simply:    oxios start / oxios stop");
205        }
206
207        #[cfg(target_os = "linux")]
208        {
209            let unit_dir = PathBuf::from("/etc/systemd/system");
210            let unit_path = unit_dir.join("oxiosd.service");
211
212            let unit = format!(
213                r#"[Unit]
214Description=Oxios Agent Operating System
215After=network.target
216
217[Service]
218Type=simple
219ExecStart={exe} --foreground
220Restart=on-failure
221RestartSec=5s
222
223[Install]
224WantedBy=multi-user.target
225"#,
226                exe = exe.display(),
227            );
228
229            // Try to write — may fail without sudo
230            if let Err(e) = std::fs::write(&unit_path, &unit) {
231                anyhow::bail!(
232                    "Failed to write {} — run with sudo: {}",
233                    unit_path.display(),
234                    e
235                );
236            }
237
238            println!("✓ Installed systemd service");
239            println!("  {}", unit_path.display());
240            println!();
241            println!("  Reload:  sudo systemctl daemon-reload");
242            println!("  Start:   sudo systemctl start oxiosd");
243            println!("  Enable:  sudo systemctl enable oxiosd");
244        }
245
246        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
247        {
248            anyhow::bail!("daemon install only supported on macOS and Linux");
249        }
250
251        Ok(())
252    }
253
254    /// Uninstall the system service.
255    pub fn uninstall_service(&self) -> Result<()> {
256        #[cfg(target_os = "macos")]
257        {
258            let plist_path = dirs::home_dir()
259                .map(|h| h.join("Library/LaunchAgents/com.a7garden.oxios.plist"))
260                .context("failed to locate plist")?;
261
262            if plist_path.exists() {
263                std::fs::remove_file(&plist_path)?;
264                println!("✓ Removed launchd service");
265            } else {
266                println!("  Service not installed");
267            }
268        }
269
270        #[cfg(target_os = "linux")]
271        {
272            let unit_path = PathBuf::from("/etc/systemd/system/oxiosd.service");
273            if unit_path.exists() {
274                if let Err(e) = std::fs::remove_file(&unit_path) {
275                    anyhow::bail!(
276                        "Failed to remove {} — run with sudo: {}",
277                        unit_path.display(),
278                        e
279                    );
280                }
281                println!("✓ Removed systemd service");
282            } else {
283                println!("  Service not installed");
284            }
285        }
286
287        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
288        {
289            anyhow::bail!("daemon uninstall only supported on macOS and Linux");
290        }
291
292        Ok(())
293    }
294
295    // ── Internal helpers ──
296
297    fn read_pid(&self) -> Option<u32> {
298        let content = std::fs::read_to_string(&self.pid_file).ok()?;
299        content.trim().parse().ok()
300    }
301
302    fn write_pid(&self, pid: u32) -> Result<()> {
303        if let Some(parent) = self.pid_file.parent() {
304            std::fs::create_dir_all(parent)?;
305        }
306        std::fs::write(&self.pid_file, pid.to_string())?;
307        Ok(())
308    }
309
310    fn cleanup(&self) -> Result<()> {
311        if self.pid_file.exists() {
312            std::fs::remove_file(&self.pid_file)?;
313        }
314        Ok(())
315    }
316
317    fn is_alive(&self, pid: u32) -> bool {
318        #[cfg(unix)]
319        {
320            // Signal 0 = check if process exists
321            unsafe { libc::kill(pid as i32, 0) == 0 }
322        }
323        #[cfg(not(unix))]
324        {
325            // On non-Unix, always return false (conservative)
326            let _ = pid;
327            false
328        }
329    }
330}