Skip to main content

mempal_runtime/ingest/
lock.rs

1//! Per-source filesystem advisory lock for ingest critical sections.
2//!
3//! Eliminates the TOCTOU race between concurrent `ingest_file_with_options`
4//! calls (e.g. Claude Code + Codex ingesting the same file at the same
5//! time). Pattern:
6//!
7//!   1. acquire_source_lock(home, source_key, timeout)  ← blocks if held
8//!   2. re-check dedup (may have changed while waiting)
9//!   3. delete-then-insert drawers + vectors
10//!   4. IngestLock dropped on return → file closed → OS releases flock
11//!
12//! Unix: `flock(fd, LOCK_EX | LOCK_NB)` via inline extern (no libc dep).
13//! Windows: no-op fallback (concurrent ingest on Windows is not
14//! race-protected; follow-up work to adopt `LockFileEx`).
15
16use std::fs::{File, OpenOptions};
17use std::io;
18use std::path::{Path, PathBuf};
19use std::time::{Duration, Instant};
20
21use thiserror::Error;
22
23#[derive(Debug, Error)]
24pub enum LockError {
25    #[error("timed out after {} ms acquiring ingest lock on {path}", timeout.as_millis())]
26    Timeout { path: PathBuf, timeout: Duration },
27    #[error("io error on ingest lock {path}: {source}")]
28    Io {
29        path: PathBuf,
30        #[source]
31        source: io::Error,
32    },
33    #[error("invalid source_key `{0}` (empty or contains path separators)")]
34    InvalidSourceKey(String),
35}
36
37/// RAII guard. Dropping releases the lock (OS-level close of file handle
38/// releases the flock on Unix).
39#[derive(Debug)]
40pub struct IngestLock {
41    _file: File,
42    path: PathBuf,
43    wait: Duration,
44}
45
46impl IngestLock {
47    pub fn wait_duration(&self) -> Duration {
48        self.wait
49    }
50
51    pub fn path(&self) -> &Path {
52        &self.path
53    }
54}
55
56/// Compute a short, filename-safe source key from a path.
57///
58/// Uses the std `DefaultHasher` (SipHash) → 16 hex chars. Not
59/// cryptographic; collision probability on realistic workloads is
60/// negligible and collisions only cause false-serialization of unrelated
61/// sources (correctness-preserving).
62pub fn source_key(source_file: &Path) -> String {
63    use std::hash::{Hash, Hasher};
64    let mut hasher = std::collections::hash_map::DefaultHasher::new();
65    source_file.to_string_lossy().hash(&mut hasher);
66    format!("{:016x}", hasher.finish())
67}
68
69pub fn acquire_source_lock(
70    mempal_home: &Path,
71    source_key: &str,
72    timeout: Duration,
73) -> Result<IngestLock, LockError> {
74    if source_key.is_empty()
75        || source_key.contains('/')
76        || source_key.contains('\\')
77        || source_key.contains("..")
78    {
79        return Err(LockError::InvalidSourceKey(source_key.to_string()));
80    }
81
82    let locks_dir = mempal_home.join("locks");
83    if !locks_dir.exists() {
84        std::fs::create_dir_all(&locks_dir).map_err(|e| LockError::Io {
85            path: locks_dir.clone(),
86            source: e,
87        })?;
88    }
89    let lock_path = locks_dir.join(format!("{source_key}.lock"));
90
91    let file = OpenOptions::new()
92        .create(true)
93        .read(true)
94        .truncate(false)
95        .write(true)
96        .open(&lock_path)
97        .map_err(|e| LockError::Io {
98            path: lock_path.clone(),
99            source: e,
100        })?;
101
102    let start = Instant::now();
103    loop {
104        match imp::try_lock_exclusive(&file) {
105            Ok(()) => {
106                return Ok(IngestLock {
107                    _file: file,
108                    path: lock_path,
109                    wait: start.elapsed(),
110                });
111            }
112            Err(imp::LockAcquire::WouldBlock) => {
113                if start.elapsed() >= timeout {
114                    return Err(LockError::Timeout {
115                        path: lock_path,
116                        timeout,
117                    });
118                }
119                std::thread::sleep(Duration::from_millis(50 + jitter_ms()));
120            }
121            Err(imp::LockAcquire::Io(e)) => {
122                return Err(LockError::Io {
123                    path: lock_path,
124                    source: e,
125                });
126            }
127        }
128    }
129}
130
131fn jitter_ms() -> u64 {
132    use std::time::{SystemTime, UNIX_EPOCH};
133    SystemTime::now()
134        .duration_since(UNIX_EPOCH)
135        .map(|d| d.subsec_nanos() as u64 % 30)
136        .unwrap_or(0)
137}
138
139#[cfg(unix)]
140mod imp {
141    use std::fs::File;
142    use std::io;
143    use std::os::fd::AsRawFd;
144
145    const LOCK_EX: i32 = 2;
146    const LOCK_NB: i32 = 4;
147    const EWOULDBLOCK: i32 = 35; // macOS; Linux EWOULDBLOCK/EAGAIN both route here
148
149    unsafe extern "C" {
150        fn flock(fd: i32, operation: i32) -> i32;
151    }
152
153    pub enum LockAcquire {
154        WouldBlock,
155        Io(io::Error),
156    }
157
158    pub fn try_lock_exclusive(file: &File) -> Result<(), LockAcquire> {
159        let fd = file.as_raw_fd();
160        let ret = unsafe { flock(fd, LOCK_EX | LOCK_NB) };
161        if ret == 0 {
162            return Ok(());
163        }
164        let err = io::Error::last_os_error();
165        // Linux uses EAGAIN (11) for flock contention, macOS uses
166        // EWOULDBLOCK (35). Accept both.
167        match err.raw_os_error() {
168            Some(code) if code == EWOULDBLOCK || code == 11 => Err(LockAcquire::WouldBlock),
169            _ => Err(LockAcquire::Io(err)),
170        }
171    }
172}
173
174#[cfg(windows)]
175mod imp {
176    use std::fs::File;
177    use std::io;
178
179    pub enum LockAcquire {
180        WouldBlock,
181        Io(io::Error),
182    }
183
184    /// Windows fallback: always succeeds. Concurrent ingest on Windows is
185    /// not race-protected in 0.3.x; follow-up spec to adopt LockFileEx.
186    pub fn try_lock_exclusive(_file: &File) -> Result<(), LockAcquire> {
187        Ok(())
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn test_source_key_is_deterministic() {
197        let p = Path::new("/tmp/foo/bar.md");
198        assert_eq!(source_key(p), source_key(p));
199    }
200
201    #[test]
202    fn test_source_key_is_fs_safe() {
203        let k = source_key(Path::new("/tmp/a/b/c.md"));
204        assert_eq!(k.len(), 16);
205        assert!(k.chars().all(|c| c.is_ascii_hexdigit()));
206    }
207
208    #[test]
209    fn test_invalid_source_key_with_slash_rejected() {
210        let tmp = tempfile::tempdir().unwrap();
211        let err = acquire_source_lock(tmp.path(), "a/b", Duration::from_millis(100));
212        assert!(matches!(err, Err(LockError::InvalidSourceKey(_))));
213    }
214
215    #[test]
216    fn test_invalid_source_key_with_traversal_rejected() {
217        let tmp = tempfile::tempdir().unwrap();
218        let err = acquire_source_lock(tmp.path(), "..", Duration::from_millis(100));
219        assert!(matches!(err, Err(LockError::InvalidSourceKey(_))));
220    }
221
222    #[cfg(unix)]
223    #[test]
224    fn test_acquire_then_release_round_trip() {
225        let tmp = tempfile::tempdir().unwrap();
226        let key = source_key(Path::new("/tmp/test-source"));
227
228        let guard1 =
229            acquire_source_lock(tmp.path(), &key, Duration::from_secs(1)).expect("first acquire");
230        drop(guard1);
231
232        let guard2 = acquire_source_lock(tmp.path(), &key, Duration::from_millis(500))
233            .expect("second acquire after drop");
234        assert!(guard2.wait_duration() < Duration::from_millis(500));
235    }
236
237    #[cfg(unix)]
238    #[test]
239    fn test_concurrent_holders_serialize() {
240        use std::sync::Arc;
241        use std::sync::atomic::{AtomicUsize, Ordering};
242        use std::thread;
243
244        let tmp = Arc::new(tempfile::tempdir().unwrap());
245        let key = source_key(Path::new("/tmp/concurrent-source"));
246        let counter = Arc::new(AtomicUsize::new(0));
247
248        let handles: Vec<_> = (0..3)
249            .map(|_| {
250                let tmp = Arc::clone(&tmp);
251                let key = key.clone();
252                let counter = Arc::clone(&counter);
253                thread::spawn(move || {
254                    let guard = acquire_source_lock(tmp.path(), &key, Duration::from_secs(5))
255                        .expect("acquire");
256                    // Enter critical section: ensure no other thread is
257                    // inside simultaneously.
258                    let inside = counter.fetch_add(1, Ordering::SeqCst);
259                    assert_eq!(inside, 0, "serial critical section violated");
260                    thread::sleep(Duration::from_millis(50));
261                    counter.fetch_sub(1, Ordering::SeqCst);
262                    drop(guard);
263                })
264            })
265            .collect();
266
267        for h in handles {
268            h.join().expect("thread");
269        }
270    }
271}