use std::fs::{File, OpenOptions};
use fd_lock::RwLock;
use crate::error::{MindError, Result};
use crate::paths::{Paths, mkdir_p};
pub struct MindLock {
inner: RwLock<File>,
path: std::path::PathBuf,
}
pub struct WriteGuard<'a> {
_guard: fd_lock::RwLockWriteGuard<'a, File>,
}
pub struct ReadGuard<'a> {
_guard: fd_lock::RwLockReadGuard<'a, File>,
}
pub fn open(paths: &Paths) -> Result<MindLock> {
mkdir_p(&paths.mind_home)?;
let lock_path = paths.lock_file();
let file = OpenOptions::new()
.create(true)
.truncate(false) .read(true)
.write(true)
.open(&lock_path)
.map_err(|e| MindError::io(&lock_path, e))?;
Ok(MindLock {
inner: RwLock::new(file),
path: lock_path,
})
}
impl MindLock {
pub fn write(&mut self) -> Result<WriteGuard<'_>> {
let guard = self
.inner
.write()
.map_err(|e| MindError::io(&self.path, e))?;
Ok(WriteGuard { _guard: guard })
}
pub fn read(&self) -> Result<ReadGuard<'_>> {
let guard = self
.inner
.read()
.map_err(|e| MindError::io(&self.path, e))?;
Ok(ReadGuard { _guard: guard })
}
pub fn try_read(&self) -> Option<ReadGuard<'_>> {
self.inner.try_read().ok().map(|g| ReadGuard { _guard: g })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::MindError;
use std::sync::atomic::{AtomicU32, Ordering};
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn temp_paths(label: &str) -> (Paths, std::path::PathBuf) {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let base =
std::env::temp_dir().join(format!("mind-lock-{}-{n}-{label}", std::process::id()));
let paths = Paths {
mind_home: base.join("mind"),
claude_home: base.join("claude"),
};
(paths, base)
}
fn cleanup(base: &std::path::Path) {
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn exclusive_lock_on_fresh_home_succeeds() {
let (paths, base) = temp_paths("excl");
let mut lock = open(&paths).expect("open lock on fresh mind home");
let _guard = lock.write().expect("exclusive acquire should succeed");
drop(_guard);
cleanup(&base);
}
#[test]
fn shared_lock_on_fresh_home_succeeds() {
let (paths, base) = temp_paths("shared");
let lock = open(&paths).expect("open lock on fresh mind home");
let _guard = lock.read().expect("shared acquire should succeed");
drop(_guard);
cleanup(&base);
}
#[test]
fn two_shared_locks_coexist() {
let (paths, base) = temp_paths("twoshared");
mkdir_p(&paths.mind_home).unwrap();
let lock_path = paths.lock_file();
let f1 = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
let f2 = OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
let l1 = RwLock::new(f1);
let l2 = RwLock::new(f2);
let _g1 = l1.read().expect("first shared lock");
let _g2 = l2
.try_read()
.expect("second shared lock should succeed while first is held");
cleanup(&base);
}
#[test]
fn exclusive_lock_excludes_a_second_exclusive_holder() {
let (paths, base) = temp_paths("exclexcl");
mkdir_p(&paths.mind_home).unwrap();
let lock_path = paths.lock_file();
let f1 = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
let f2 = OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
let mut l1 = RwLock::new(f1);
let mut l2 = RwLock::new(f2);
let g1 = l1.write().expect("first exclusive lock");
assert!(
l2.try_write().is_err(),
"a second exclusive lock must be refused while the first is held"
);
drop(g1);
let _g2 = l2
.try_write()
.expect("second exclusive lock should succeed after the first is released");
cleanup(&base);
}
#[test]
fn exclusive_lock_excludes_a_shared_reader() {
let (paths, base) = temp_paths("exclshared");
mkdir_p(&paths.mind_home).unwrap();
let lock_path = paths.lock_file();
let f1 = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
let f2 = OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
let mut l1 = RwLock::new(f1);
let l2 = RwLock::new(f2);
let g1 = l1.write().expect("exclusive lock");
assert!(
l2.try_read().is_err(),
"a shared reader must be refused while an exclusive lock is held"
);
drop(g1);
let _g2 = l2
.try_read()
.expect("shared read should succeed after the exclusive lock is released");
cleanup(&base);
}
#[test]
fn shared_lock_excludes_an_exclusive_writer() {
let (paths, base) = temp_paths("sharedexcl");
mkdir_p(&paths.mind_home).unwrap();
let lock_path = paths.lock_file();
let f1 = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
let f2 = OpenOptions::new()
.read(true)
.write(true)
.open(&lock_path)
.unwrap();
let l1 = RwLock::new(f1);
let mut l2 = RwLock::new(f2);
let g1 = l1.read().expect("shared lock");
assert!(
l2.try_write().is_err(),
"an exclusive writer must be refused while a shared lock is held"
);
drop(g1);
let _g2 = l2
.try_write()
.expect("exclusive write should succeed after the shared lock is released");
cleanup(&base);
}
#[test]
fn exclusive_write_blocks_until_holder_releases() {
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::time::{Duration, Instant};
let (paths, base) = temp_paths("blockwait");
mkdir_p(&paths.mind_home).unwrap();
let paths = Arc::new(paths);
let a_released = Arc::new(AtomicBool::new(false));
let hold = Duration::from_millis(300);
let p_a = Arc::clone(&paths);
let rel_a = Arc::clone(&a_released);
let a = std::thread::spawn(move || {
let mut lock = open(&p_a).expect("open A");
let guard = lock.write().expect("A exclusive");
std::thread::sleep(hold);
rel_a.store(true, Ordering::SeqCst);
drop(guard);
});
std::thread::sleep(Duration::from_millis(50));
let p_b = Arc::clone(&paths);
let rel_b = Arc::clone(&a_released);
let start = Instant::now();
let b = std::thread::spawn(move || {
let mut lock = open(&p_b).expect("open B");
let _guard = lock.write().expect("B exclusive");
assert!(
rel_b.load(Ordering::SeqCst),
"B acquired the exclusive lock before A released it (no mutual exclusion)"
);
start.elapsed()
});
a.join().unwrap();
let waited = b.join().unwrap();
assert!(
waited >= Duration::from_millis(200),
"B should have blocked roughly until A released; only waited {waited:?}"
);
cleanup(&base);
}
#[test]
fn lock_failure_is_io_error_with_lock_path() {
let (paths, base) = temp_paths("err");
mkdir_p(&paths.mind_home).unwrap();
let lock_path = paths.lock_file();
std::fs::create_dir_all(&lock_path).unwrap();
let result = open(&paths);
cleanup(&base);
match result {
Err(MindError::Io { path, .. }) => {
assert_eq!(path, lock_path, "Io error should carry the lock path");
}
Ok(_) => panic!("expected an error when lock path is a directory"),
Err(e) => panic!("unexpected error variant: {e:?}"),
}
}
}