Skip to main content

ralph/commands/daemon/
mod.rs

1//! Daemon command implementation for background service management.
2//!
3//! Responsibilities:
4//! - Re-export daemon subcommands (start, stop, serve, status, logs)
5//! - Define shared types (DaemonState) and constants
6//! - Provide shared helpers for daemon state management
7//!
8//! Not handled here:
9//! - Individual command implementations (see submodules)
10//! - Windows service management (Unix-only implementation)
11//!
12//! Invariants/assumptions:
13//! - Daemon uses a dedicated lock at `.ralph/cache/daemon.lock`
14//! - Daemon state is stored at `.ralph/cache/daemon.json`
15
16mod logs;
17mod serve;
18mod start;
19mod status;
20mod stop;
21
22use anyhow::{Context, Result};
23use serde::{Deserialize, Serialize};
24use std::fs;
25use std::path::Path;
26use std::time::{Duration, Instant};
27
28pub use logs::logs;
29pub use serve::serve;
30pub use start::start;
31pub use status::status;
32pub use stop::stop;
33
34/// Daemon state file name.
35pub(super) const DAEMON_STATE_FILE: &str = "daemon.json";
36/// Daemon lock directory name (relative to .ralph/cache).
37pub(super) const DAEMON_LOCK_DIR: &str = "daemon.lock";
38
39/// Re-export for use in submodules.
40pub(super) use logs::DAEMON_LOG_FILE_NAME;
41
42/// Daemon state persisted to disk.
43#[derive(Debug, Serialize, Deserialize)]
44pub(super) struct DaemonState {
45    /// Schema version for future compatibility.
46    pub(super) version: u32,
47    /// Process ID of the daemon.
48    pub(super) pid: u32,
49    /// ISO 8601 timestamp when the daemon started.
50    pub(super) started_at: String,
51    /// Repository root path.
52    pub(super) repo_root: String,
53    /// Full command line of the daemon process.
54    pub(super) command: String,
55}
56
57/// Read daemon state from disk.
58pub(super) fn get_daemon_state(cache_dir: &Path) -> Result<Option<DaemonState>> {
59    let path = cache_dir.join(DAEMON_STATE_FILE);
60    if !path.exists() {
61        return Ok(None);
62    }
63
64    let content = fs::read_to_string(&path)
65        .with_context(|| format!("Failed to read daemon state from {}", path.display()))?;
66
67    let state: DaemonState = serde_json::from_str(&content)
68        .with_context(|| format!("Failed to parse daemon state from {}", path.display()))?;
69
70    Ok(Some(state))
71}
72
73/// Write daemon state to disk atomically.
74pub(super) fn write_daemon_state(cache_dir: &Path, state: &DaemonState) -> Result<()> {
75    let path = cache_dir.join(DAEMON_STATE_FILE);
76    let content =
77        serde_json::to_string_pretty(state).context("Failed to serialize daemon state")?;
78    crate::fsutil::write_atomic(&path, content.as_bytes())
79        .with_context(|| format!("Failed to write daemon state to {}", path.display()))?;
80    Ok(())
81}
82
83/// Poll daemon state until it matches `pid` or a timeout elapses.
84pub(super) fn wait_for_daemon_state_pid(
85    cache_dir: &Path,
86    pid: u32,
87    timeout: Duration,
88    poll_interval: Duration,
89) -> Result<bool> {
90    let poll_interval = poll_interval.max(Duration::from_millis(1));
91    let deadline = Instant::now() + timeout;
92    loop {
93        if let Some(state) = get_daemon_state(cache_dir)?
94            && state.pid == pid
95        {
96            return Ok(true);
97        }
98        if Instant::now() >= deadline {
99            return Ok(false);
100        }
101        std::thread::sleep(poll_interval);
102    }
103}
104
105/// Check PID liveness for daemon processes.
106pub(super) fn daemon_pid_liveness(pid: u32) -> crate::lock::PidLiveness {
107    crate::lock::pid_liveness(pid)
108}
109
110/// Render manual cleanup instructions for stale/indeterminate daemon state.
111pub(super) fn manual_daemon_cleanup_instructions(cache_dir: &Path) -> String {
112    format!(
113        "If you are certain the daemon is stopped, manually remove:\n  rm {}\n  rm -rf {}",
114        cache_dir.join(DAEMON_STATE_FILE).display(),
115        cache_dir.join(DAEMON_LOCK_DIR).display()
116    )
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use std::time::Duration;
123    use tempfile::TempDir;
124
125    #[test]
126    fn wait_for_daemon_state_pid_returns_true_when_state_appears() {
127        let temp = TempDir::new().expect("create temp dir");
128        let cache_dir = temp.path().join(".ralph/cache");
129        fs::create_dir_all(&cache_dir).expect("create cache dir");
130        let expected_pid = 424_242_u32;
131
132        let writer_cache_dir = cache_dir.clone();
133        let writer = std::thread::spawn(move || {
134            std::thread::sleep(Duration::from_millis(60));
135            let state = DaemonState {
136                version: 1,
137                pid: expected_pid,
138                started_at: "2026-01-01T00:00:00Z".to_string(),
139                repo_root: "/tmp/repo".to_string(),
140                command: "ralph daemon serve".to_string(),
141            };
142            write_daemon_state(&writer_cache_dir, &state).expect("write daemon state");
143        });
144
145        let ready = wait_for_daemon_state_pid(
146            &cache_dir,
147            expected_pid,
148            Duration::from_secs(1),
149            Duration::from_millis(10),
150        )
151        .expect("poll daemon state");
152        writer.join().expect("join writer thread");
153        assert!(ready, "expected daemon state to appear before timeout");
154    }
155
156    #[test]
157    fn wait_for_daemon_state_pid_returns_false_on_timeout() {
158        let temp = TempDir::new().expect("create temp dir");
159        let cache_dir = temp.path().join(".ralph/cache");
160        fs::create_dir_all(&cache_dir).expect("create cache dir");
161
162        let ready = wait_for_daemon_state_pid(
163            &cache_dir,
164            123_456_u32,
165            Duration::from_millis(100),
166            Duration::from_millis(10),
167        )
168        .expect("poll daemon state");
169        assert!(!ready, "expected timeout when daemon state is absent");
170    }
171
172    #[test]
173    fn manual_cleanup_instructions_include_state_and_lock_paths() {
174        let temp = TempDir::new().expect("create temp dir");
175        let cache_dir = temp.path().join(".ralph/cache");
176        let instructions = manual_daemon_cleanup_instructions(&cache_dir);
177
178        assert!(instructions.contains(&format!(
179            "rm {}",
180            cache_dir.join(DAEMON_STATE_FILE).display()
181        )));
182        assert!(instructions.contains(&format!(
183            "rm -rf {}",
184            cache_dir.join(DAEMON_LOCK_DIR).display()
185        )));
186    }
187
188    #[test]
189    fn manual_cleanup_instructions_do_not_reference_force_flag() {
190        let temp = TempDir::new().expect("create temp dir");
191        let cache_dir = temp.path().join(".ralph/cache");
192        let instructions = manual_daemon_cleanup_instructions(&cache_dir);
193
194        assert!(
195            !instructions.contains("--force"),
196            "daemon cleanup instructions must not mention nonexistent --force flag"
197        );
198    }
199}