1use std::collections::HashSet;
31use std::fs::{File, OpenOptions};
32use std::path::{Path, PathBuf};
33use std::sync::{Mutex, OnceLock};
34use std::time::{Duration, Instant};
35
36use crate::error::SqliteStoreError;
37
38fn held_exclusive_locks() -> &'static Mutex<HashSet<PathBuf>> {
47 static REGISTRY: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
48 REGISTRY.get_or_init(|| Mutex::new(HashSet::new()))
49}
50
51fn registry_key(lock_path: &Path) -> PathBuf {
52 std::fs::canonicalize(lock_path).unwrap_or_else(|_| lock_path.to_path_buf())
53}
54
55fn process_holds_exclusive(lock_path: &Path) -> bool {
56 held_exclusive_locks()
57 .lock()
58 .map(|held| held.contains(®istry_key(lock_path)))
59 .unwrap_or(false)
60}
61
62pub const FENCE_LOCK_SUFFIX: &str = "mfence";
64
65pub fn fence_lock_path(db_path: &Path) -> PathBuf {
67 let mut name = db_path.file_name().unwrap_or_default().to_os_string();
68 name.push(".");
69 name.push(FENCE_LOCK_SUFFIX);
70 db_path.with_file_name(name)
71}
72
73fn open_lock_file(lock_path: &Path) -> Option<File> {
74 OpenOptions::new()
77 .read(true)
78 .write(true)
79 .create(true)
80 .truncate(false)
81 .open(lock_path)
82 .ok()
83}
84
85#[derive(Debug)]
89pub struct OperationGuard {
90 _lock: Option<File>,
91}
92
93impl OperationGuard {
94 pub fn try_for_database_strict(db_path: &Path) -> Result<Self, SqliteStoreError> {
98 if db_path.file_name().is_none() || db_path.as_os_str() == ":memory:" {
99 return Err(SqliteStoreError::Io(std::io::Error::new(
100 std::io::ErrorKind::InvalidInput,
101 "strict operation custody requires an existing physical database",
102 )));
103 }
104 crate::profile::validate_database_file(db_path)?;
105 if !db_path.is_file() {
106 return Err(SqliteStoreError::Io(std::io::Error::new(
107 std::io::ErrorKind::NotFound,
108 "strict operation database is missing",
109 )));
110 }
111 #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
112 {
113 Err(SqliteStoreError::Io(std::io::Error::new(
114 std::io::ErrorKind::Unsupported,
115 "strict operation custody requires descriptor-scoped locking",
116 )))
117 }
118 #[cfg(any(target_os = "macos", target_os = "linux", windows))]
119 {
120 let path = fence_lock_path(db_path);
121 crate::profile::validate_database_file(&path)?;
122 let file = OpenOptions::new()
123 .read(true)
124 .write(true)
125 .create(true)
126 .truncate(false)
127 .open(&path)?;
128 crate::profile::validate_database_file(&path)?;
129 if !file.metadata()?.is_file() {
130 return Err(SqliteStoreError::Io(std::io::Error::new(
131 std::io::ErrorKind::Unsupported,
132 "strict operation lock is not a regular file",
133 )));
134 }
135 match file.try_lock_shared() {
136 Ok(()) => Ok(Self { _lock: Some(file) }),
137 Err(error) if is_would_block(&error) => {
138 Err(SqliteStoreError::MaintenanceFenceHeld {
139 path: db_path.to_path_buf(),
140 })
141 }
142 Err(error) => Err(SqliteStoreError::Io(std::io::Error::other(error))),
143 }
144 }
145 }
146
147 pub fn for_database(db_path: &Path) -> Result<Self, SqliteStoreError> {
153 if db_path.file_name().is_none() || db_path.as_os_str() == ":memory:" {
154 return Ok(Self { _lock: None });
155 }
156 crate::profile::validate_database_file(db_path)?;
158 let lock_path = fence_lock_path(db_path);
159 if process_holds_exclusive(&lock_path) {
160 return Ok(Self { _lock: None });
162 }
163 let Some(file) = open_lock_file(&lock_path) else {
164 return Ok(Self { _lock: None });
165 };
166 match file.try_lock_shared() {
167 Ok(()) => Ok(Self { _lock: Some(file) }),
168 Err(err) if is_would_block(&err) => Err(SqliteStoreError::MaintenanceFenceHeld {
169 path: db_path.to_path_buf(),
170 }),
171 Err(_) => Ok(Self { _lock: None }),
173 }
174 }
175
176 pub fn admit_missing_database(db_path: &Path) -> Result<(), SqliteStoreError> {
185 if db_path.file_name().is_none() || db_path.as_os_str() == ":memory:" {
186 return Ok(());
187 }
188 let lock_path = fence_lock_path(db_path);
189 if process_holds_exclusive(&lock_path) {
190 return Ok(());
191 }
192 let Ok(file) = OpenOptions::new().read(true).write(true).open(&lock_path) else {
193 return Ok(());
194 };
195 match file.try_lock_shared() {
196 Err(err) if is_would_block(&err) => Err(SqliteStoreError::MaintenanceFenceHeld {
197 path: db_path.to_path_buf(),
198 }),
199 Ok(()) | Err(_) => Ok(()),
202 }
203 }
204}
205
206#[derive(Debug)]
213pub struct ExclusiveFence {
214 _lock: File,
215 lock_path: PathBuf,
216}
217
218impl ExclusiveFence {
219 pub fn try_acquire(db_path: &Path) -> Result<Option<Self>, SqliteStoreError> {
221 let lock_path = fence_lock_path(db_path);
222 let file = OpenOptions::new()
223 .read(true)
224 .write(true)
225 .create(true)
226 .truncate(false)
227 .open(&lock_path)?;
228 match file.try_lock() {
229 Ok(()) => {
230 if let Ok(mut held) = held_exclusive_locks().lock() {
231 held.insert(registry_key(&lock_path));
232 }
233 Ok(Some(Self {
234 _lock: file,
235 lock_path,
236 }))
237 }
238 Err(err) if is_would_block(&err) => Ok(None),
239 Err(err) => Err(SqliteStoreError::Io(std::io::Error::other(err))),
240 }
241 }
242
243 pub fn acquire(db_path: &Path, deadline: Duration) -> Result<Self, SqliteStoreError> {
246 let started = Instant::now();
247 loop {
248 if let Some(fence) = Self::try_acquire(db_path)? {
249 return Ok(fence);
250 }
251 if started.elapsed() >= deadline {
252 return Err(SqliteStoreError::MaintenanceFenceHeld {
253 path: db_path.to_path_buf(),
254 });
255 }
256 std::thread::sleep(Duration::from_millis(25));
257 }
258 }
259
260 pub fn lock_path(&self) -> &Path {
262 &self.lock_path
263 }
264}
265
266impl Drop for ExclusiveFence {
267 fn drop(&mut self) {
268 if let Ok(mut held) = held_exclusive_locks().lock() {
269 held.remove(®istry_key(&self.lock_path));
270 }
271 }
272}
273
274fn is_would_block(err: &std::fs::TryLockError) -> bool {
275 matches!(err, std::fs::TryLockError::WouldBlock)
276}
277
278#[cfg(test)]
279#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
280mod tests {
281 use super::*;
282
283 #[test]
284 fn shared_guards_coexist() {
285 let dir = tempfile::tempdir().expect("tempdir");
286 let db = dir.path().join("db.sqlite3");
287 let g1 = OperationGuard::for_database(&db).expect("g1");
288 let g2 = OperationGuard::for_database(&db).expect("g2");
289 drop((g1, g2));
290 }
291
292 #[test]
293 fn exclusive_fence_blocks_foreign_operations_typed() {
294 let dir = tempfile::tempdir().expect("tempdir");
295 let db = dir.path().join("db.sqlite3");
296 let lock_path = fence_lock_path(&db);
300 let foreign = OpenOptions::new()
301 .read(true)
302 .write(true)
303 .create(true)
304 .truncate(false)
305 .open(&lock_path)
306 .expect("open lock file");
307 foreign.try_lock().expect("foreign exclusive lock");
308 let err = OperationGuard::for_database(&db).expect_err("fence must block foreign ops");
309 assert!(matches!(err, SqliteStoreError::MaintenanceFenceHeld { .. }));
310 drop(foreign);
311 OperationGuard::for_database(&db).expect("released after drop");
312 }
313
314 #[test]
318 fn a_missing_database_honours_a_held_fence_and_creates_nothing() {
319 let dir = tempfile::tempdir().expect("tempdir");
320 let db = dir.path().join("db.sqlite3");
321 OperationGuard::admit_missing_database(&db).expect("no lock file: no fence");
322 assert!(
323 !fence_lock_path(&db).exists(),
324 "admission creates no lock file"
325 );
326
327 let lock_path = fence_lock_path(&db);
328 let foreign = OpenOptions::new()
329 .read(true)
330 .write(true)
331 .create(true)
332 .truncate(false)
333 .open(&lock_path)
334 .expect("open lock file");
335 foreign.try_lock().expect("foreign exclusive lock");
336 let err = OperationGuard::admit_missing_database(&db)
337 .expect_err("a foreign fence covers the missing database");
338 assert!(matches!(err, SqliteStoreError::MaintenanceFenceHeld { .. }));
339 drop(foreign);
340 OperationGuard::admit_missing_database(&db).expect("released after drop");
341
342 let own = ExclusiveFence::try_acquire(&db)
343 .expect("io")
344 .expect("own fence");
345 OperationGuard::admit_missing_database(&db).expect("the fence holder is admitted");
346 drop(own);
347 assert!(!db.exists(), "nothing created the database");
348 }
349
350 #[test]
351 fn exclusive_fence_waits_for_guard_drain() {
352 let dir = tempfile::tempdir().expect("tempdir");
353 let db = dir.path().join("db.sqlite3");
354 let guard = OperationGuard::for_database(&db).expect("guard");
355 assert!(
356 ExclusiveFence::try_acquire(&db).expect("io").is_none(),
357 "fence must not be acquirable while an operation is in flight"
358 );
359 let handle = std::thread::spawn(move || {
360 ExclusiveFence::acquire(&db, Duration::from_secs(5)).expect("acquire")
361 });
362 std::thread::sleep(Duration::from_millis(100));
363 drop(guard);
364 let fence = handle.join().expect("thread");
365 assert!(fence.lock_path().ends_with("db.sqlite3.mfence"));
366 }
367
368 #[test]
369 fn fence_holder_operations_self_admit() {
370 let dir = tempfile::tempdir().expect("tempdir");
371 let db = dir.path().join("db.sqlite3");
372 let fence = ExclusiveFence::try_acquire(&db)
373 .expect("io")
374 .expect("acquired");
375 let guard = OperationGuard::for_database(&db).expect("holder self-admission");
378 drop(guard);
379 drop(fence);
380 OperationGuard::for_database(&db).expect("released");
382 let fence = ExclusiveFence::try_acquire(&db)
384 .expect("io")
385 .expect("reacquired");
386 drop(fence);
387 }
388
389 #[test]
390 fn in_memory_paths_get_noop_guards() {
391 let guard = OperationGuard::for_database(Path::new(":memory:")).expect("noop");
392 drop(guard);
393 }
394
395 #[test]
396 fn fence_lock_path_shape() {
397 assert_eq!(
398 fence_lock_path(Path::new("/a/b/sessions.sqlite3")),
399 PathBuf::from("/a/b/sessions.sqlite3.mfence")
400 );
401 }
402}