Skip to main content

oximemo_core/
lock.rs

1//! Process-local + cross-process advisory locking (§5.7).
2//!
3//! redb guarantees a single in-process writer but offers no protection between
4//! processes (GUI + CLI sharing one `meta.redb`). We layer an `fs2` flock on a
5//! dedicated lock file:
6//!
7//! - **shared** for reads (multiple readers allowed),
8//! - **exclusive** for writes (mutates the index).
9//!
10//! The memo files themselves are never locked — atomic rename keeps them safe,
11//! and external editors/agents may read and write them freely.
12
13use std::fs::{File, OpenOptions};
14use std::path::Path;
15use std::time::Duration;
16
17use fs2::FileExt;
18
19use crate::error::{CoreError, Result};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum LockKind {
23    Shared,
24    Exclusive,
25}
26
27/// A held advisory lock. Dropping it releases the flock.
28pub struct FileLock {
29    file: File,
30    kind: LockKind,
31}
32
33impl FileLock {
34    fn acquire(path: &Path, kind: LockKind, timeout: Duration) -> Result<Self> {
35        if let Some(parent) = path.parent() {
36            std::fs::create_dir_all(parent)?;
37        }
38        let file = OpenOptions::new()
39            .create(true)
40            .read(true)
41            .write(true)
42            .truncate(false)
43            .open(path)?;
44
45        let deadline = std::time::Instant::now() + timeout;
46        loop {
47            let acquired = match kind {
48                LockKind::Shared => file.try_lock_shared().is_ok(),
49                LockKind::Exclusive => file.try_lock_exclusive().is_ok(),
50            };
51            if acquired {
52                return Ok(Self { file, kind });
53            }
54            // Contended (another process holds the lock); retry until deadline.
55            if std::time::Instant::now() >= deadline {
56                let secs = timeout.as_secs();
57                tracing::warn!(lock = %path.display(), "lock acquire timed out");
58                return Err(CoreError::LockTimeout(secs.max(1)));
59            }
60            std::thread::sleep(Duration::from_millis(25));
61        }
62    }
63
64    pub fn kind(&self) -> LockKind {
65        self.kind
66    }
67}
68
69impl Drop for FileLock {
70    fn drop(&mut self) {
71        let _ = self.file.unlock();
72    }
73}
74
75/// Open (creating if needed) and hold a lock file. `timeout` caps contention
76/// waits; default policy in callers is 5s (§5.7).
77pub fn acquire(path: &Path, kind: LockKind, timeout: Duration) -> Result<FileLock> {
78    FileLock::acquire(path, kind, timeout)
79}
80
81/// Probe whether an *exclusive* lock is currently held by some process. Used by
82/// `oximemo doctor` to report lock contention without blocking.
83pub fn is_locked(path: &Path) -> bool {
84    let Ok(file) = OpenOptions::new()
85        .read(true)
86        .write(true)
87        .create(false)
88        .open(path)
89    else {
90        return false;
91    };
92    file.try_lock_exclusive().is_err()
93}