Skip to main content

pulsedb/watch/
lock.rs

1//! Advisory file locking for cross-process writer detection.
2//!
3//! Provides a lightweight mechanism for reader processes to detect whether
4//! a writer process currently has the database open. This is complementary
5//! to redb's own lock — it uses a separate lock file specifically for
6//! cross-process watch coordination.
7//!
8//! # Lock File
9//!
10//! The lock file is created at `{db_path}.watch.lock`. The writer holds
11//! an exclusive lock; readers hold shared locks.
12
13use std::fs::{File, OpenOptions};
14use std::io;
15use std::path::{Path, PathBuf};
16
17// Import lock methods but not unlock — unlock requires Rust 1.89+ (above our MSRV).
18// File drop releases the lock on all platforms.
19use fs2::FileExt;
20
21/// Advisory file lock for cross-process watch coordination.
22///
23/// The writer process acquires an exclusive lock, and reader processes
24/// can check whether a writer is active before polling for changes.
25///
26/// The lock is automatically released when this struct is dropped.
27///
28/// # Example
29///
30/// ```rust
31/// # fn main() -> pulsedb::Result<()> {
32/// # let dir = tempfile::tempdir().unwrap();
33/// # let db_path = dir.path().join("test.db");
34/// # let db = pulsedb::PulseDB::open(&db_path, pulsedb::Config::default())?;
35/// use pulsedb::WatchLock;
36///
37/// // Writer process
38/// let lock = WatchLock::acquire_exclusive(&db_path)?;
39/// // ... write experiences ...
40/// drop(lock); // releases lock
41///
42/// // Reader process
43/// if WatchLock::is_writer_active(&db_path) {
44///     let seq = db.get_current_sequence()?;
45///     let (events, _new_seq) = db.poll_changes(seq)?;
46///     # let _ = events;
47/// }
48/// # Ok(())
49/// # }
50/// ```
51pub struct WatchLock {
52    /// The locked file handle. Lock is held as long as this exists.
53    _file: File,
54
55    /// Path to the lock file (for Display/Debug).
56    path: PathBuf,
57}
58
59impl WatchLock {
60    /// Returns the lock file path for a given database path.
61    fn lock_path(db_path: &Path) -> PathBuf {
62        let mut lock_path = db_path.as_os_str().to_owned();
63        lock_path.push(".watch.lock");
64        PathBuf::from(lock_path)
65    }
66
67    /// Opens or creates the lock file.
68    fn open_lock_file(db_path: &Path) -> io::Result<(File, PathBuf)> {
69        let path = Self::lock_path(db_path);
70        let file = OpenOptions::new()
71            .read(true)
72            .write(true)
73            .create(true)
74            .truncate(false)
75            .open(&path)?;
76        Ok((file, path))
77    }
78
79    /// Acquires an exclusive lock (for the writer process).
80    ///
81    /// Blocks until the lock is available. Only one exclusive lock can
82    /// be held at a time.
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if the lock file cannot be created or locked.
87    pub fn acquire_exclusive(db_path: &Path) -> io::Result<Self> {
88        let (file, path) = Self::open_lock_file(db_path)?;
89        file.lock_exclusive()?;
90        Ok(Self { _file: file, path })
91    }
92
93    /// Acquires a shared lock (for reader processes).
94    ///
95    /// Multiple shared locks can coexist. A shared lock blocks exclusive
96    /// locks from being acquired.
97    ///
98    /// # Errors
99    ///
100    /// Returns an error if the lock file cannot be created or locked.
101    pub fn acquire_shared(db_path: &Path) -> io::Result<Self> {
102        let (file, path) = Self::open_lock_file(db_path)?;
103        file.lock_shared()?;
104        Ok(Self { _file: file, path })
105    }
106
107    /// Tries to acquire an exclusive lock without blocking.
108    ///
109    /// Returns `Ok(Some(lock))` if acquired, `Ok(None)` if another
110    /// process holds the lock.
111    pub fn try_exclusive(db_path: &Path) -> io::Result<Option<Self>> {
112        let (file, path) = Self::open_lock_file(db_path)?;
113        match file.try_lock_exclusive() {
114            Ok(()) => Ok(Some(Self { _file: file, path })),
115            Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(None),
116            // fs2 on some platforms returns Other instead of WouldBlock
117            Err(e) if e.raw_os_error().is_some() => Ok(None),
118            Err(e) => Err(e),
119        }
120    }
121
122    /// Checks whether a writer currently holds the exclusive lock.
123    ///
124    /// This is a non-blocking check. Returns `true` if an exclusive lock
125    /// is held (writer is active), `false` otherwise.
126    ///
127    /// Note: This has a TOCTOU race — the writer could start or stop
128    /// between this check and any subsequent action. Use it as a hint,
129    /// not a guarantee.
130    pub fn is_writer_active(db_path: &Path) -> bool {
131        let (file, _path) = match Self::open_lock_file(db_path) {
132            Ok(f) => f,
133            Err(_) => return false,
134        };
135        // If we can get an exclusive lock, no writer is active.
136        // Dropping the file handle releases the lock.
137        match file.try_lock_exclusive() {
138            Ok(()) => {
139                // We got it — no writer. Drop releases the lock.
140                drop(file);
141                false
142            }
143            Err(_) => {
144                // Lock is held — writer is active
145                true
146            }
147        }
148    }
149
150    /// Returns the path to the lock file.
151    pub fn path(&self) -> &Path {
152        &self.path
153    }
154}
155
156// No explicit Drop needed — dropping the File handle closes it, which
157// releases the advisory lock on all platforms (POSIX and Windows).
158
159impl std::fmt::Debug for WatchLock {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.debug_struct("WatchLock")
162            .field("path", &self.path)
163            .finish()
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use tempfile::tempdir;
171
172    fn test_db_path() -> (tempfile::TempDir, PathBuf) {
173        let dir = tempdir().unwrap();
174        let db_path = dir.path().join("test.db");
175        (dir, db_path)
176    }
177
178    #[test]
179    fn test_exclusive_lock_acquired() {
180        let (_dir, db_path) = test_db_path();
181        let lock = WatchLock::acquire_exclusive(&db_path).unwrap();
182        assert!(lock.path().exists());
183    }
184
185    #[test]
186    fn test_shared_lock_acquired() {
187        let (_dir, db_path) = test_db_path();
188        let lock = WatchLock::acquire_shared(&db_path).unwrap();
189        assert!(lock.path().exists());
190    }
191
192    #[test]
193    fn test_multiple_shared_locks() {
194        let (_dir, db_path) = test_db_path();
195        let _lock1 = WatchLock::acquire_shared(&db_path).unwrap();
196        let _lock2 = WatchLock::acquire_shared(&db_path).unwrap();
197        // Both held simultaneously — should not deadlock
198    }
199
200    #[test]
201    fn test_exclusive_blocks_second_exclusive() {
202        let (_dir, db_path) = test_db_path();
203        let _lock = WatchLock::acquire_exclusive(&db_path).unwrap();
204        // try_exclusive should fail (lock is held)
205        let result = WatchLock::try_exclusive(&db_path).unwrap();
206        assert!(result.is_none());
207    }
208
209    #[test]
210    fn test_is_writer_active_when_locked() {
211        let (_dir, db_path) = test_db_path();
212        let _lock = WatchLock::acquire_exclusive(&db_path).unwrap();
213        assert!(WatchLock::is_writer_active(&db_path));
214    }
215
216    #[test]
217    fn test_is_writer_not_active_when_unlocked() {
218        let (_dir, db_path) = test_db_path();
219        // Create then drop lock
220        {
221            let _lock = WatchLock::acquire_exclusive(&db_path).unwrap();
222        }
223        assert!(!WatchLock::is_writer_active(&db_path));
224    }
225
226    #[test]
227    fn test_is_writer_active_no_lock_file() {
228        let dir = tempdir().unwrap();
229        let db_path = dir.path().join("nonexistent.db");
230        // No lock file exists — should return false (not panic)
231        assert!(!WatchLock::is_writer_active(&db_path));
232    }
233
234    #[test]
235    fn test_lock_released_on_drop() {
236        let (_dir, db_path) = test_db_path();
237        {
238            let _lock = WatchLock::acquire_exclusive(&db_path).unwrap();
239        }
240        // After drop, we should be able to acquire again
241        let lock = WatchLock::try_exclusive(&db_path).unwrap();
242        assert!(lock.is_some());
243    }
244}