1use anyhow::{Context, Result};
7use std::path::{Path, PathBuf};
8
9const SIGTERM_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
14
15#[derive(Debug, Clone)]
17pub enum DaemonStatus {
18 Running {
20 pid: u32,
22 },
23 Stale {
25 pid: u32,
27 },
28 Stopped,
30 Orphaned {
35 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
53pub struct DaemonManager {
55 pid_file: PathBuf,
56 log_dir: PathBuf,
57 probe_port: Option<u16>,
62}
63
64impl DaemonManager {
65 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 pub fn set_probe_port(&mut self, port: u16) {
78 self.probe_port = Some(port);
79 }
80
81 pub fn with_probe_port(mut self, port: u16) -> Self {
85 self.probe_port = Some(port);
86 self
87 }
88
89 pub fn status(&self) -> DaemonStatus {
98 if let Some(pid) = self.read_lock_pid().filter(|&p| self.is_alive(p)) {
100 return DaemonStatus::Running { pid };
101 }
102 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 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 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 }
140 }
141
142 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 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 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 match self.wait_until_listening(port, std::time::Duration::from_secs(15)) {
196 Ok(()) => println!(" Status: ready (listening on :{port})"),
197 Err(_) => {
198 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 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 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 pub fn stop(&self) -> Result<()> {
288 let service_loaded = self.is_service_loaded();
289
290 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 if service_loaded {
313 self.unload_service()?;
314 }
315
316 self.cleanup()?;
317 println!("⬡ oxios stopped");
318 Ok(())
319 }
320
321 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 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 #[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 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 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 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 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 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 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 unsafe { libc::kill(pid as i32, 0) == 0 }
603 }
604 #[cfg(not(unix))]
605 {
606 let _ = pid;
608 false
609 }
610 }
611
612 fn lock_path(&self) -> PathBuf {
616 self.pid_file.with_extension("lock")
617 }
618
619 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 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 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 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 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 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 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 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")]
768fn 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")]
785fn 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("&"),
796 '<' => out.push_str("<"),
797 '>' => out.push_str(">"),
798 '"' => out.push_str("""),
799 '\'' => out.push_str("'"),
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 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 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 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 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); assert!(matches!(dm.status(), DaemonStatus::Stopped));
861 }
862}