ralph/commands/daemon/
mod.rs1mod 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
34pub(super) const DAEMON_STATE_FILE: &str = "daemon.json";
36pub(super) const DAEMON_LOCK_DIR: &str = "daemon.lock";
38
39pub(super) use logs::DAEMON_LOG_FILE_NAME;
41
42#[derive(Debug, Serialize, Deserialize)]
44pub(super) struct DaemonState {
45 pub(super) version: u32,
47 pub(super) pid: u32,
49 pub(super) started_at: String,
51 pub(super) repo_root: String,
53 pub(super) command: String,
55}
56
57pub(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
73pub(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
83pub(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
105pub(super) fn daemon_pid_liveness(pid: u32) -> crate::lock::PidLiveness {
107 crate::lock::pid_liveness(pid)
108}
109
110pub(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}