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
177#[derive(Debug)]
184pub struct ExclusiveFence {
185 _lock: File,
186 lock_path: PathBuf,
187}
188
189impl ExclusiveFence {
190 pub fn try_acquire(db_path: &Path) -> Result<Option<Self>, SqliteStoreError> {
192 let lock_path = fence_lock_path(db_path);
193 let file = OpenOptions::new()
194 .read(true)
195 .write(true)
196 .create(true)
197 .truncate(false)
198 .open(&lock_path)?;
199 match file.try_lock() {
200 Ok(()) => {
201 if let Ok(mut held) = held_exclusive_locks().lock() {
202 held.insert(registry_key(&lock_path));
203 }
204 Ok(Some(Self {
205 _lock: file,
206 lock_path,
207 }))
208 }
209 Err(err) if is_would_block(&err) => Ok(None),
210 Err(err) => Err(SqliteStoreError::Io(std::io::Error::other(err))),
211 }
212 }
213
214 pub fn acquire(db_path: &Path, deadline: Duration) -> Result<Self, SqliteStoreError> {
217 let started = Instant::now();
218 loop {
219 if let Some(fence) = Self::try_acquire(db_path)? {
220 return Ok(fence);
221 }
222 if started.elapsed() >= deadline {
223 return Err(SqliteStoreError::MaintenanceFenceHeld {
224 path: db_path.to_path_buf(),
225 });
226 }
227 std::thread::sleep(Duration::from_millis(25));
228 }
229 }
230
231 pub fn lock_path(&self) -> &Path {
233 &self.lock_path
234 }
235}
236
237impl Drop for ExclusiveFence {
238 fn drop(&mut self) {
239 if let Ok(mut held) = held_exclusive_locks().lock() {
240 held.remove(®istry_key(&self.lock_path));
241 }
242 }
243}
244
245fn is_would_block(err: &std::fs::TryLockError) -> bool {
246 matches!(err, std::fs::TryLockError::WouldBlock)
247}
248
249#[cfg(test)]
250#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
251mod tests {
252 use super::*;
253
254 #[test]
255 fn shared_guards_coexist() {
256 let dir = tempfile::tempdir().expect("tempdir");
257 let db = dir.path().join("db.sqlite3");
258 let g1 = OperationGuard::for_database(&db).expect("g1");
259 let g2 = OperationGuard::for_database(&db).expect("g2");
260 drop((g1, g2));
261 }
262
263 #[test]
264 fn exclusive_fence_blocks_foreign_operations_typed() {
265 let dir = tempfile::tempdir().expect("tempdir");
266 let db = dir.path().join("db.sqlite3");
267 let lock_path = fence_lock_path(&db);
271 let foreign = OpenOptions::new()
272 .read(true)
273 .write(true)
274 .create(true)
275 .truncate(false)
276 .open(&lock_path)
277 .expect("open lock file");
278 foreign.try_lock().expect("foreign exclusive lock");
279 let err = OperationGuard::for_database(&db).expect_err("fence must block foreign ops");
280 assert!(matches!(err, SqliteStoreError::MaintenanceFenceHeld { .. }));
281 drop(foreign);
282 OperationGuard::for_database(&db).expect("released after drop");
283 }
284
285 #[test]
286 fn exclusive_fence_waits_for_guard_drain() {
287 let dir = tempfile::tempdir().expect("tempdir");
288 let db = dir.path().join("db.sqlite3");
289 let guard = OperationGuard::for_database(&db).expect("guard");
290 assert!(
291 ExclusiveFence::try_acquire(&db).expect("io").is_none(),
292 "fence must not be acquirable while an operation is in flight"
293 );
294 let handle = std::thread::spawn(move || {
295 ExclusiveFence::acquire(&db, Duration::from_secs(5)).expect("acquire")
296 });
297 std::thread::sleep(Duration::from_millis(100));
298 drop(guard);
299 let fence = handle.join().expect("thread");
300 assert!(fence.lock_path().ends_with("db.sqlite3.mfence"));
301 }
302
303 #[test]
304 fn fence_holder_operations_self_admit() {
305 let dir = tempfile::tempdir().expect("tempdir");
306 let db = dir.path().join("db.sqlite3");
307 let fence = ExclusiveFence::try_acquire(&db)
308 .expect("io")
309 .expect("acquired");
310 let guard = OperationGuard::for_database(&db).expect("holder self-admission");
313 drop(guard);
314 drop(fence);
315 OperationGuard::for_database(&db).expect("released");
317 let fence = ExclusiveFence::try_acquire(&db)
319 .expect("io")
320 .expect("reacquired");
321 drop(fence);
322 }
323
324 #[test]
325 fn in_memory_paths_get_noop_guards() {
326 let guard = OperationGuard::for_database(Path::new(":memory:")).expect("noop");
327 drop(guard);
328 }
329
330 #[test]
331 fn fence_lock_path_shape() {
332 assert_eq!(
333 fence_lock_path(Path::new("/a/b/sessions.sqlite3")),
334 PathBuf::from("/a/b/sessions.sqlite3.mfence")
335 );
336 }
337}