Skip to main content

handoff/
lock.rs

1//! `DataDirLock` — RAII flock on `<data_dir>/.handoff.lock`.
2//!
3//! The flock is the single source of truth for "which process owns the writer
4//! for this data directory." See correctness invariant #1 in `ARCHITECTURE.md`.
5//!
6//! The lock is paired with `<data_dir>/.handoff.pidfile`, a plain-text file
7//! containing the holder's PID. If a process dies abnormally (SIGKILL,
8//! oom-kill, segfault), the kernel releases the flock automatically — but the
9//! pidfile remains as a hint. [`DataDirLock::acquire_or_break_stale`] uses the
10//! pidfile + `kill(pid, 0)` liveness check to safely break orphaned locks
11//! without risk of two-writers.
12
13use std::fs::{File, OpenOptions};
14use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use nix::fcntl::{Flock, FlockArg};
18use nix::sys::signal::kill;
19use nix::unistd::Pid;
20
21use crate::error::{Error, Result};
22
23const LOCK_FILE: &str = ".handoff.lock";
24const PID_FILE: &str = ".handoff.pidfile";
25
26/// RAII guard. Drop releases the kernel-level flock automatically (by closing
27/// the underlying file descriptor) and removes the pidfile.
28pub struct DataDirLock {
29    _flock: Flock<File>,
30    data_dir: PathBuf,
31    pid_path: PathBuf,
32}
33
34impl std::fmt::Debug for DataDirLock {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_struct("DataDirLock")
37            .field("data_dir", &self.data_dir)
38            .finish()
39    }
40}
41
42impl DataDirLock {
43    /// Path of the data directory this lock protects. Used by callers that
44    /// need to release-then-re-acquire (e.g. `ResumeAfterAbort`).
45    pub fn data_dir(&self) -> &Path {
46        &self.data_dir
47    }
48}
49
50impl DataDirLock {
51    /// Acquire the writer lock on `data_dir`. Returns immediately with
52    /// [`Error::LockHeld`] if another process holds it.
53    pub fn acquire(data_dir: &Path) -> Result<Self> {
54        std::fs::create_dir_all(data_dir)?;
55        let lock_path = data_dir.join(LOCK_FILE);
56        let pid_path = data_dir.join(PID_FILE);
57
58        let file = OpenOptions::new()
59            .read(true)
60            .write(true)
61            .create(true)
62            .truncate(false)
63            .open(&lock_path)?;
64
65        let flock = match Flock::lock(file, FlockArg::LockExclusiveNonblock) {
66            Ok(flock) => flock,
67            Err((_file, nix::errno::Errno::EWOULDBLOCK)) => {
68                let holder = read_pidfile(&pid_path).unwrap_or(0);
69                return Err(Error::LockHeld { holder_pid: holder });
70            }
71            Err((_file, errno)) => return Err(Error::Nix(errno)),
72        };
73
74        write_pid_atomic(&pid_path, std::process::id())?;
75        Ok(Self {
76            _flock: flock,
77            data_dir: data_dir.to_path_buf(),
78            pid_path,
79        })
80    }
81
82    /// Like [`Self::acquire`], but if the lock appears stale (pidfile names
83    /// a PID that's no longer alive), reclaim it. Refuses to break a lock
84    /// held by a live named holder.
85    ///
86    /// Strategy: re-attempt `Self::acquire` on the existing lockfile inode.
87    /// When the named holder really has died, the kernel released its flock
88    /// and the second attempt succeeds. When something else is genuinely
89    /// holding the flock — an inherited FD outliving the named holder, or a
90    /// PID-reuse race that briefly makes the pidfile lie — the second
91    /// attempt still returns `LockHeld` and we surface
92    /// [`Error::StaleLockBreakRefused`].
93    ///
94    /// We deliberately do NOT unlink the lockfile and acquire on a fresh
95    /// inode: that path can leave two processes each holding "the lock" on
96    /// separate inodes if the original inode's flock is still held,
97    /// violating invariant #1 (at most one process holds the writer lock).
98    pub fn acquire_or_break_stale(data_dir: &Path) -> Result<Self> {
99        match Self::acquire(data_dir) {
100            Ok(lock) => Ok(lock),
101            Err(Error::LockHeld { holder_pid }) => {
102                if holder_pid != 0 && is_pid_alive(holder_pid) {
103                    return Err(Error::StaleLockBreakRefused { holder_pid });
104                }
105                tracing::warn!(
106                    holder_pid,
107                    "data-dir flock appears stale (named holder dead); retrying acquire"
108                );
109                match Self::acquire(data_dir) {
110                    Ok(lock) => Ok(lock),
111                    Err(Error::LockHeld { holder_pid }) => {
112                        Err(Error::StaleLockBreakRefused { holder_pid })
113                    }
114                    Err(e) => Err(e),
115                }
116            }
117            Err(e) => Err(e),
118        }
119    }
120}
121
122impl Drop for DataDirLock {
123    fn drop(&mut self) {
124        // The flock is released when `_flock` drops (kernel close).
125        // Best-effort: clear the pidfile so future stale-break checks don't
126        // see our PID hanging around.
127        let _ = std::fs::remove_file(&self.pid_path);
128    }
129}
130
131fn read_pidfile(path: &Path) -> Option<i32> {
132    std::fs::read_to_string(path).ok()?.trim().parse().ok()
133}
134
135fn write_pid_atomic(path: &Path, pid: u32) -> Result<()> {
136    let tmp = path.with_extension("pidfile.tmp");
137    {
138        let mut f = OpenOptions::new()
139            .write(true)
140            .create(true)
141            .truncate(true)
142            .open(&tmp)?;
143        writeln!(f, "{pid}")?;
144        f.sync_all()?;
145    }
146    std::fs::rename(&tmp, path)?;
147    // fsync the parent directory so the rename's link-update is durable.
148    // The pidfile is advisory (flock is authoritative), but a stale or
149    // missing pidfile after crash recovery defeats `acquire_or_break_stale`'s
150    // ability to identify the prior holder.
151    if let Some(parent) = path.parent() {
152        let target = if parent.as_os_str().is_empty() {
153            Path::new(".")
154        } else {
155            parent
156        };
157        File::open(target)?.sync_all()?;
158    }
159    Ok(())
160}
161
162fn is_pid_alive(pid: i32) -> bool {
163    if pid <= 0 {
164        return false;
165    }
166    matches!(kill(Pid::from_raw(pid), None), Ok(()))
167}
168
169#[cfg(test)]
170mod tests {
171    use std::os::fd::AsRawFd;
172
173    use super::*;
174
175    #[test]
176    fn acquire_succeeds_on_empty_dir() {
177        let dir = tempfile::tempdir().unwrap();
178        let lock = DataDirLock::acquire(dir.path()).unwrap();
179        drop(lock);
180    }
181
182    #[test]
183    fn second_acquire_returns_lock_held() {
184        let dir = tempfile::tempdir().unwrap();
185        let _lock = DataDirLock::acquire(dir.path()).unwrap();
186        match DataDirLock::acquire(dir.path()) {
187            Err(Error::LockHeld { holder_pid }) => {
188                assert_eq!(holder_pid as u32, std::process::id());
189            }
190            other => panic!("expected LockHeld, got {other:?}"),
191        }
192    }
193
194    #[test]
195    fn release_on_drop_allows_reacquire() {
196        let dir = tempfile::tempdir().unwrap();
197        {
198            let _lock = DataDirLock::acquire(dir.path()).unwrap();
199        }
200        let _lock = DataDirLock::acquire(dir.path()).unwrap();
201    }
202
203    #[test]
204    fn stale_break_refuses_for_live_pid() {
205        let dir = tempfile::tempdir().unwrap();
206        let _held = DataDirLock::acquire(dir.path()).unwrap();
207        match DataDirLock::acquire_or_break_stale(dir.path()) {
208            Err(Error::StaleLockBreakRefused { holder_pid }) => {
209                assert_eq!(holder_pid as u32, std::process::id());
210            }
211            other => panic!("expected refusal, got {other:?}"),
212        }
213    }
214
215    #[test]
216    fn stale_break_succeeds_when_kernel_released_flock() {
217        // Crashed prior holder: lockfile + pidfile on disk, flock NOT
218        // currently held (the kernel released it when the PID died).
219        // `i32::MAX` is above pid_max on Linux, so `kill(MAX, 0)` returns
220        // ESRCH and the pidfile is unambiguously stale.
221        let dir = tempfile::tempdir().unwrap();
222        std::fs::write(dir.path().join(LOCK_FILE), b"").unwrap();
223        std::fs::write(dir.path().join(PID_FILE), format!("{}", i32::MAX)).unwrap();
224
225        let _new_lock = DataDirLock::acquire_or_break_stale(dir.path()).unwrap();
226    }
227
228    #[test]
229    fn stale_break_refuses_when_pidfile_lies_but_flock_held() {
230        // The pidfile names a dead PID, but someone is genuinely holding
231        // the flock right now (inherited FD, or a brief PID-reuse race).
232        // Safer to refuse than to unlink the lockfile and produce two
233        // parallel-inode flocks that would split-brain invariant #1.
234        let dir = tempfile::tempdir().unwrap();
235        let lock_path = dir.path().join(LOCK_FILE);
236        let pid_path = dir.path().join(PID_FILE);
237
238        let f = OpenOptions::new()
239            .read(true)
240            .write(true)
241            .create(true)
242            .truncate(false)
243            .open(&lock_path)
244            .unwrap();
245        let _other_flock = Flock::lock(f, FlockArg::LockExclusiveNonblock)
246            .map_err(|(_, e)| e)
247            .unwrap();
248        std::fs::write(&pid_path, format!("{}", i32::MAX)).unwrap();
249
250        match DataDirLock::acquire_or_break_stale(dir.path()) {
251            Err(Error::StaleLockBreakRefused { .. }) => {}
252            other => panic!("expected StaleLockBreakRefused, got {other:?}"),
253        }
254        // Keep the flock alive through the assertion so the test models
255        // a genuinely-held flock, not a transient one.
256        assert!(_other_flock.as_raw_fd() >= 0);
257    }
258}