Skip to main content

agent_teams/util/
file_lock.rs

1//! Cross-platform file locking via `fs2`.
2//!
3//! Provides exclusive file locks that work on both Unix (`flock(2)`) and
4//! Windows (`LockFileEx`). Uses the `fs2` crate which abstracts over
5//! platform-specific locking APIs.
6//!
7//! Mirrors the Python `fcntl.flock(fd, LOCK_EX)` pattern used in
8//! `claude-code-teams-mcp` for concurrent file access protection.
9//!
10//! # Platform notes
11//!
12//! - **macOS / Linux (local FS)**: Reliable advisory locks via `flock(2)`.
13//! - **Windows**: Uses `LockFileEx` kernel API, mandatory locks.
14//! - **NFS / CIFS / network filesystems**: `flock(2)` behavior is
15//!   implementation-dependent and may silently succeed without actual locking.
16//!   If the teams base directory resides on a network filesystem, consider
17//!   using a distributed lock service instead.
18
19use std::fs::{File, OpenOptions};
20use std::path::{Path, PathBuf};
21
22use fs2::FileExt;
23
24use crate::error::{Error, Result};
25
26/// An exclusive file lock (RAII guard).
27///
28/// The lock is released when this value is dropped (the underlying file
29/// descriptor / handle is closed, which releases the lock).
30pub struct FileLock {
31    file: File,
32    path: PathBuf,
33}
34
35impl FileLock {
36    /// Acquire an exclusive lock on `path`, blocking until available.
37    ///
38    /// If the lock file does not exist, it is created.
39    pub fn acquire(path: &Path) -> Result<Self> {
40        let file = OpenOptions::new()
41            .create(true)
42            .truncate(false)
43            .write(true)
44            .open(path)
45            .map_err(|e| Error::LockFailed {
46                path: path.to_path_buf(),
47                reason: e.to_string(),
48            })?;
49
50        file.lock_exclusive().map_err(|e| Error::LockFailed {
51            path: path.to_path_buf(),
52            reason: e.to_string(),
53        })?;
54
55        Ok(Self {
56            file,
57            path: path.to_path_buf(),
58        })
59    }
60
61    /// Try to acquire an exclusive lock without blocking.
62    ///
63    /// Returns `Ok(None)` if the lock is held by another process.
64    pub fn try_acquire(path: &Path) -> Result<Option<Self>> {
65        let file = OpenOptions::new()
66            .create(true)
67            .truncate(false)
68            .write(true)
69            .open(path)
70            .map_err(|e| Error::LockFailed {
71                path: path.to_path_buf(),
72                reason: e.to_string(),
73            })?;
74
75        match file.try_lock_exclusive() {
76            Ok(()) => Ok(Some(Self {
77                file,
78                path: path.to_path_buf(),
79            })),
80            Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None),
81            // fs2 on macOS returns EAGAIN as ErrorKind::Other instead of WouldBlock.
82            // EAGAIN = 35 on macOS, 11 on Linux (same as EWOULDBLOCK on both).
83            Err(ref e)
84                if e.raw_os_error()
85                    .is_some_and(|code| code == 11 || code == 35) =>
86            {
87                Ok(None)
88            }
89            Err(e) => Err(Error::LockFailed {
90                path: path.to_path_buf(),
91                reason: e.to_string(),
92            }),
93        }
94    }
95
96    /// Path of the lock file.
97    pub fn path(&self) -> &Path {
98        &self.path
99    }
100}
101
102impl Drop for FileLock {
103    fn drop(&mut self) {
104        // Explicitly unlock before the fd/handle is closed.
105        let _ = self.file.unlock();
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn acquire_and_release() {
115        let dir = tempfile::tempdir().unwrap();
116        let lock_path = dir.path().join("test.lock");
117
118        let lock = FileLock::acquire(&lock_path).unwrap();
119        assert!(lock_path.exists());
120        drop(lock);
121    }
122
123    #[test]
124    fn try_acquire_non_blocking() {
125        let dir = tempfile::tempdir().unwrap();
126        let lock_path = dir.path().join("test.lock");
127
128        let lock1 = FileLock::try_acquire(&lock_path).unwrap();
129        assert!(lock1.is_some());
130
131        // Release and re-acquire to verify the API works.
132        drop(lock1);
133
134        let lock2 = FileLock::try_acquire(&lock_path).unwrap();
135        assert!(lock2.is_some());
136    }
137
138    #[test]
139    fn lock_path_accessor() {
140        let dir = tempfile::tempdir().unwrap();
141        let lock_path = dir.path().join("test.lock");
142
143        let lock = FileLock::acquire(&lock_path).unwrap();
144        assert_eq!(lock.path(), lock_path);
145    }
146}