Skip to main content

omgbase_sync/
lock.rs

1//! Locks (`spec/sync/README.md` §7): advisory `O_EXCL` lock files under
2//! `.omgbase/` holding `{"pid": <holder>, "ts": <ms>}`; a lock whose holder
3//! is dead (or whose body is unparsable — §9) is stolen. The writer lock
4//! polls; the watch lease is try-only.
5
6use std::fs::OpenOptions;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::time::{Duration, Instant};
10
11use crate::error::{Error, Result};
12
13/// `writer.lock`.
14pub const WRITER_LOCK: &str = "writer.lock";
15/// `watch.lock`.
16pub const WATCH_LEASE: &str = "watch.lock";
17
18/// Whether `pid` is alive: `kill(pid, 0)` succeeds, or fails with `EPERM`
19/// (alive but not ours).
20#[must_use]
21#[allow(unsafe_code)]
22pub fn pid_alive(pid: i64) -> bool {
23    let Ok(pid) = libc::pid_t::try_from(pid) else {
24        return false;
25    };
26    if pid <= 0 {
27        return false;
28    }
29    // SAFETY: `kill` with signal 0 performs no action beyond the permission
30    // and existence checks; it takes plain integers and touches no memory.
31    let rc = unsafe { libc::kill(pid, 0) };
32    if rc == 0 {
33        return true;
34    }
35    std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
36}
37
38/// Send `SIGTERM` to `pid` (a no-op for a non-positive pid).
39#[allow(unsafe_code)]
40pub(crate) fn send_sigterm(pid: i64) {
41    let Ok(pid) = libc::pid_t::try_from(pid) else {
42        return;
43    };
44    if pid <= 0 {
45        return;
46    }
47    // SAFETY: `kill` takes plain integers and touches no memory.
48    unsafe {
49        libc::kill(pid, libc::SIGTERM);
50    }
51}
52
53fn now_ms() -> i64 {
54    std::time::SystemTime::now()
55        .duration_since(std::time::UNIX_EPOCH)
56        .map(|d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX))
57        .unwrap_or(0)
58}
59
60/// The holder pid recorded in a lock file, or `None` when the file is
61/// missing, unreadable or not `{"pid": n}`.
62#[must_use]
63pub fn read_holder_pid(path: &Path) -> Option<i64> {
64    let text = std::fs::read_to_string(path).ok()?;
65    let v: serde_json::Value = serde_json::from_str(&text).ok()?;
66    v.get("pid")?.as_i64()
67}
68
69/// One attempt: exclusive create with our record; on `EEXIST` steal a dead
70/// or unparsable holder's file (unlink, retry once); `false` when a live
71/// holder keeps it.
72fn try_create(path: &Path) -> Result<bool> {
73    match OpenOptions::new().write(true).create_new(true).open(path) {
74        Ok(mut f) => {
75            let record = serde_json::json!({ "pid": std::process::id(), "ts": now_ms() });
76            f.write_all(record.to_string().as_bytes())
77                .map_err(|e| Error::io("cannot write lock", path, e))?;
78            Ok(true)
79        }
80        Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
81            let holder = read_holder_pid(path);
82            let stale = holder.is_none_or(|pid| !pid_alive(pid));
83            if !stale {
84                return Ok(false);
85            }
86            if std::fs::remove_file(path).is_err() {
87                // Someone else stole it first; the caller retries.
88                return Ok(false);
89            }
90            match OpenOptions::new().write(true).create_new(true).open(path) {
91                Ok(mut f) => {
92                    let record = serde_json::json!({ "pid": std::process::id(), "ts": now_ms() });
93                    f.write_all(record.to_string().as_bytes())
94                        .map_err(|e| Error::io("cannot write lock", path, e))?;
95                    Ok(true)
96                }
97                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false),
98                Err(e) => Err(Error::io("cannot create lock", path, e)),
99            }
100        }
101        Err(e) => Err(Error::io("cannot create lock", path, e)),
102    }
103}
104
105fn ensure_dir(dir: &Path) -> Result<()> {
106    std::fs::create_dir_all(dir).map_err(|e| Error::io("cannot create", dir, e))
107}
108
109/// How long a writer waits, and how often it polls.
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub struct WriterLockOptions {
112    pub timeout: Duration,
113    pub poll: Duration,
114}
115
116impl Default for WriterLockOptions {
117    /// 5 s, polling every 25 ms (§7).
118    fn default() -> Self {
119        Self {
120            timeout: Duration::from_millis(5000),
121            poll: Duration::from_millis(25),
122        }
123    }
124}
125
126/// The workspace writer lock (`writer.lock`), released on drop.
127#[derive(Debug)]
128pub struct WriterLock {
129    path: PathBuf,
130    held: bool,
131}
132
133impl WriterLock {
134    /// The lock file's path under `omgbase_dir`.
135    #[must_use]
136    pub fn path_in(omgbase_dir: &Path) -> PathBuf {
137        omgbase_dir.join(WRITER_LOCK)
138    }
139
140    /// Take the lock without waiting; `None` when a live writer holds it.
141    pub fn try_acquire(omgbase_dir: &Path) -> Result<Option<Self>> {
142        ensure_dir(omgbase_dir)?;
143        let path = Self::path_in(omgbase_dir);
144        Ok(try_create(&path)?.then(|| Self { path, held: true }))
145    }
146
147    /// Take the lock, polling until free or the timeout elapses
148    /// ([`Error::WriterLockTimeout`] naming the holder).
149    pub fn acquire(omgbase_dir: &Path, opts: WriterLockOptions) -> Result<Self> {
150        ensure_dir(omgbase_dir)?;
151        let path = Self::path_in(omgbase_dir);
152        let deadline = Instant::now() + opts.timeout;
153        loop {
154            if try_create(&path)? {
155                return Ok(Self { path, held: true });
156            }
157            if Instant::now() >= deadline {
158                return Err(Error::WriterLockTimeout {
159                    holder_pid: read_holder_pid(&path),
160                    lock_path: path,
161                });
162            }
163            std::thread::sleep(opts.poll);
164        }
165    }
166
167    /// Whether no live writer holds the lock (a probe; acquires nothing).
168    #[must_use]
169    pub fn is_free(omgbase_dir: &Path) -> bool {
170        let path = Self::path_in(omgbase_dir);
171        if !path.exists() {
172            return true;
173        }
174        read_holder_pid(&path).is_none_or(|pid| !pid_alive(pid))
175    }
176
177    #[must_use]
178    pub fn path(&self) -> &Path {
179        &self.path
180    }
181
182    /// Unlink the lock file (best effort; idempotent).
183    pub fn release(&mut self) {
184        if !self.held {
185            return;
186        }
187        self.held = false;
188        let _ = std::fs::remove_file(&self.path);
189    }
190}
191
192impl Drop for WriterLock {
193    fn drop(&mut self) {
194        self.release();
195    }
196}
197
198/// Run `f` holding the writer lock; released even when `f` fails.
199pub fn with_writer_lock<T>(
200    omgbase_dir: &Path,
201    opts: WriterLockOptions,
202    f: impl FnOnce() -> Result<T>,
203) -> Result<T> {
204    let mut lock = WriterLock::acquire(omgbase_dir, opts)?;
205    let out = f();
206    lock.release();
207    out
208}
209
210/// The watch lease (`watch.lock`): held by a live watcher for its lifetime.
211#[derive(Debug)]
212pub struct WatchLease {
213    path: PathBuf,
214    held: bool,
215}
216
217impl WatchLease {
218    /// The lease file's path under `omgbase_dir`.
219    #[must_use]
220    pub fn path_in(omgbase_dir: &Path) -> PathBuf {
221        omgbase_dir.join(WATCH_LEASE)
222    }
223
224    /// Take the lease; `None` when a live watcher holds it (a dead holder's
225    /// lease is stolen).
226    pub fn try_acquire(omgbase_dir: &Path) -> Result<Option<Self>> {
227        ensure_dir(omgbase_dir)?;
228        let path = Self::path_in(omgbase_dir);
229        Ok(try_create(&path)?.then(|| Self { path, held: true }))
230    }
231
232    /// Whether a live watcher holds the lease (the one-shot commands' probe).
233    #[must_use]
234    pub fn live(omgbase_dir: &Path) -> bool {
235        let path = Self::path_in(omgbase_dir);
236        if !path.exists() {
237            return false;
238        }
239        read_holder_pid(&path).is_some_and(pid_alive)
240    }
241
242    #[must_use]
243    pub fn path(&self) -> &Path {
244        &self.path
245    }
246
247    pub fn release(&mut self) {
248        if !self.held {
249            return;
250        }
251        self.held = false;
252        let _ = std::fs::remove_file(&self.path);
253    }
254}
255
256impl Drop for WatchLease {
257    fn drop(&mut self) {
258        self.release();
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use crate::fs::TempDir;
266
267    /// A pid that is certainly dead: a reaped child's.
268    fn dead_pid() -> i64 {
269        let mut child = std::process::Command::new("true")
270            .spawn()
271            .expect("spawn true");
272        let pid = i64::from(child.id());
273        child.wait().unwrap();
274        pid
275    }
276
277    #[test]
278    fn liveness_probe() {
279        assert!(pid_alive(i64::from(std::process::id())));
280        assert!(pid_alive(1), "pid 1 is alive (EPERM counts as alive)");
281        assert!(!pid_alive(dead_pid()));
282        assert!(!pid_alive(0));
283        assert!(!pid_alive(-5));
284        assert!(!pid_alive(i64::MAX));
285    }
286
287    #[test]
288    fn writer_lock_round_trip_and_timeout() {
289        let tmp = TempDir::new("writer");
290        let dir = tmp.path().join(".omgbase");
291        assert!(WriterLock::is_free(&dir));
292        let fast = WriterLockOptions {
293            timeout: Duration::from_millis(80),
294            poll: Duration::from_millis(10),
295        };
296        {
297            let lock = WriterLock::acquire(&dir, fast).unwrap();
298            assert!(lock.path().is_file());
299            let body: serde_json::Value =
300                serde_json::from_str(&std::fs::read_to_string(lock.path()).unwrap()).unwrap();
301            assert_eq!(body["pid"], std::process::id());
302            assert!(body["ts"].is_number());
303            assert!(!WriterLock::is_free(&dir));
304            assert!(WriterLock::try_acquire(&dir).unwrap().is_none());
305            let err = WriterLock::acquire(&dir, fast).unwrap_err();
306            match err {
307                Error::WriterLockTimeout {
308                    holder_pid,
309                    lock_path,
310                } => {
311                    assert_eq!(holder_pid, Some(i64::from(std::process::id())));
312                    assert_eq!(lock_path, lock.path());
313                }
314                other => panic!("{other}"),
315            }
316            assert!(err_string_names_pid(
317                &WriterLock::acquire(&dir, fast).unwrap_err()
318            ));
319        }
320        assert!(WriterLock::is_free(&dir), "released on drop");
321        assert!(!WriterLock::path_in(&dir).exists());
322
323        // with_writer_lock releases even on failure.
324        let r: Result<()> = with_writer_lock(&dir, fast, || Err(Error::Other("boom".into())));
325        assert!(r.is_err());
326        assert!(WriterLock::is_free(&dir));
327        assert_eq!(with_writer_lock(&dir, fast, || Ok(7)).unwrap(), 7);
328
329        // A dead holder is stolen; so is an unparsable record.
330        std::fs::write(
331            WriterLock::path_in(&dir),
332            format!("{{\"pid\":{}}}", dead_pid()),
333        )
334        .unwrap();
335        assert!(WriterLock::is_free(&dir));
336        let l = WriterLock::try_acquire(&dir).unwrap().expect("stolen");
337        drop(l);
338        std::fs::write(WriterLock::path_in(&dir), "garbage").unwrap();
339        assert!(WriterLock::is_free(&dir));
340        assert!(WriterLock::try_acquire(&dir).unwrap().is_some());
341        assert!(WriterLock::is_free(&dir));
342        // Releasing twice is fine.
343        let mut l = WriterLock::try_acquire(&dir).unwrap().unwrap();
344        l.release();
345        l.release();
346    }
347
348    fn err_string_names_pid(e: &Error) -> bool {
349        e.to_string()
350            .contains(&format!("held by pid {}", std::process::id()))
351    }
352
353    #[test]
354    fn watch_lease_is_try_only_and_probeable() {
355        let tmp = TempDir::new("lease");
356        let dir = tmp.path().join(".omgbase");
357        assert!(!WatchLease::live(&dir));
358        let lease = WatchLease::try_acquire(&dir).unwrap().expect("free");
359        assert!(WatchLease::live(&dir));
360        assert!(WatchLease::try_acquire(&dir).unwrap().is_none());
361        assert_eq!(
362            read_holder_pid(lease.path()),
363            Some(i64::from(std::process::id()))
364        );
365        drop(lease);
366        assert!(!WatchLease::live(&dir));
367        std::fs::write(
368            WatchLease::path_in(&dir),
369            format!("{{\"pid\":{},\"ts\":0}}", dead_pid()),
370        )
371        .unwrap();
372        assert!(!WatchLease::live(&dir), "a dead holder is not live");
373        let mut l = WatchLease::try_acquire(&dir)
374            .unwrap()
375            .expect("stolen from the dead");
376        assert!(WatchLease::live(&dir));
377        l.release();
378        assert!(!WatchLease::path_in(&dir).exists());
379        std::fs::write(WatchLease::path_in(&dir), "{}").unwrap();
380        assert!(!WatchLease::live(&dir));
381        assert!(WatchLease::try_acquire(&dir).unwrap().is_some());
382        // The two locks are independent files.
383        let _w = WriterLock::try_acquire(&dir).unwrap().unwrap();
384        assert!(!WatchLease::live(&dir));
385    }
386}