Skip to main content

zeph_common/
pidfile.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Shared `flock(2)`-backed advisory pid-file guard.
5//!
6//! This is the single-instance-lock primitive used by both `zeph-core::daemon::PidGuard`
7//! and `zeph-scheduler::pidfile::PidFile`. The lock is acquired with `LOCK_EX | LOCK_NB`
8//! so a second invocation fails immediately rather than blocking, and the pid file is
9//! unlinked when the guard is dropped.
10//!
11//! **Invariant**: the pid file MUST reside on a local filesystem. NFS mounts do not
12//! guarantee reliable exclusive locking with `flock(2)`.
13//!
14//! Unix only — `flock(2)` has no portable equivalent, so callers on other platforms must
15//! implement their own fallback (see `zeph-core::daemon::PidGuard`'s non-Unix branch).
16
17#![cfg(unix)]
18
19use std::path::{Path, PathBuf};
20
21use rustix::fd::OwnedFd;
22use rustix::fs::{FlockOperation, Mode, OFlags};
23
24/// Error acquiring or maintaining a [`PidLockGuard`].
25#[derive(Debug, thiserror::Error)]
26pub enum PidLockError {
27    /// Another process already holds the exclusive lock on the pid file.
28    ///
29    /// The inner `pid` is read back from the file's contents; it is `0` if the file could
30    /// not be read or its contents could not be parsed as a PID.
31    #[error("another process holds the lock (pid {pid})")]
32    AlreadyRunning {
33        /// PID of the process currently holding the lock, or `0` if unknown.
34        pid: u32,
35    },
36    /// A filesystem error occurred while opening, locking, or writing the pid file.
37    #[error(transparent)]
38    Io(#[from] std::io::Error),
39}
40
41/// Exclusive, `flock(2)`-backed guard on a pid file.
42///
43/// Acquiring the lock writes the current process PID to the file. Dropping the guard
44/// unlinks the file and then closes the file descriptor, releasing the lock.
45///
46/// The fd inheritance invariant: the file is opened with `O_CLOEXEC`, so child processes
47/// spawned via `Command` do NOT inherit the lock. If you re-exec the binary, the new
48/// process must call [`PidLockGuard::acquire`] independently.
49///
50/// # Examples
51///
52/// ```
53/// use zeph_common::pidfile::PidLockGuard;
54///
55/// let path = std::env::temp_dir().join(format!("zeph-pidfile-doctest-{}.pid", std::process::id()));
56/// let guard = PidLockGuard::acquire(&path).expect("no other instance running");
57/// assert_eq!(guard.path(), path.as_path());
58/// drop(guard); // releases the lock and removes the pid file
59/// assert!(!path.exists());
60/// ```
61#[derive(Debug)]
62pub struct PidLockGuard {
63    #[allow(dead_code)] // held for its Drop (closes fd, releases flock)
64    fd: OwnedFd,
65    path: PathBuf,
66}
67
68impl PidLockGuard {
69    /// Open (or create) the pid file at `path` and acquire an exclusive advisory lock.
70    ///
71    /// The sequence is:
72    /// 1. `open(O_RDWR | O_CREAT | O_CLOEXEC, 0o644)` — atomic create-or-open.
73    /// 2. `flock(LOCK_EX | LOCK_NB)` — fails immediately if already locked.
74    /// 3. `ftruncate(0)` + write current PID.
75    ///
76    /// # Errors
77    ///
78    /// - [`PidLockError::AlreadyRunning`] if another process holds the lock.
79    /// - [`PidLockError::Io`] for filesystem errors.
80    pub fn acquire(path: &Path) -> Result<Self, PidLockError> {
81        // Create parent directory on-demand so first-run works out of the box.
82        if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
83            std::fs::create_dir_all(parent)?;
84        }
85
86        let fd = rustix::fs::open(
87            path,
88            OFlags::RDWR | OFlags::CREATE | OFlags::CLOEXEC,
89            Mode::from_raw_mode(0o644),
90        )
91        .map_err(std::io::Error::from)?;
92
93        // Try to acquire an exclusive non-blocking lock.
94        rustix::fs::flock(&fd, FlockOperation::NonBlockingLockExclusive).map_err(|e| {
95            // EWOULDBLOCK means another process holds the lock.
96            if e == rustix::io::Errno::WOULDBLOCK {
97                let pid = read_pid_lenient(path).unwrap_or(0);
98                PidLockError::AlreadyRunning { pid }
99            } else {
100                PidLockError::Io(e.into())
101            }
102        })?;
103
104        // We hold the lock — truncate and write our PID.
105        rustix::fs::ftruncate(&fd, 0).map_err(std::io::Error::from)?;
106        rustix::io::write(&fd, std::process::id().to_string().as_bytes())
107            .map_err(std::io::Error::from)?;
108
109        Ok(Self {
110            fd,
111            path: path.to_owned(),
112        })
113    }
114
115    /// Path to the locked pid file.
116    #[must_use]
117    pub fn path(&self) -> &Path {
118        &self.path
119    }
120}
121
122impl Drop for PidLockGuard {
123    fn drop(&mut self) {
124        // Unlink first so a subsequent acquire attempt sees no stale file while we still
125        // hold the lock. Then `fd` drops, closing the fd and releasing the flock.
126        let _ = std::fs::remove_file(&self.path);
127    }
128}
129
130/// Best-effort read of the PID stored in the file at `path`.
131///
132/// Returns `None` if the file does not exist, cannot be read, or its contents cannot be
133/// parsed as a PID. Used to populate [`PidLockError::AlreadyRunning`] and by callers that
134/// want to check pid-file contents without holding (or contending for) the lock.
135///
136/// # Examples
137///
138/// ```
139/// use zeph_common::pidfile::read_pid_lenient;
140///
141/// let missing = std::env::temp_dir().join("zeph-pidfile-doctest-missing.pid");
142/// assert_eq!(read_pid_lenient(&missing), None);
143/// ```
144#[must_use]
145pub fn read_pid_lenient(path: &Path) -> Option<u32> {
146    std::fs::read_to_string(path).ok()?.trim().parse().ok()
147}
148
149/// Check whether a process with the given PID is currently alive.
150///
151/// Uses `kill(pid, 0)`, which sends no signal but returns an error if the process does not
152/// exist or is a zombie that cannot be signalled. Promoted here (from `zeph-scheduler`'s
153/// former private copy) so any crate diagnosing a contended [`PidLockGuard`]-style lock can
154/// check the recorded holder's liveness without depending on `zeph-scheduler`.
155///
156/// # Examples
157///
158/// ```
159/// use zeph_common::pidfile::is_process_alive;
160///
161/// assert!(is_process_alive(std::process::id()));
162/// assert!(!is_process_alive(0));
163/// ```
164#[must_use]
165pub fn is_process_alive(pid: u32) -> bool {
166    // kill(pid, 0) returns Ok if the process exists and we have permission to signal it,
167    // Err(EPERM) if it exists but we lack permission, Err(ESRCH) if it does not exist.
168    // Both Ok and EPERM mean the process is alive.
169    let Some(rustix_pid) = rustix::process::Pid::from_raw(pid.cast_signed()) else {
170        return false;
171    };
172    match rustix::process::test_kill_process(rustix_pid) {
173        Ok(()) => true,
174        Err(e) if e == rustix::io::Errno::PERM => true,
175        Err(_) => false,
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use std::sync::atomic::{AtomicU32, Ordering};
182
183    use tempfile::TempDir;
184
185    use super::*;
186
187    static COUNTER: AtomicU32 = AtomicU32::new(0);
188
189    fn unique_pid_path(dir: &TempDir) -> PathBuf {
190        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
191        dir.path().join(format!("zeph-{n}.pid"))
192    }
193
194    #[test]
195    fn acquire_creates_file_with_pid() {
196        let dir = TempDir::new().unwrap();
197        let path = unique_pid_path(&dir);
198
199        let guard = PidLockGuard::acquire(&path).expect("acquire should succeed");
200        let content = std::fs::read_to_string(&path).expect("pid file must exist");
201        assert_eq!(
202            content.trim().parse::<u32>().unwrap(),
203            std::process::id(),
204            "pid file must contain current process pid"
205        );
206        drop(guard);
207        assert!(!path.exists(), "pid file must be removed on drop");
208    }
209
210    #[test]
211    fn second_acquire_fails_with_already_running() {
212        let dir = TempDir::new().unwrap();
213        let path = unique_pid_path(&dir);
214
215        let _guard = PidLockGuard::acquire(&path).expect("first acquire must succeed");
216        let err = PidLockGuard::acquire(&path).expect_err("second acquire must fail");
217        assert!(
218            matches!(err, PidLockError::AlreadyRunning { .. }),
219            "expected AlreadyRunning, got {err:?}"
220        );
221    }
222
223    #[test]
224    fn read_pid_lenient_returns_none_for_nonexistent_file() {
225        let dir = TempDir::new().unwrap();
226        let path = dir.path().join("nonexistent.pid");
227        assert!(read_pid_lenient(&path).is_none());
228    }
229
230    #[test]
231    fn read_pid_lenient_returns_none_for_invalid_content() {
232        let dir = TempDir::new().unwrap();
233        let path = unique_pid_path(&dir);
234        std::fs::write(&path, "not_a_number").unwrap();
235        assert!(read_pid_lenient(&path).is_none());
236    }
237}