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}