1use std::time::Duration;
7
8use tokio::sync::watch;
9use tokio::task::JoinHandle;
10
11use crate::config::DaemonConfig;
12
13#[non_exhaustive]
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum ComponentStatus {
16 Running,
17 Failed(String),
18 Stopped,
19}
20
21#[non_exhaustive]
22#[derive(Debug, thiserror::Error)]
24pub enum DaemonError {
25 #[error("task error: {0}")]
26 Task(String),
27 #[error("shutdown error: {0}")]
28 Shutdown(String),
29 #[error(
30 "another daemon instance is already running (PID {pid}); stop it before starting a new one"
31 )]
32 AlreadyRunning { pid: u32 },
33 #[error("pid file error: {0}")]
34 PidFile(#[from] std::io::Error),
35}
36
37pub struct ComponentHandle {
38 pub name: String,
39 handle: JoinHandle<Result<(), DaemonError>>,
40 pub status: ComponentStatus,
41 pub restart_count: u32,
42}
43
44impl ComponentHandle {
45 #[must_use]
46 pub fn new(name: impl Into<String>, handle: JoinHandle<Result<(), DaemonError>>) -> Self {
47 Self {
48 name: name.into(),
49 handle,
50 status: ComponentStatus::Running,
51 restart_count: 0,
52 }
53 }
54
55 #[must_use]
56 pub fn is_finished(&self) -> bool {
57 self.handle.is_finished()
58 }
59}
60
61pub struct DaemonSupervisor {
62 components: Vec<ComponentHandle>,
63 health_interval: Duration,
64 _max_backoff: Duration,
65 shutdown_rx: watch::Receiver<bool>,
66}
67
68impl DaemonSupervisor {
69 #[must_use]
70 pub fn new(config: &DaemonConfig, shutdown_rx: watch::Receiver<bool>) -> Self {
71 Self {
72 components: Vec::new(),
73 health_interval: Duration::from_secs(config.health_interval_secs),
74 _max_backoff: Duration::from_secs(config.max_restart_backoff_secs),
75 shutdown_rx,
76 }
77 }
78
79 pub fn add_component(&mut self, handle: ComponentHandle) {
80 self.components.push(handle);
81 }
82
83 #[must_use]
84 pub fn component_count(&self) -> usize {
85 self.components.len()
86 }
87
88 pub async fn run(&mut self) {
90 let mut interval = tokio::time::interval(self.health_interval);
91 loop {
92 tokio::select! {
93 _ = interval.tick() => {
94 self.check_health();
95 }
96 _ = self.shutdown_rx.changed() => {
97 if *self.shutdown_rx.borrow() {
98 tracing::info!("daemon supervisor shutting down");
99 break;
100 }
101 }
102 }
103 }
104 }
105
106 fn check_health(&mut self) {
107 for component in &mut self.components {
108 if component.status == ComponentStatus::Running && component.is_finished() {
109 component.status = ComponentStatus::Failed("task exited".into());
110 component.restart_count += 1;
111 tracing::warn!(
112 component = %component.name,
113 restarts = component.restart_count,
114 "component exited unexpectedly"
115 );
116 }
117 }
118 }
119
120 #[must_use]
121 pub fn component_statuses(&self) -> Vec<(&str, &ComponentStatus)> {
122 self.components
123 .iter()
124 .map(|c| (c.name.as_str(), &c.status))
125 .collect()
126 }
127}
128
129#[must_use]
135pub fn is_process_alive(pid: u32) -> bool {
136 #[cfg(unix)]
137 {
138 let Ok(signed) = i32::try_from(pid) else {
141 return false;
142 };
143 if signed <= 0 {
144 return false;
145 }
146 std::process::Command::new("kill")
147 .args(["-0", &signed.to_string()])
148 .output()
149 .is_ok_and(|o| o.status.success())
150 }
151 #[cfg(windows)]
152 {
153 std::process::Command::new("tasklist")
154 .args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
155 .output()
156 .map(|o| {
157 let stdout = String::from_utf8_lossy(&o.stdout);
158 stdout.contains(&format!("\"{pid}\""))
161 })
162 .unwrap_or(false)
163 }
164 #[cfg(not(any(unix, windows)))]
165 {
166 let _ = pid;
167 false
168 }
169}
170
171pub fn write_pid_file(path: &str) -> std::io::Result<()> {
178 use std::io::Write as _;
179 let expanded = expand_tilde(path);
180 let path = std::path::Path::new(&expanded);
181 if let Some(parent) = path.parent() {
182 std::fs::create_dir_all(parent)?;
183 }
184 let mut file = std::fs::OpenOptions::new()
185 .write(true)
186 .create_new(true)
187 .open(path)?;
188 file.write_all(std::process::id().to_string().as_bytes())
189}
190
191pub fn read_pid_file(path: &str) -> std::io::Result<u32> {
197 let expanded = expand_tilde(path);
198 let content = std::fs::read_to_string(&expanded)?;
199 content
200 .trim()
201 .parse::<u32>()
202 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
203}
204
205pub fn remove_pid_file(path: &str) -> std::io::Result<()> {
211 let expanded = expand_tilde(path);
212 match std::fs::remove_file(&expanded) {
213 Ok(()) => Ok(()),
214 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
215 Err(e) => Err(e),
216 }
217}
218
219#[cfg(unix)]
238#[derive(Debug)]
239pub struct PidGuard(#[allow(dead_code)] zeph_common::pidfile::PidLockGuard);
240
241#[cfg(unix)]
242impl PidGuard {
243 pub fn acquire(path: &str) -> Result<Self, DaemonError> {
251 use zeph_common::pidfile::{PidLockError, PidLockGuard};
252
253 let expanded = expand_tilde(path);
254 let path = std::path::Path::new(&expanded);
255
256 PidLockGuard::acquire(path).map(Self).map_err(|e| match e {
257 PidLockError::AlreadyRunning { pid } => DaemonError::AlreadyRunning { pid },
258 PidLockError::Io(err) => DaemonError::PidFile(err),
259 })
260 }
261}
262
263#[cfg(not(unix))]
271#[derive(Debug)]
272pub struct PidGuard {
273 path: String,
274}
275
276#[cfg(not(unix))]
277impl PidGuard {
278 pub fn acquire(path: &str) -> Result<Self, DaemonError> {
285 if let Ok(existing_pid) = read_pid_file(path) {
286 if is_process_alive(existing_pid) {
287 return Err(DaemonError::AlreadyRunning { pid: existing_pid });
288 }
289 remove_pid_file(path)?;
290 }
291 write_pid_file(path)?;
292 Ok(Self {
293 path: path.to_owned(),
294 })
295 }
296}
297
298#[cfg(not(unix))]
299impl Drop for PidGuard {
300 fn drop(&mut self) {
301 let _ = remove_pid_file(&self.path);
302 }
303}
304
305fn expand_tilde(path: &str) -> String {
306 if let Some(rest) = path.strip_prefix("~/")
307 && let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))
308 {
309 return format!("{}/{rest}", home.to_string_lossy());
310 }
311 path.to_owned()
312}
313
314#[cfg(test)]
315mod tests {
316 #![allow(clippy::field_reassign_with_default)]
317 use std::assert_matches;
318
319 use super::*;
320
321 #[test]
322 fn expand_tilde_with_home() {
323 let result = expand_tilde("~/test/file.pid");
324 assert!(!result.starts_with("~/"));
325 }
326
327 #[test]
328 fn expand_tilde_absolute_unchanged() {
329 assert_eq!(expand_tilde("/tmp/zeph.pid"), "/tmp/zeph.pid");
330 }
331
332 #[test]
333 fn pid_file_roundtrip() {
334 let dir = tempfile::tempdir().unwrap();
335 let path = dir.path().join("test.pid");
336 let path_str = path.to_string_lossy().to_string();
337
338 write_pid_file(&path_str).unwrap();
339 let pid = read_pid_file(&path_str).unwrap();
340 assert_eq!(pid, std::process::id());
341 remove_pid_file(&path_str).unwrap();
342 assert!(!path.exists());
343 }
344
345 #[test]
346 fn remove_nonexistent_pid_file_ok() {
347 assert!(remove_pid_file("/tmp/nonexistent_zeph_test.pid").is_ok());
348 }
349
350 #[cfg(unix)]
351 #[test]
352 fn pid_guard_acquire_writes_pid_and_removes_on_drop() {
353 let dir = tempfile::tempdir().unwrap();
354 let path = dir.path().join("guard.pid");
355 let path_str = path.to_string_lossy().to_string();
356
357 let guard = PidGuard::acquire(&path_str).expect("acquire should succeed");
358 let pid = read_pid_file(&path_str).expect("pid file must exist");
359 assert_eq!(pid, std::process::id());
360 drop(guard);
361 assert!(!path.exists(), "pid file must be removed on drop");
362 }
363
364 #[cfg(unix)]
368 #[test]
369 fn pid_guard_second_acquire_fails_with_already_running() {
370 let dir = tempfile::tempdir().unwrap();
371 let path = dir.path().join("guard-race.pid");
372 let path_str = path.to_string_lossy().to_string();
373
374 let _first = PidGuard::acquire(&path_str).expect("first acquire must succeed");
375 let err = PidGuard::acquire(&path_str).expect_err("second acquire must fail");
376 assert_matches!(err, DaemonError::AlreadyRunning { .. });
377 }
378
379 #[test]
380 fn read_invalid_pid_file() {
381 let dir = tempfile::tempdir().unwrap();
382 let path = dir.path().join("bad.pid");
383 std::fs::write(&path, "not_a_number").unwrap();
384 assert!(read_pid_file(&path.to_string_lossy()).is_err());
385 }
386
387 #[tokio::test]
388 async fn supervisor_tracks_components() {
389 let config = DaemonConfig::default();
390 let (_tx, rx) = watch::channel(false);
391 let mut supervisor = DaemonSupervisor::new(&config, rx);
392
393 let handle = tokio::spawn(async { Ok::<(), DaemonError>(()) });
394 supervisor.add_component(ComponentHandle::new("test", handle));
395 assert_eq!(supervisor.component_count(), 1);
396 }
397
398 #[tokio::test]
399 async fn supervisor_detects_finished_component() {
400 let config = DaemonConfig::default();
401 let (_tx, rx) = watch::channel(false);
402 let mut supervisor = DaemonSupervisor::new(&config, rx);
403
404 let handle = tokio::spawn(async { Ok::<(), DaemonError>(()) });
405 tokio::time::sleep(Duration::from_millis(10)).await;
406 supervisor.add_component(ComponentHandle::new("finished", handle));
407 supervisor.check_health();
408
409 let statuses = supervisor.component_statuses();
410 assert_eq!(statuses.len(), 1);
411 assert_matches!(statuses[0].1, ComponentStatus::Failed(_));
412 }
413
414 #[tokio::test]
415 async fn supervisor_shutdown() {
416 let config = DaemonConfig {
417 health_interval_secs: 1,
418 ..DaemonConfig::default()
419 };
420 let (tx, rx) = watch::channel(false);
421 let mut supervisor = DaemonSupervisor::new(&config, rx);
422
423 let run_handle = tokio::spawn(async move { supervisor.run().await });
424 tokio::time::sleep(Duration::from_millis(50)).await;
425 let _ = tx.send(true);
426 tokio::time::timeout(Duration::from_secs(2), run_handle)
427 .await
428 .expect("supervisor should stop on shutdown")
429 .expect("task should complete");
430 }
431
432 #[test]
433 fn component_status_eq() {
434 assert_eq!(ComponentStatus::Running, ComponentStatus::Running);
435 assert_eq!(ComponentStatus::Stopped, ComponentStatus::Stopped);
436 assert_ne!(ComponentStatus::Running, ComponentStatus::Stopped);
437 }
438
439 #[test]
440 fn is_process_alive_current_process() {
441 let pid = std::process::id();
442 assert!(is_process_alive(pid), "current process must be alive");
443 }
444
445 #[test]
446 fn is_process_alive_nonexistent_pid() {
447 assert!(
449 !is_process_alive(u32::MAX),
450 "PID u32::MAX must not be alive"
451 );
452 }
453}