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}