Skip to main content

rustyclaw_core/
daemon.rs

1//! Gateway daemon management — PID file, spawn, stop, status.
2//!
3//! The `gateway start` command spawns the `rustyclaw-gateway` binary as a
4//! detached background process, writes a PID file to
5//! `<settings_dir>/gateway.pid`, and stores the log path alongside it.
6//!
7//! `gateway stop` reads that PID file and terminates the process.
8//! `gateway restart` does stop-then-start.
9//! `gateway status` checks if the recorded PID is still alive.
10//!
11//! All process management uses `sysinfo` and `which` for cross-platform
12//! support (macOS, Linux, Windows) with no `cfg(unix)` gates.
13
14use std::fs;
15use std::path::{Path, PathBuf};
16use std::process::Command;
17
18use anyhow::{Context, Result};
19use sysinfo::{Pid, Signal, System};
20
21// ── PID file helpers ────────────────────────────────────────────────────────
22
23/// Returns the path to the PID file: `<settings_dir>/gateway.pid`.
24pub fn pid_path(settings_dir: &Path) -> PathBuf {
25    settings_dir.join("gateway.pid")
26}
27
28/// Returns the path to the gateway log file: `<settings_dir>/logs/gateway.log`.
29pub fn log_path(settings_dir: &Path) -> PathBuf {
30    settings_dir.join("logs").join("gateway.log")
31}
32
33/// Write a PID to the PID file.
34pub fn write_pid(settings_dir: &Path, pid: u32) -> Result<()> {
35    let path = pid_path(settings_dir);
36    if let Some(parent) = path.parent() {
37        fs::create_dir_all(parent)?;
38    }
39    fs::write(&path, pid.to_string())
40        .with_context(|| format!("Failed to write PID file {}", path.display()))
41}
42
43/// Read the stored PID, if the file exists and is valid.
44pub fn read_pid(settings_dir: &Path) -> Option<u32> {
45    let path = pid_path(settings_dir);
46    fs::read_to_string(&path)
47        .ok()
48        .and_then(|s| s.trim().parse().ok())
49}
50
51/// Remove the PID file.
52pub fn remove_pid(settings_dir: &Path) {
53    let path = pid_path(settings_dir);
54    let _ = fs::remove_file(&path);
55}
56
57/// Check whether a process with the given PID is alive.
58pub fn is_process_alive(pid: u32) -> bool {
59    let mut sys = System::new();
60    sys.refresh_processes(
61        sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]),
62        true,
63    );
64    sys.process(Pid::from_u32(pid)).is_some()
65}
66
67// ── High-level daemon operations ────────────────────────────────────────────
68
69/// Status of the gateway daemon.
70#[derive(Debug, Clone)]
71pub enum DaemonStatus {
72    /// Running with the given PID.
73    Running { pid: u32 },
74    /// PID file exists but the process is dead.
75    Stale { pid: u32 },
76    /// No PID file — not running.
77    Stopped,
78}
79
80/// Check the current daemon status.
81pub fn status(settings_dir: &Path) -> DaemonStatus {
82    match read_pid(settings_dir) {
83        Some(pid) => {
84            if is_process_alive(pid) {
85                DaemonStatus::Running { pid }
86            } else {
87                DaemonStatus::Stale { pid }
88            }
89        }
90        None => DaemonStatus::Stopped,
91    }
92}
93
94/// Spawn the `rustyclaw-gateway` binary as a background process.
95///
96/// The gateway binary is expected to be on `$PATH` or next to the current
97/// executable.  We pass `--port`, `--bind`, and any config/settings-dir
98/// flags, then redirect stdout/stderr to the log file.
99pub fn start(
100    settings_dir: &Path,
101    port: u16,
102    bind: &str,
103    extra_args: &[String],
104    model_api_key: Option<&str>,
105    vault_password: Option<&str>,
106    tls_cert: Option<&Path>,
107    tls_key: Option<&Path>,
108) -> Result<u32> {
109    // If already running, bail.
110    if let DaemonStatus::Running { pid } = status(settings_dir) {
111        anyhow::bail!("Gateway is already running (PID {})", pid);
112    }
113
114    // Clean up stale PID file.
115    remove_pid(settings_dir);
116
117    // Resolve gateway binary path — look next to our own binary first.
118    let gateway_bin = resolve_gateway_binary()?;
119
120    // Ensure log directory exists.
121    let log = log_path(settings_dir);
122    if let Some(parent) = log.parent() {
123        fs::create_dir_all(parent)?;
124    }
125
126    let log_file = fs::File::create(&log)
127        .with_context(|| format!("Failed to create gateway log at {}", log.display()))?;
128    let log_stderr = log_file
129        .try_clone()
130        .context("Failed to clone log file handle")?;
131
132    let mut cmd = Command::new(&gateway_bin);
133    cmd.arg("run")
134        .arg("--port")
135        .arg(port.to_string())
136        .arg("--bind")
137        .arg(bind)
138        .arg("--settings-dir")
139        .arg(settings_dir)
140        .stdout(log_file)
141        .stderr(log_stderr);
142
143    // Pass the model API key via an environment variable so the gateway
144    // never needs direct access to the secrets vault.
145    if let Some(key) = model_api_key {
146        cmd.env("RUSTYCLAW_MODEL_API_KEY", key);
147    }
148
149    // Pass the vault password so the gateway can unlock the secrets vault.
150    if let Some(pw) = vault_password {
151        cmd.env("RUSTYCLAW_VAULT_PASSWORD", pw);
152    }
153
154    // Pass TLS certificate and key paths for WSS support.
155    if let Some(cert) = tls_cert {
156        cmd.arg("--tls-cert").arg(cert);
157    }
158    if let Some(key) = tls_key {
159        cmd.arg("--tls-key").arg(key);
160    }
161
162    for a in extra_args {
163        cmd.arg(a);
164    }
165
166    // Platform-specific detach so the child survives our exit.
167    detach_child(&mut cmd);
168
169    let child = cmd
170        .spawn()
171        .with_context(|| format!("Failed to spawn {}", gateway_bin.display()))?;
172
173    let pid = child.id();
174    write_pid(settings_dir, pid)?;
175
176    Ok(pid)
177}
178
179/// Stop a running gateway by terminating the process.
180pub fn stop(settings_dir: &Path) -> Result<StopResult> {
181    match status(settings_dir) {
182        DaemonStatus::Running { pid } => {
183            kill_process(pid)?;
184            // Wait briefly for the process to exit.
185            for _ in 0..20 {
186                std::thread::sleep(std::time::Duration::from_millis(100));
187                if !is_process_alive(pid) {
188                    remove_pid(settings_dir);
189                    return Ok(StopResult::Stopped { pid });
190                }
191            }
192            // Process still alive after 2s — it may be shutting down slowly.
193            // Remove PID file anyway; the OS will finish cleanup.
194            remove_pid(settings_dir);
195            Ok(StopResult::Stopped { pid })
196        }
197        DaemonStatus::Stale { pid } => {
198            remove_pid(settings_dir);
199            Ok(StopResult::WasStale { pid })
200        }
201        DaemonStatus::Stopped => Ok(StopResult::WasNotRunning),
202    }
203}
204
205#[derive(Debug)]
206pub enum StopResult {
207    Stopped { pid: u32 },
208    WasStale { pid: u32 },
209    WasNotRunning,
210}
211
212/// Terminate a process by PID using `sysinfo`.
213/// Sends SIGTERM on Unix, TerminateProcess on Windows.
214fn kill_process(pid: u32) -> Result<()> {
215    let sysinfo_pid = Pid::from_u32(pid);
216    let mut sys = System::new();
217    sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[sysinfo_pid]), true);
218    let process = sys
219        .process(sysinfo_pid)
220        .context(format!("Process {} not found", pid))?;
221
222    if !process.kill_with(Signal::Term).unwrap_or(false) {
223        // Fallback: hard kill if graceful signal unsupported (e.g. Windows
224        // doesn't have SIGTERM — kill_with(Term) returns false).
225        process.kill();
226    }
227    Ok(())
228}
229
230/// Configure a `Command` to detach the child from the parent session.
231#[cfg(unix)]
232fn detach_child(cmd: &mut Command) {
233    use std::os::unix::process::CommandExt;
234    // Create a new process group so the child isn't killed when the
235    // parent's terminal closes.
236    cmd.process_group(0);
237}
238
239#[cfg(windows)]
240fn detach_child(cmd: &mut Command) {
241    use std::os::windows::process::CommandExt;
242    // CREATE_NEW_PROCESS_GROUP (0x200) | DETACHED_PROCESS (0x08)
243    cmd.creation_flags(0x0000_0208);
244}
245
246#[cfg(not(any(unix, windows)))]
247fn detach_child(_cmd: &mut Command) {
248    // No detach on unknown platforms — the child may be tied to our terminal.
249}
250
251/// Find the gateway binary.  Checks:
252/// 1. Next to the current executable (same directory).
253/// 2. On `$PATH` via the `which` crate (cross-platform).
254fn resolve_gateway_binary() -> Result<PathBuf> {
255    let name = if cfg!(windows) {
256        "rustyclaw-gateway.exe"
257    } else {
258        "rustyclaw-gateway"
259    };
260
261    // 1. Same directory as the running binary.
262    if let Ok(current_exe) = std::env::current_exe() {
263        if let Some(dir) = current_exe.parent() {
264            let candidate = dir.join(name);
265            if candidate.is_file() {
266                return Ok(candidate);
267            }
268        }
269    }
270
271    // 2. On $PATH.
272    if let Ok(path) = which::which(name) {
273        return Ok(path);
274    }
275
276    anyhow::bail!(
277        "Could not find the `rustyclaw-gateway` binary.\n\
278         Make sure it is installed or built (`cargo build`) and on your PATH."
279    )
280}