Skip to main content

ralph_core/
file_lock.rs

1//! File locking for shared resources in multi-loop scenarios.
2//!
3//! Provides fine-grained file locking using `flock()` for concurrent access
4//! to shared resources like `.ralph/agent/tasks.jsonl` and `.ralph/agent/memories.md`.
5//! This enables multiple Ralph loops (in worktrees) to safely read and write
6//! shared state files.
7//!
8//! # Design
9//!
10//! - **Shared locks** for reading: Multiple readers can hold shared locks simultaneously
11//! - **Exclusive locks** for writing: Only one writer at a time, blocks readers
12//! - **Blocking by default**: Operations wait for lock availability
13//! - **RAII guards**: Locks are automatically released when guards are dropped
14//!
15//! # Example
16//!
17//! ```no_run
18//! use ralph_core::file_lock::FileLock;
19//!
20//! fn read_tasks(path: &std::path::Path) -> std::io::Result<String> {
21//!     let lock = FileLock::new(path)?;
22//!     let _guard = lock.shared()?;  // Acquire shared lock
23//!     std::fs::read_to_string(path)
24//! }
25//!
26//! fn write_tasks(path: &std::path::Path, content: &str) -> std::io::Result<()> {
27//!     let lock = FileLock::new(path)?;
28//!     let _guard = lock.exclusive()?;  // Acquire exclusive lock
29//!     std::fs::write(path, content)
30//! }
31//! ```
32
33use std::fs::{File, OpenOptions};
34use std::io;
35use std::path::{Path, PathBuf};
36
37/// A file lock for coordinating concurrent access to shared files.
38///
39/// Uses a `.lock` file alongside the target file for locking.
40/// This avoids issues with locking the target file directly
41/// (which can interfere with truncation/replacement).
42#[derive(Debug)]
43pub struct FileLock {
44    /// Path to the lock file.
45    lock_path: PathBuf,
46}
47
48impl FileLock {
49    /// Creates a new file lock for the given path.
50    ///
51    /// The lock file is created at `{path}.lock`.
52    /// The parent directory is created if it doesn't exist.
53    pub fn new(path: impl AsRef<Path>) -> io::Result<Self> {
54        let path = path.as_ref();
55        let lock_path = path.with_extension(format!(
56            "{}.lock",
57            path.extension()
58                .map(|s| s.to_string_lossy().to_string())
59                .unwrap_or_default()
60        ));
61
62        // Ensure parent directory exists
63        if let Some(parent) = lock_path.parent() {
64            std::fs::create_dir_all(parent)?;
65        }
66
67        Ok(Self { lock_path })
68    }
69
70    /// Acquires a shared (read) lock.
71    ///
72    /// Multiple processes can hold shared locks simultaneously.
73    /// Blocks until the lock is available.
74    pub fn shared(&self) -> io::Result<LockGuard> {
75        self.acquire(LockType::Shared)
76    }
77
78    /// Tries to acquire a shared (read) lock without blocking.
79    ///
80    /// Returns `Ok(None)` if the lock is not available.
81    pub fn try_shared(&self) -> io::Result<Option<LockGuard>> {
82        self.try_acquire(LockType::Shared)
83    }
84
85    /// Acquires an exclusive (write) lock.
86    ///
87    /// Only one process can hold an exclusive lock.
88    /// Blocks until the lock is available.
89    pub fn exclusive(&self) -> io::Result<LockGuard> {
90        self.acquire(LockType::Exclusive)
91    }
92
93    /// Tries to acquire an exclusive (write) lock without blocking.
94    ///
95    /// Returns `Ok(None)` if the lock is not available.
96    pub fn try_exclusive(&self) -> io::Result<Option<LockGuard>> {
97        self.try_acquire(LockType::Exclusive)
98    }
99
100    /// Acquires a lock of the specified type (blocking).
101    fn acquire(&self, lock_type: LockType) -> io::Result<LockGuard> {
102        let file = self.open_lock_file()?;
103
104        #[cfg(unix)]
105        {
106            use nix::fcntl::{Flock, FlockArg};
107
108            let arg = match lock_type {
109                LockType::Shared => FlockArg::LockShared,
110                LockType::Exclusive => FlockArg::LockExclusive,
111            };
112
113            match Flock::lock(file, arg) {
114                Ok(flock) => Ok(LockGuard {
115                    _flock: flock,
116                    _lock_type: lock_type,
117                }),
118                Err((_, errno)) => Err(io::Error::new(
119                    io::ErrorKind::Other,
120                    format!("flock failed: {}", errno),
121                )),
122            }
123        }
124
125        #[cfg(not(unix))]
126        {
127            let _ = (file, lock_type);
128            Err(io::Error::new(
129                io::ErrorKind::Unsupported,
130                "File locking not supported on this platform",
131            ))
132        }
133    }
134
135    /// Tries to acquire a lock of the specified type (non-blocking).
136    fn try_acquire(&self, lock_type: LockType) -> io::Result<Option<LockGuard>> {
137        let file = self.open_lock_file()?;
138
139        #[cfg(unix)]
140        {
141            use nix::errno::Errno;
142            use nix::fcntl::{Flock, FlockArg};
143
144            let arg = match lock_type {
145                LockType::Shared => FlockArg::LockSharedNonblock,
146                LockType::Exclusive => FlockArg::LockExclusiveNonblock,
147            };
148
149            match Flock::lock(file, arg) {
150                Ok(flock) => Ok(Some(LockGuard {
151                    _flock: flock,
152                    _lock_type: lock_type,
153                })),
154                Err((_, errno)) if errno == Errno::EWOULDBLOCK || errno == Errno::EAGAIN => {
155                    Ok(None)
156                }
157                Err((_, errno)) => Err(io::Error::new(
158                    io::ErrorKind::Other,
159                    format!("flock failed: {}", errno),
160                )),
161            }
162        }
163
164        #[cfg(not(unix))]
165        {
166            let _ = (file, lock_type);
167            Err(io::Error::new(
168                io::ErrorKind::Unsupported,
169                "File locking not supported on this platform",
170            ))
171        }
172    }
173
174    /// Opens or creates the lock file.
175    fn open_lock_file(&self) -> io::Result<File> {
176        OpenOptions::new()
177            .read(true)
178            .write(true)
179            .create(true)
180            .truncate(false)
181            .open(&self.lock_path)
182    }
183
184    /// Returns the path to the lock file.
185    pub fn lock_path(&self) -> &Path {
186        &self.lock_path
187    }
188}
189
190/// Type of lock to acquire.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192enum LockType {
193    /// Shared (read) lock - multiple holders allowed.
194    Shared,
195    /// Exclusive (write) lock - single holder only.
196    Exclusive,
197}
198
199/// A guard that holds the file lock. The lock is released when dropped.
200#[derive(Debug)]
201pub struct LockGuard {
202    /// The flock guard (Unix only).
203    #[cfg(unix)]
204    _flock: nix::fcntl::Flock<File>,
205
206    /// The type of lock held.
207    _lock_type: LockType,
208}
209
210/// A locked file that provides safe read/write access.
211///
212/// This is a convenience wrapper that combines file locking with
213/// read/write operations in a single API.
214pub struct LockedFile {
215    lock: FileLock,
216}
217
218impl LockedFile {
219    /// Creates a new locked file handle for the given path.
220    pub fn new(path: impl AsRef<Path>) -> io::Result<Self> {
221        Ok(Self {
222            lock: FileLock::new(path)?,
223        })
224    }
225
226    /// Reads the file contents with a shared lock.
227    ///
228    /// If the file doesn't exist, returns an empty string.
229    pub fn read(&self, path: &Path) -> io::Result<String> {
230        let _guard = self.lock.shared()?;
231        if path.exists() {
232            std::fs::read_to_string(path)
233        } else {
234            Ok(String::new())
235        }
236    }
237
238    /// Writes content to the file with an exclusive lock.
239    pub fn write(&self, path: &Path, content: &str) -> io::Result<()> {
240        let _guard = self.lock.exclusive()?;
241        // Ensure parent directory exists
242        if let Some(parent) = path.parent() {
243            std::fs::create_dir_all(parent)?;
244        }
245        std::fs::write(path, content)
246    }
247
248    /// Executes a read operation with a shared lock.
249    pub fn with_shared_lock<T, F>(&self, f: F) -> io::Result<T>
250    where
251        F: FnOnce() -> io::Result<T>,
252    {
253        let _guard = self.lock.shared()?;
254        f()
255    }
256
257    /// Executes a write operation with an exclusive lock.
258    pub fn with_exclusive_lock<T, F>(&self, f: F) -> io::Result<T>
259    where
260        F: FnOnce() -> io::Result<T>,
261    {
262        let _guard = self.lock.exclusive()?;
263        f()
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use std::sync::{Arc, Barrier};
271    use std::thread;
272    use std::time::{Duration, Instant};
273    use tempfile::TempDir;
274
275    #[test]
276    fn test_lock_file_path() {
277        let temp_dir = TempDir::new().unwrap();
278        let file_path = temp_dir.path().join("test.jsonl");
279
280        let lock = FileLock::new(&file_path).unwrap();
281        assert_eq!(lock.lock_path(), temp_dir.path().join("test.jsonl.lock"));
282    }
283
284    #[test]
285    fn test_lock_file_path_no_extension() {
286        let temp_dir = TempDir::new().unwrap();
287        let file_path = temp_dir.path().join("tasks");
288
289        let lock = FileLock::new(&file_path).unwrap();
290        assert_eq!(lock.lock_path(), temp_dir.path().join("tasks..lock"));
291    }
292
293    #[test]
294    fn test_shared_lock_acquired() {
295        let temp_dir = TempDir::new().unwrap();
296        let file_path = temp_dir.path().join("test.txt");
297
298        let lock = FileLock::new(&file_path).unwrap();
299        let guard = lock.shared();
300        assert!(guard.is_ok());
301    }
302
303    #[test]
304    fn test_exclusive_lock_acquired() {
305        let temp_dir = TempDir::new().unwrap();
306        let file_path = temp_dir.path().join("test.txt");
307
308        let lock = FileLock::new(&file_path).unwrap();
309        let guard = lock.exclusive();
310        assert!(guard.is_ok());
311    }
312
313    #[test]
314    fn test_multiple_shared_locks() {
315        // Multiple shared locks should be allowed
316        let temp_dir = TempDir::new().unwrap();
317        let file_path = temp_dir.path().join("test.txt");
318
319        let lock1 = FileLock::new(&file_path).unwrap();
320        let lock2 = FileLock::new(&file_path).unwrap();
321
322        let _guard1 = lock1.shared().unwrap();
323        let guard2 = lock2.try_shared();
324
325        // Should be able to acquire second shared lock
326        assert!(guard2.is_ok());
327        assert!(guard2.unwrap().is_some());
328    }
329
330    #[test]
331    fn test_exclusive_blocks_shared() {
332        // Exclusive lock should block new shared locks
333        let temp_dir = TempDir::new().unwrap();
334        let file_path = temp_dir.path().join("test.txt");
335
336        let lock1 = FileLock::new(&file_path).unwrap();
337        let lock2 = FileLock::new(&file_path).unwrap();
338
339        let _guard1 = lock1.exclusive().unwrap();
340        let guard2 = lock2.try_shared();
341
342        // Should not be able to acquire shared lock while exclusive is held
343        assert!(guard2.is_ok());
344        assert!(guard2.unwrap().is_none());
345    }
346
347    #[test]
348    fn test_shared_blocks_exclusive() {
349        // Shared lock should block exclusive locks
350        let temp_dir = TempDir::new().unwrap();
351        let file_path = temp_dir.path().join("test.txt");
352
353        let lock1 = FileLock::new(&file_path).unwrap();
354        let lock2 = FileLock::new(&file_path).unwrap();
355
356        let _guard1 = lock1.shared().unwrap();
357        let guard2 = lock2.try_exclusive();
358
359        // Should not be able to acquire exclusive lock while shared is held
360        assert!(guard2.is_ok());
361        assert!(guard2.unwrap().is_none());
362    }
363
364    #[test]
365    fn test_lock_released_on_drop() {
366        let temp_dir = TempDir::new().unwrap();
367        let file_path = temp_dir.path().join("test.txt");
368
369        let lock1 = FileLock::new(&file_path).unwrap();
370        let lock2 = FileLock::new(&file_path).unwrap();
371
372        {
373            let _guard1 = lock1.exclusive().unwrap();
374            // Lock is held
375        }
376        // Guard dropped, lock released
377
378        // Should be able to acquire exclusive lock now
379        let guard2 = lock2.try_exclusive();
380        assert!(guard2.is_ok());
381        assert!(guard2.unwrap().is_some());
382    }
383
384    #[test]
385    fn test_locked_file_read_write() {
386        let temp_dir = TempDir::new().unwrap();
387        let file_path = temp_dir.path().join("test.txt");
388
389        let locked = LockedFile::new(&file_path).unwrap();
390
391        // Write content
392        locked.write(&file_path, "Hello, World!").unwrap();
393
394        // Read content
395        let content = locked.read(&file_path).unwrap();
396        assert_eq!(content, "Hello, World!");
397    }
398
399    #[test]
400    fn test_locked_file_read_nonexistent() {
401        let temp_dir = TempDir::new().unwrap();
402        let file_path = temp_dir.path().join("nonexistent.txt");
403
404        let locked = LockedFile::new(&file_path).unwrap();
405        let content = locked.read(&file_path).unwrap();
406        assert!(content.is_empty());
407    }
408
409    #[test]
410    fn test_concurrent_writes_serialized() {
411        // Test that concurrent writes are properly serialized
412        let temp_dir = TempDir::new().unwrap();
413        let file_path = temp_dir.path().join("counter.txt");
414        let file_path_clone = file_path.clone();
415
416        // Initialize file
417        std::fs::write(&file_path, "0").unwrap();
418
419        let barrier = Arc::new(Barrier::new(2));
420        let barrier_clone = barrier.clone();
421
422        let handle1 = thread::spawn(move || {
423            let locked = LockedFile::new(&file_path).unwrap();
424            barrier.wait();
425
426            locked
427                .with_exclusive_lock(|| {
428                    let content = std::fs::read_to_string(&file_path)?;
429                    let n: i32 = content.trim().parse().unwrap_or(0);
430                    // Small delay to increase chance of race condition without lock
431                    thread::sleep(Duration::from_millis(10));
432                    std::fs::write(&file_path, format!("{}", n + 1))
433                })
434                .unwrap();
435        });
436
437        let handle2 = thread::spawn(move || {
438            let locked = LockedFile::new(&file_path_clone).unwrap();
439            barrier_clone.wait();
440
441            locked
442                .with_exclusive_lock(|| {
443                    let content = std::fs::read_to_string(&file_path_clone)?;
444                    let n: i32 = content.trim().parse().unwrap_or(0);
445                    thread::sleep(Duration::from_millis(10));
446                    std::fs::write(&file_path_clone, format!("{}", n + 1))
447                })
448                .unwrap();
449        });
450
451        handle1.join().unwrap();
452        handle2.join().unwrap();
453
454        // With proper locking, final value should be 2
455        let final_content = std::fs::read_to_string(temp_dir.path().join("counter.txt")).unwrap();
456        assert_eq!(final_content.trim(), "2");
457    }
458
459    #[test]
460    fn test_blocking_lock_waits() {
461        let temp_dir = TempDir::new().unwrap();
462        let file_path = temp_dir.path().join("wait.txt");
463        let file_path_clone = file_path.clone();
464
465        let barrier = Arc::new(Barrier::new(2));
466        let barrier_clone = barrier.clone();
467
468        let handle1 = thread::spawn(move || {
469            let lock = FileLock::new(&file_path).unwrap();
470            let _guard = lock.exclusive().unwrap();
471
472            barrier.wait();
473            // Hold lock for a bit
474            thread::sleep(Duration::from_millis(50));
475            // Guard dropped here
476        });
477
478        let start = Instant::now();
479        let handle2 = thread::spawn(move || {
480            let lock = FileLock::new(&file_path_clone).unwrap();
481            barrier_clone.wait();
482
483            // This should block until handle1 releases the lock
484            let _guard = lock.exclusive().unwrap();
485        });
486
487        handle1.join().unwrap();
488        handle2.join().unwrap();
489
490        // Second thread should have waited
491        assert!(start.elapsed() >= Duration::from_millis(40));
492    }
493}