1use anyhow::{Context, Result};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone)]
11pub enum DaemonStatus {
12 Running {
14 pid: u32,
16 },
17 Stale {
19 pid: u32,
21 },
22 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
36pub struct DaemonManager {
38 pid_file: PathBuf,
39 log_dir: PathBuf,
40}
41
42impl DaemonManager {
43 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 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 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 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 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 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 match self.wait_until_listening(port, std::time::Duration::from_secs(15)) {
134 Ok(()) => println!(" Status: ready (listening on :{port})"),
135 Err(_) => {
136 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 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 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 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 let _ = std::process::Command::new("taskkill")
214 .args(["/PID", &pid.to_string(), "/F"])
215 .output();
216 }
217
218 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 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 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 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 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 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 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 unsafe { libc::kill(pid as i32, 0) == 0 }
447 }
448 #[cfg(not(unix))]
449 {
450 let _ = pid;
452 false
453 }
454 }
455}
456
457#[cfg(target_os = "macos")]
458fn 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("&"),
469 '<' => out.push_str("<"),
470 '>' => out.push_str(">"),
471 '"' => out.push_str("""),
472 '\'' => out.push_str("'"),
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 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 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}