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}