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