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#[cfg(test)]
150mod tests {
151    use std::sync::atomic::{AtomicU32, Ordering};
152
153    use tempfile::TempDir;
154
155    use super::*;
156
157    static COUNTER: AtomicU32 = AtomicU32::new(0);
158
159    fn unique_pid_path(dir: &TempDir) -> PathBuf {
160        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
161        dir.path().join(format!("zeph-{n}.pid"))
162    }
163
164    #[test]
165    fn acquire_creates_file_with_pid() {
166        let dir = TempDir::new().unwrap();
167        let path = unique_pid_path(&dir);
168
169        let guard = PidLockGuard::acquire(&path).expect("acquire should succeed");
170        let content = std::fs::read_to_string(&path).expect("pid file must exist");
171        assert_eq!(
172            content.trim().parse::<u32>().unwrap(),
173            std::process::id(),
174            "pid file must contain current process pid"
175        );
176        drop(guard);
177        assert!(!path.exists(), "pid file must be removed on drop");
178    }
179
180    #[test]
181    fn second_acquire_fails_with_already_running() {
182        let dir = TempDir::new().unwrap();
183        let path = unique_pid_path(&dir);
184
185        let _guard = PidLockGuard::acquire(&path).expect("first acquire must succeed");
186        let err = PidLockGuard::acquire(&path).expect_err("second acquire must fail");
187        assert!(
188            matches!(err, PidLockError::AlreadyRunning { .. }),
189            "expected AlreadyRunning, got {err:?}"
190        );
191    }
192
193    #[test]
194    fn read_pid_lenient_returns_none_for_nonexistent_file() {
195        let dir = TempDir::new().unwrap();
196        let path = dir.path().join("nonexistent.pid");
197        assert!(read_pid_lenient(&path).is_none());
198    }
199
200    #[test]
201    fn read_pid_lenient_returns_none_for_invalid_content() {
202        let dir = TempDir::new().unwrap();
203        let path = unique_pid_path(&dir);
204        std::fs::write(&path, "not_a_number").unwrap();
205        assert!(read_pid_lenient(&path).is_none());
206    }
207}