Skip to main content

kindling_server/
pid.rs

1//! PID-file lock with stale-cleanup.
2//!
3//! On startup the daemon writes its PID to [`ServerConfig::pid_path`]. Before
4//! binding, if a PID file already exists it is checked for liveness:
5//!   - **dead PID → stale**: the file is removed and acquisition proceeds,
6//!     rewriting the file with the current PID.
7//!   - **live PID → another daemon is running**: acquisition fails with
8//!     [`ServerError::AlreadyRunning`] (the file is never clobbered).
9//!
10//! [`PidGuard`] removes the PID file on drop, so a clean shutdown leaves no
11//! stale file behind.
12
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use crate::error::ServerError;
17
18/// Holds the PID-file lock for the lifetime of the daemon. Removes the file on
19/// drop (best-effort).
20#[derive(Debug)]
21pub struct PidGuard {
22    path: PathBuf,
23}
24
25impl PidGuard {
26    /// The PID-file path this guard owns.
27    pub fn path(&self) -> &Path {
28        &self.path
29    }
30}
31
32impl Drop for PidGuard {
33    fn drop(&mut self) {
34        // Best-effort: only remove the file if it still holds *our* pid, so we
35        // never delete a file a successor process has rewritten.
36        if let Ok(contents) = fs::read_to_string(&self.path) {
37            if contents.trim().parse::<i32>().ok() == Some(std::process::id() as i32) {
38                let _ = fs::remove_file(&self.path);
39            }
40        }
41    }
42}
43
44/// Acquire the PID-file lock at `path`, cleaning up a stale file if present.
45///
46/// Returns a [`PidGuard`] that releases the lock on drop. Errors with
47/// [`ServerError::AlreadyRunning`] if a live daemon already holds the file.
48pub fn acquire_pid_lock(path: &Path) -> Result<PidGuard, ServerError> {
49    if let Some(existing) = read_pid(path)? {
50        if process_is_alive(existing) {
51            return Err(ServerError::AlreadyRunning(existing));
52        }
53        // Stale: the owning process is gone. Remove and take over.
54        fs::remove_file(path).map_err(|e| ServerError::Pid(format!("removing stale pid: {e}")))?;
55    }
56
57    if let Some(parent) = path.parent() {
58        if !parent.as_os_str().is_empty() {
59            fs::create_dir_all(parent)
60                .map_err(|e| ServerError::Pid(format!("creating pid dir: {e}")))?;
61        }
62    }
63
64    let pid = std::process::id();
65    fs::write(path, pid.to_string())
66        .map_err(|e| ServerError::Pid(format!("writing pid file: {e}")))?;
67
68    Ok(PidGuard {
69        path: path.to_path_buf(),
70    })
71}
72
73/// Read and parse the PID from `path`, if the file exists. A malformed file is
74/// treated as stale (`Ok(None)` would leave it in place, so we surface a parse
75/// failure as "no live owner" by returning `Ok(None)` — the caller then treats
76/// a present-but-unparseable file as removable). We return the parsed PID when
77/// readable, and `Ok(None)` when the file is absent or unparseable.
78fn read_pid(path: &Path) -> Result<Option<i32>, ServerError> {
79    match fs::read_to_string(path) {
80        Ok(contents) => Ok(contents.trim().parse::<i32>().ok()),
81        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
82        Err(e) => Err(ServerError::Pid(format!("reading pid file: {e}"))),
83    }
84}
85
86/// Whether a process with `pid` is currently alive.
87#[cfg(unix)]
88fn process_is_alive(pid: i32) -> bool {
89    use nix::sys::signal::kill;
90    use nix::unistd::Pid;
91    // Signal 0 performs error checking without sending a signal. `Ok` means the
92    // process exists (and we may signal it); `EPERM` means it exists but we
93    // lack permission — still alive. Any other error means it's gone.
94    match kill(Pid::from_raw(pid), None) {
95        Ok(()) => true,
96        Err(nix::errno::Errno::EPERM) => true,
97        Err(_) => false,
98    }
99}
100
101#[cfg(windows)]
102fn process_is_alive(_pid: i32) -> bool {
103    // Minimal Windows fallback: assume not alive so a stale file never blocks
104    // startup. PORT-013 can harden this with OpenProcess if needed.
105    false
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111    use std::io::Write;
112
113    #[test]
114    fn acquires_when_no_pidfile() {
115        let dir = tempfile::tempdir().unwrap();
116        let path = dir.path().join("kindling.pid");
117        let guard = acquire_pid_lock(&path).expect("should acquire");
118        let written = fs::read_to_string(&path).unwrap();
119        assert_eq!(written.trim(), std::process::id().to_string());
120        drop(guard);
121        assert!(!path.exists(), "guard should remove pid file on drop");
122    }
123
124    #[test]
125    fn cleans_up_stale_pidfile() {
126        let dir = tempfile::tempdir().unwrap();
127        let path = dir.path().join("kindling.pid");
128
129        // Write a pidfile with a guaranteed-dead PID. PID 2^31-1 is not a live
130        // process on any reasonable system.
131        let dead_pid = i32::MAX;
132        {
133            let mut f = fs::File::create(&path).unwrap();
134            write!(f, "{dead_pid}").unwrap();
135        }
136        assert!(!process_is_alive(dead_pid));
137
138        let guard = acquire_pid_lock(&path).expect("stale pid must not block acquisition");
139        let written = fs::read_to_string(&path).unwrap();
140        assert_eq!(
141            written.trim(),
142            std::process::id().to_string(),
143            "pid file should be rewritten with the new (live) pid"
144        );
145        drop(guard);
146    }
147
148    #[test]
149    fn live_pidfile_blocks_acquisition() {
150        let dir = tempfile::tempdir().unwrap();
151        let path = dir.path().join("kindling.pid");
152
153        // Our own PID is definitely alive.
154        let me = std::process::id() as i32;
155        fs::write(&path, me.to_string()).unwrap();
156
157        let result = acquire_pid_lock(&path);
158        assert!(
159            matches!(result, Err(ServerError::AlreadyRunning(p)) if p == me),
160            "a live pidfile must block acquisition"
161        );
162        // The live file must be left untouched.
163        assert_eq!(fs::read_to_string(&path).unwrap().trim(), me.to_string());
164    }
165}