Skip to main content

heddle_fs_prims/
lock.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Repository locking for concurrent access.
3//!
4//! [`RepoLock`] guarantees three invariants:
5//! - **Cross-process** exclusion via `flock(2)` on a lock file.
6//! - **Cross-thread, same-process** exclusion: two threads never both hold the
7//!   write lock.
8//! - **Same-thread reentrancy**: the owning thread may re-acquire the write lock
9//!   any number of times without blocking.
10//!
11//! The reentrancy invariant matters because `flock(2)` locks attach to the open
12//! file description, not the process: a single thread that opens the lock file
13//! twice and calls `flock` on the second fd blocks forever on its own first
14//! lock. The canonical write lock is taken at the top of an import and then
15//! re-taken by downstream writers on the same thread, so a non-reentrant
16//! primitive self-deadlocks. We therefore hold the `flock` once on the outermost
17//! acquisition and gate intra-process access through a per-lock-path registry.
18
19use std::{
20    collections::HashMap,
21    fs::File,
22    io,
23    path::{Path, PathBuf},
24    sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock},
25    thread::{self, ThreadId},
26};
27
28use fs2::FileExt;
29pub use heddle_object_model::error::LockError;
30
31pub type Result<T> = std::result::Result<T, LockError>;
32
33/// Intra-process state for a single lock path. `flock` holds the OS-level lock
34/// file while owned; dropping it releases the cross-process lock.
35struct GateState {
36    owner: Option<ThreadId>,
37    depth: usize,
38    flock: Option<File>,
39}
40
41struct Entry {
42    gate: Mutex<GateState>,
43    cv: Condvar,
44}
45
46impl Entry {
47    fn new() -> Self {
48        Self {
49            gate: Mutex::new(GateState {
50                owner: None,
51                depth: 0,
52                flock: None,
53            }),
54            cv: Condvar::new(),
55        }
56    }
57}
58
59/// Process-global registry of per-lock-path gates, keyed by the canonical lock
60/// path. Entries are created on first use and never removed (one small entry per
61/// distinct lock path per process lifetime).
62static REGISTRY: OnceLock<Mutex<HashMap<PathBuf, Arc<Entry>>>> = OnceLock::new();
63
64fn registry() -> &'static Mutex<HashMap<PathBuf, Arc<Entry>>> {
65    REGISTRY.get_or_init(|| Mutex::new(HashMap::new()))
66}
67
68fn entry_for(key: PathBuf) -> Arc<Entry> {
69    let mut map = registry().lock().unwrap_or_else(|e| e.into_inner());
70    Arc::clone(map.entry(key).or_insert_with(|| Arc::new(Entry::new())))
71}
72
73fn lock_gate(entry: &Entry) -> MutexGuard<'_, GateState> {
74    entry.gate.lock().unwrap_or_else(|e| e.into_inner())
75}
76
77pub struct ReadLockGuard {
78    // `None` when this is a no-op guard: the current thread already holds the
79    // write lock, whose exclusive flock subsumes a shared read.
80    _file: Option<File>,
81}
82
83impl Drop for ReadLockGuard {
84    fn drop(&mut self) {
85        if let Some(file) = &self._file {
86            let _ = file.unlock();
87        }
88    }
89}
90
91pub struct WriteLockGuard {
92    entry: Arc<Entry>,
93}
94
95impl WriteLockGuard {
96    /// Transfer this guard's reentrant ownership to the calling thread.
97    ///
98    /// A guard moved to a worker must update the process-local owner before
99    /// the original thread can continue. Otherwise the original thread can
100    /// re-acquire the same lock as a false "same-thread" reentrant hold while
101    /// the worker still owns the cross-process flock. Handoff is valid only
102    /// for an outermost guard: transferring one level of a nested hold would
103    /// strand the remaining guards on the previous owner.
104    #[doc(hidden)]
105    pub fn handoff_to_current_thread(&mut self) -> Result<()> {
106        let mut state = lock_gate(&self.entry);
107        let current = thread::current().id();
108        if state.owner == Some(current) {
109            return Ok(());
110        }
111        if state.owner.is_none() || state.depth != 1 || state.flock.is_none() {
112            return Err(LockError::Acquire(io::Error::other(
113                "write-lock handoff requires one outermost held guard",
114            )));
115        }
116        state.owner = Some(current);
117        Ok(())
118    }
119}
120
121impl Drop for WriteLockGuard {
122    fn drop(&mut self) {
123        let mut state = lock_gate(&self.entry);
124        if state.depth > 0 {
125            state.depth -= 1;
126        }
127        if state.depth == 0 {
128            state.owner = None;
129            // Dropping the File releases the cross-process flock.
130            state.flock = None;
131            self.entry.cv.notify_one();
132        }
133    }
134}
135
136pub struct RepoLock {
137    lock_path: PathBuf,
138}
139
140impl RepoLock {
141    pub fn new(repo_root: &Path) -> Self {
142        let lock_path = repo_root.join(".heddle/locks/repo.lock");
143        Self { lock_path }
144    }
145
146    pub fn at(lock_path: PathBuf) -> Self {
147        Self { lock_path }
148    }
149
150    pub fn read(&self) -> Result<ReadLockGuard> {
151        self.ensure_lock_dir()?;
152        let entry = entry_for(self.registry_key());
153
154        // If the current thread already holds the write lock, the exclusive
155        // flock covers this read; hand back a no-op guard so a same-thread
156        // read-under-write cannot deadlock against our own flock.
157        {
158            let state = lock_gate(&entry);
159            if state.owner == Some(thread::current().id()) {
160                return Ok(ReadLockGuard { _file: None });
161            }
162        }
163
164        let file = self.open_lock_file()?;
165        file.lock_shared().map_err(LockError::Acquire)?;
166        Ok(ReadLockGuard { _file: Some(file) })
167    }
168
169    pub fn write(&self) -> Result<WriteLockGuard> {
170        self.ensure_lock_dir()?;
171        let entry = entry_for(self.registry_key());
172        let tid = thread::current().id();
173        let mut state = lock_gate(&entry);
174        loop {
175            match state.owner {
176                Some(owner) if owner == tid => {
177                    state.depth += 1;
178                    return Ok(WriteLockGuard {
179                        entry: Arc::clone(&entry),
180                    });
181                }
182                None => {
183                    // Acquire the cross-process flock once for the outermost
184                    // holder. Holding the gate across this blocking call is
185                    // intentional: other local threads must block here until we
186                    // either win the flock or fail.
187                    let file = self.open_lock_file()?;
188                    file.lock_exclusive().map_err(LockError::Acquire)?;
189                    state.owner = Some(tid);
190                    state.depth = 1;
191                    state.flock = Some(file);
192                    return Ok(WriteLockGuard {
193                        entry: Arc::clone(&entry),
194                    });
195                }
196                Some(_) => {
197                    state = entry.cv.wait(state).unwrap_or_else(|e| e.into_inner());
198                }
199            }
200        }
201    }
202
203    pub fn try_read(&self) -> Result<Option<ReadLockGuard>> {
204        self.ensure_lock_dir()?;
205        let file = self.open_lock_file()?;
206
207        match file.try_lock_shared() {
208            Ok(()) => Ok(Some(ReadLockGuard { _file: Some(file) })),
209            Err(_) => Ok(None),
210        }
211    }
212
213    pub fn try_write(&self) -> Result<Option<WriteLockGuard>> {
214        self.ensure_lock_dir()?;
215        let entry = entry_for(self.registry_key());
216        let mut state = lock_gate(&entry);
217        // Non-blocking acquisition is NON-reentrant: a `try_write` while the lock
218        // is held — by ANY thread, including this one — reports contention
219        // (`None`). Reentrancy exists only to keep the BLOCKING `write()` from
220        // self-deadlocking on its own `flock`; a `try_*` can never deadlock, so a
221        // caller that uses it to detect contention (e.g. the undo/redo
222        // serialization lock, heddle#355) must see "held" regardless of holder.
223        match state.owner {
224            Some(_) => Ok(None),
225            None => {
226                let file = self.open_lock_file()?;
227                match file.try_lock_exclusive() {
228                    Ok(()) => {
229                        state.owner = Some(thread::current().id());
230                        state.depth = 1;
231                        state.flock = Some(file);
232                        Ok(Some(WriteLockGuard {
233                            entry: Arc::clone(&entry),
234                        }))
235                    }
236                    Err(_) => Ok(None),
237                }
238            }
239        }
240    }
241
242    fn ensure_lock_dir(&self) -> Result<()> {
243        if let Some(parent) = self.lock_path.parent() {
244            crate::fs_atomic::create_dir_all_durable(parent).map_err(LockError::Io)?;
245        }
246        Ok(())
247    }
248
249    /// Stable registry key for this lock path. The lock file itself may not exist
250    /// yet, so canonicalize the (already-created) parent directory and re-join
251    /// the filename rather than the whole path.
252    fn registry_key(&self) -> PathBuf {
253        match self.lock_path.parent() {
254            Some(parent) => {
255                let canon_parent = parent
256                    .canonicalize()
257                    .unwrap_or_else(|_| parent.to_path_buf());
258                match self.lock_path.file_name() {
259                    Some(name) => canon_parent.join(name),
260                    None => canon_parent,
261                }
262            }
263            None => self.lock_path.clone(),
264        }
265    }
266
267    fn open_lock_file(&self) -> Result<File> {
268        File::create(&self.lock_path).map_err(LockError::Io)
269    }
270}
271
272pub trait RepositoryLockExt {
273    fn locker(&self) -> RepoLock;
274}
275
276#[cfg(test)]
277mod tests {
278    use std::{
279        sync::{
280            Arc,
281            mpsc::{self},
282        },
283        thread,
284    };
285
286    use tempfile::TempDir;
287
288    use super::*;
289
290    #[test]
291    fn test_read_lock_acquired() {
292        let temp = TempDir::new().unwrap();
293        let lock = RepoLock::new(temp.path());
294
295        let guard = lock.read().unwrap();
296        assert!(std::mem::size_of_val(&guard) > 0);
297    }
298
299    #[test]
300    fn test_write_lock_acquired() {
301        let temp = TempDir::new().unwrap();
302        let lock = RepoLock::new(temp.path());
303
304        let guard = lock.write().unwrap();
305        assert!(std::mem::size_of_val(&guard) > 0);
306    }
307
308    #[test]
309    fn test_multiple_readers() {
310        let temp = TempDir::new().unwrap();
311        let lock = Arc::new(RepoLock::new(temp.path()));
312
313        let mut handles = vec![];
314        for _ in 0..10 {
315            let lock = Arc::clone(&lock);
316            let handle = thread::spawn(move || {
317                let _guard = lock.read().unwrap();
318                thread::sleep(std::time::Duration::from_millis(10));
319            });
320            handles.push(handle);
321        }
322
323        for handle in handles {
324            handle.join().unwrap();
325        }
326    }
327
328    #[test]
329    fn test_writer_excludes_reader() {
330        let temp = TempDir::new().unwrap();
331        let lock = Arc::new(RepoLock::new(temp.path()));
332
333        let _write_guard = lock.write().unwrap();
334        let read_result = lock.try_read().unwrap();
335        assert!(read_result.is_none(), "Reader should be blocked by writer");
336    }
337
338    #[test]
339    fn test_reader_excludes_writer() {
340        let temp = TempDir::new().unwrap();
341        let lock = Arc::new(RepoLock::new(temp.path()));
342
343        let _read_guard = lock.read().unwrap();
344        let write_result = lock.try_write().unwrap();
345        assert!(write_result.is_none(), "Writer should be blocked by reader");
346    }
347
348    #[test]
349    fn test_lock_released_on_drop() {
350        let temp = TempDir::new().unwrap();
351        let lock = RepoLock::new(temp.path());
352
353        {
354            let _guard = lock.write().unwrap();
355        }
356
357        let _guard2 = lock.read().unwrap();
358    }
359
360    /// The owning thread may re-take the write lock without blocking on its own
361    /// flock — the regression that self-deadlocked the canonical import lock.
362    #[test]
363    fn same_thread_write_is_reentrant() {
364        let temp = TempDir::new().unwrap();
365        let lock = RepoLock::new(temp.path());
366
367        let _a = lock.write().unwrap();
368        let _b = lock.write().unwrap();
369        // Reaching here without hanging is the assertion (harness timeout is the
370        // backstop on regression).
371    }
372
373    /// A read taken by the thread that already holds the write lock must not
374    /// block against its own exclusive flock.
375    #[test]
376    fn same_thread_read_under_write_does_not_deadlock() {
377        let temp = TempDir::new().unwrap();
378        let lock = RepoLock::new(temp.path());
379
380        let _w = lock.write().unwrap();
381        let _r = lock.read().unwrap();
382    }
383
384    /// Reentrancy is strictly per-thread: while one thread holds the write lock,
385    /// a different thread is excluded.
386    #[test]
387    fn distinct_threads_still_exclude() {
388        let temp = TempDir::new().unwrap();
389        let lock = Arc::new(RepoLock::new(temp.path()));
390
391        let (acquired_tx, acquired_rx) = mpsc::channel();
392        let (release_tx, release_rx) = mpsc::channel();
393        let lock_a = Arc::clone(&lock);
394        let handle = thread::spawn(move || {
395            let _g = lock_a.write().unwrap();
396            acquired_tx.send(()).unwrap();
397            release_rx.recv().unwrap();
398        });
399
400        acquired_rx.recv().unwrap();
401        assert!(
402            lock.try_write().unwrap().is_none(),
403            "a second thread must not acquire the write lock"
404        );
405
406        release_tx.send(()).unwrap();
407        handle.join().unwrap();
408
409        assert!(
410            lock.try_write().unwrap().is_some(),
411            "write lock is available once the owning thread releases"
412        );
413    }
414
415    /// A reentrant (depth > 1) hold keeps the lock until the OUTERMOST guard
416    /// drops; other threads stay excluded across the inner drops.
417    #[test]
418    fn reentrant_release_keeps_lock_until_outermost_drop() {
419        let temp = TempDir::new().unwrap();
420        let lock = Arc::new(RepoLock::new(temp.path()));
421
422        let a1 = lock.write().unwrap();
423        let a2 = lock.write().unwrap();
424
425        let other = |lock: &Arc<RepoLock>| {
426            let lock = Arc::clone(lock);
427            thread::spawn(move || lock.try_write().unwrap().is_none())
428                .join()
429                .unwrap()
430        };
431
432        assert!(other(&lock), "excluded while held at depth 2");
433        drop(a2);
434        assert!(other(&lock), "still excluded while held at depth 1");
435        drop(a1);
436
437        let lock_b = Arc::clone(&lock);
438        let now_available = thread::spawn(move || lock_b.try_write().unwrap().is_some())
439            .join()
440            .unwrap();
441        assert!(now_available, "available after the outermost guard drops");
442    }
443
444    #[test]
445    fn moved_guard_handoff_changes_the_reentrant_owner() {
446        let temp = TempDir::new().unwrap();
447        let lock = RepoLock::new(temp.path());
448        let guard = lock.write().unwrap();
449        let original_owner = thread::current().id();
450        let entry = entry_for(lock.registry_key());
451        let (ready_tx, ready_rx) = mpsc::channel();
452        let (release_tx, release_rx) = mpsc::channel();
453
454        let worker = thread::spawn(move || {
455            let mut guard = guard;
456            guard.handoff_to_current_thread().unwrap();
457            ready_tx.send(thread::current().id()).unwrap();
458            release_rx.recv().unwrap();
459            drop(guard);
460        });
461
462        let worker_owner = ready_rx.recv().unwrap();
463        assert_ne!(worker_owner, original_owner);
464        assert_eq!(lock_gate(&entry).owner, Some(worker_owner));
465        assert!(
466            lock.try_write().unwrap().is_none(),
467            "the original thread must not re-enter a guard owned by the worker"
468        );
469
470        release_tx.send(()).unwrap();
471        worker.join().unwrap();
472        assert!(lock.try_write().unwrap().is_some());
473    }
474
475    /// `try_write` is intentionally NON-reentrant: even the thread that already
476    /// holds the write lock gets `None`, not a nested guard. Reentrancy exists
477    /// only so the BLOCKING `write()` can't self-deadlock on its own `flock`; a
478    /// non-blocking `try_*` can never deadlock, and callers use it to DETECT
479    /// contention (the undo/redo serialization lock, heddle#355), so it must
480    /// report "held" regardless of holder. Do NOT "fix" this to mirror
481    /// `write()`'s reentrancy.
482    #[test]
483    fn try_write_is_non_reentrant_even_for_owner() {
484        let temp = TempDir::new().unwrap();
485        let lock = RepoLock::new(temp.path());
486
487        let _held = lock.write().unwrap();
488        assert!(
489            lock.try_write().unwrap().is_none(),
490            "try_write must report contention even for the lock's own owner thread"
491        );
492    }
493}