Skip to main content

mif_rh/
lock.rs

1//! Exclusive review lock, with stale-PID detection.
2//!
3//! Backed by `std::fs::File`'s native advisory file lock (`try_lock`/
4//! `unlock`, stable since Rust 1.89 — no external locking crate needed)
5//! as the actual concurrency guard, plus a PID recorded in the lock file's
6//! contents, so a lock left behind by a killed process is detected as
7//! stale and cleared automatically, rather than requiring manual
8//! intervention. This is the direct fix for the real incident this
9//! crate's design responds to: two `ontology-review.sh` bash invocations
10//! run in parallel by accident, with no lock at all, corrupted two
11//! topics' `ontology-map.json` files mid-write.
12
13use std::fs::{self, File, OpenOptions, TryLockError};
14use std::io::{Read, Write};
15use std::path::{Path, PathBuf};
16
17use sysinfo::{Pid, ProcessesToUpdate, System};
18
19use crate::error::MifRhError;
20
21/// An exclusive lock held for the duration of one `review` run. Releases
22/// automatically on drop.
23#[derive(Debug)]
24pub struct ReviewLock {
25    file: File,
26    path: PathBuf,
27}
28
29impl ReviewLock {
30    /// Acquires the exclusive review lock at `path`, clearing it first if
31    /// the PID recorded inside it no longer names a running process.
32    ///
33    /// # Errors
34    ///
35    /// Returns [`MifRhError::LockHeld`] if another live process currently
36    /// holds the lock, or [`MifRhError::LockIo`] if the lock file cannot be
37    /// opened or written.
38    pub fn acquire(path: &Path) -> Result<Self, MifRhError> {
39        if let Some(holder_pid) = read_holder_pid(path)
40            && process_is_alive(holder_pid)
41        {
42            return Err(MifRhError::LockHeld { holder_pid });
43        }
44
45        let mut file = OpenOptions::new()
46            .create(true)
47            .truncate(false)
48            .read(true)
49            .write(true)
50            .open(path)
51            .map_err(|source| MifRhError::LockIo {
52                path: path.display().to_string(),
53                source,
54            })?;
55
56        match file.try_lock() {
57            Ok(()) => {},
58            Err(TryLockError::WouldBlock) => {
59                return Err(MifRhError::LockHeld {
60                    holder_pid: read_holder_pid(path).unwrap_or(0),
61                });
62            },
63            Err(TryLockError::Error(source)) => {
64                return Err(MifRhError::LockIo {
65                    path: path.display().to_string(),
66                    source,
67                });
68            },
69        }
70
71        file.set_len(0).map_err(|source| MifRhError::LockIo {
72            path: path.display().to_string(),
73            source,
74        })?;
75        file.write_all(std::process::id().to_string().as_bytes())
76            .map_err(|source| MifRhError::LockIo {
77                path: path.display().to_string(),
78                source,
79            })?;
80
81        Ok(Self {
82            file,
83            path: path.to_path_buf(),
84        })
85    }
86}
87
88impl Drop for ReviewLock {
89    fn drop(&mut self) {
90        let _ = self.file.unlock();
91        let _ = fs::remove_file(&self.path);
92    }
93}
94
95fn read_holder_pid(path: &Path) -> Option<u32> {
96    let mut contents = String::new();
97    File::open(path).ok()?.read_to_string(&mut contents).ok()?;
98    contents.trim().parse().ok()
99}
100
101fn process_is_alive(pid: u32) -> bool {
102    let mut system = System::new();
103    system.refresh_processes(ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), true);
104    system.process(Pid::from_u32(pid)).is_some()
105}
106
107#[cfg(test)]
108mod tests {
109    use super::ReviewLock;
110
111    #[test]
112    fn acquire_creates_and_releases_the_lock_file() {
113        let dir = tempfile::tempdir().unwrap();
114        let path = dir.path().join(".review.lock");
115        assert!(!path.exists());
116
117        {
118            let _lock = ReviewLock::acquire(&path).unwrap();
119            assert!(path.exists());
120        }
121        assert!(!path.exists(), "lock file should be removed on drop");
122    }
123
124    #[test]
125    fn a_second_acquire_while_the_first_is_held_fails_closed() {
126        let dir = tempfile::tempdir().unwrap();
127        let path = dir.path().join(".review.lock");
128
129        let _first = ReviewLock::acquire(&path).unwrap();
130        let error = ReviewLock::acquire(&path).unwrap_err();
131        assert!(matches!(error, super::MifRhError::LockHeld { .. }));
132    }
133
134    #[test]
135    fn a_stale_lock_from_a_dead_pid_is_cleared_automatically() {
136        let dir = tempfile::tempdir().unwrap();
137        let path = dir.path().join(".review.lock");
138        // A PID astronomically unlikely to be alive on any real system.
139        std::fs::write(&path, "999999999").unwrap();
140
141        let lock = ReviewLock::acquire(&path);
142        assert!(
143            lock.is_ok(),
144            "expected a stale lock to be cleared, got {lock:?}"
145        );
146    }
147
148    #[test]
149    fn acquiring_again_after_release_succeeds() {
150        let dir = tempfile::tempdir().unwrap();
151        let path = dir.path().join(".review.lock");
152
153        drop(ReviewLock::acquire(&path).unwrap());
154        let second = ReviewLock::acquire(&path);
155        assert!(second.is_ok());
156    }
157}