Skip to main content

zeph_core/
daemon.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Daemon supervisor for component lifecycle management.
5
6use 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/// Error type for daemon component task failures and pid file guard acquisition.
23#[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    /// Run the health monitoring loop until shutdown signal.
89    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/// Check whether a process with the given PID is currently alive.
130///
131/// On Unix, uses `kill -0` which returns success if the process exists and the current user
132/// has permission to signal it.
133/// On Windows, uses `tasklist /FI "PID eq <pid>"` and checks for the PID in the output.
134#[must_use]
135pub fn is_process_alive(pid: u32) -> bool {
136    #[cfg(unix)]
137    {
138        // PIDs on Unix are signed (pid_t = i32); u32::MAX wraps to -1 which would
139        // signal every process, so reject anything that does not fit in a positive i32.
140        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                // tasklist outputs lines like: "process.exe","PID","..."
159                // We look for the PID appearing as a quoted field.
160                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
171/// Write a PID file atomically using `O_CREAT | O_EXCL` to prevent TOCTOU races.
172///
173/// # Errors
174///
175/// Returns an error if the PID file directory cannot be created, the file already exists,
176/// or the file cannot be written.
177pub 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
191/// Read the PID from a PID file.
192///
193/// # Errors
194///
195/// Returns an error if the file cannot be read or the content is not a valid PID.
196pub 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
205/// Remove the PID file.
206///
207/// # Errors
208///
209/// Returns an error if the file cannot be removed.
210pub 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/// Exclusive, `flock(2)`-backed guard on the daemon's pid file.
220///
221/// Replaces the previous check-then-act sequence (read pid file, check liveness, remove if
222/// stale, then write) with a single atomic lock acquisition: only one process can ever hold the
223/// advisory lock on the pid file, so a second concurrent `--daemon` invocation fails immediately
224/// with [`DaemonError::AlreadyRunning`] instead of racing the first to write the file. Dropping
225/// the guard unlinks the pid file and releases the lock.
226///
227/// # Examples
228///
229/// ```no_run
230/// use zeph_core::daemon::PidGuard;
231///
232/// let guard = PidGuard::acquire("/tmp/zeph-daemon.pid").expect("no other instance running");
233/// // ... run the daemon ...
234/// drop(guard); // releases the lock and removes the pid file
235/// ```
236// Wraps the shared guard purely for its `Drop` impl (unlinks the pid file on release).
237#[cfg(unix)]
238#[derive(Debug)]
239pub struct PidGuard(#[allow(dead_code)] zeph_common::pidfile::PidLockGuard);
240
241#[cfg(unix)]
242impl PidGuard {
243    /// Acquire an exclusive lock on the pid file at `path`, creating it if necessary, and write
244    /// the current process id into it.
245    ///
246    /// # Errors
247    ///
248    /// Returns [`DaemonError::AlreadyRunning`] if another process already holds the lock, or
249    /// [`DaemonError::PidFile`] for filesystem failures.
250    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/// Exclusive guard on the daemon's pid file (non-Unix fallback).
264///
265/// `flock(2)` is unavailable outside Unix, so this falls back to the create-then-check
266/// sequence: [`write_pid_file`] already uses an atomic exclusive create, so two processes
267/// racing to start fresh are still serialized correctly; only recovery from a stale pid file
268/// left by a crashed process re-opens a (narrow) TOCTOU window between the liveness check and
269/// the write.
270#[cfg(not(unix))]
271#[derive(Debug)]
272pub struct PidGuard {
273    path: String,
274}
275
276#[cfg(not(unix))]
277impl PidGuard {
278    /// Acquire the pid file guard at `path`, removing a stale (dead-process) file first.
279    ///
280    /// # Errors
281    ///
282    /// Returns [`DaemonError::AlreadyRunning`] if the existing pid file names a live process, or
283    /// [`DaemonError::PidFile`] for filesystem failures.
284    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    /// Regression test for #5679: a second concurrent acquisition attempt on a pid file
365    /// already locked by a live guard must fail closed with `AlreadyRunning`, not silently
366    /// overwrite the file or proceed.
367    #[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        // u32::MAX is effectively guaranteed to not be a valid running PID.
448        assert!(
449            !is_process_alive(u32::MAX),
450            "PID u32::MAX must not be alive"
451        );
452    }
453}