Skip to main content

agent_graph_mcp/
owner_lock.rs

1//! Process-lifetime exclusive ownership of a durable data directory.
2//!
3//! Uses `fs2` for safe file locking without `unsafe` code.
4//! The lock is automatically released when the owning process exits
5//! or when the `OwnerLock` is dropped.
6
7use fs2::FileExt;
8use std::fs::{File, OpenOptions};
9use std::io;
10use std::os::unix::fs::OpenOptionsExt;
11use std::path::{Path, PathBuf};
12
13/// Error message when the data directory is already owned by another process.
14pub const DATA_DIR_ALREADY_OWNED: &str =
15    "DATA_DIR_ALREADY_OWNED: another process owns this data directory";
16
17/// A process-lifetime exclusive lock on a data directory.
18///
19/// The lock file is created at `{data_dir}/.owner.lock` with mode 0600.
20/// The lock is held for the lifetime of this struct (RAII).
21/// When dropped, the file handle is closed which automatically releases
22/// the advisory lock.
23#[derive(Debug)]
24pub struct OwnerLock {
25    _file: File,
26    pub path: PathBuf,
27}
28
29impl OwnerLock {
30    /// Attempt to acquire an exclusive, non-blocking lock on the data directory.
31    ///
32    /// Returns `Err(DATA_DIR_ALREADY_OWNED)` if another process already holds the lock.
33    /// No database files are opened or modified during lock acquisition.
34    pub fn acquire(data_dir: &Path) -> io::Result<Self> {
35        // Ensure the directory exists before creating the lock file
36        std::fs::create_dir_all(data_dir)?;
37
38        let path = data_dir.join(".owner.lock");
39        let file = OpenOptions::new()
40            .create(true)
41            .read(true)
42            .write(true)
43            .mode(0o600)
44            .open(&path)?;
45
46        // Try non-blocking exclusive lock
47        match file.try_lock_exclusive() {
48            Ok(()) => Ok(Self { _file: file, path }),
49            Err(_) => Err(io::Error::new(
50                io::ErrorKind::AlreadyExists,
51                DATA_DIR_ALREADY_OWNED,
52            )),
53        }
54    }
55}
56
57impl Drop for OwnerLock {
58    fn drop(&mut self) {
59        // fs2 unlocks automatically when the File is dropped.
60        // The _file field will be dropped after this returns,
61        // releasing the lock.
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use tempfile::tempdir;
69
70    #[test]
71    fn acquire_and_reacquire_after_drop() {
72        let dir = tempdir().unwrap();
73        let lock1 = OwnerLock::acquire(dir.path());
74        assert!(lock1.is_ok(), "first acquire should succeed");
75
76        // Drop the lock
77        drop(lock1);
78
79        // Should be able to reacquire
80        let lock2 = OwnerLock::acquire(dir.path());
81        assert!(lock2.is_ok(), "reacquire after drop should succeed");
82    }
83
84    #[test]
85    fn second_acquire_fails() {
86        let dir = tempdir().unwrap();
87        let _lock1 = OwnerLock::acquire(dir.path()).expect("first acquire");
88
89        let lock2 = OwnerLock::acquire(dir.path());
90        assert!(lock2.is_err(), "second concurrent acquire should fail");
91        let err = lock2.unwrap_err();
92        assert!(
93            err.to_string().contains("ALREADY_OWNED"),
94            "error should mention ALREADY_OWNED"
95        );
96    }
97
98    #[test]
99    fn lock_file_has_private_permissions() {
100        let dir = tempdir().unwrap();
101        let _lock = OwnerLock::acquire(dir.path()).expect("acquire");
102
103        let meta = std::fs::metadata(dir.path().join(".owner.lock")).unwrap();
104        use std::os::unix::fs::PermissionsExt;
105        let mode = meta.permissions().mode() & 0o777;
106        assert_eq!(
107            mode, 0o600,
108            "lock file should have 0600 permissions, got 0o{:o}",
109            mode
110        );
111    }
112}