use std::fs::{File, OpenOptions};
use fd_lock::RwLock;
use crate::error::{MindError, Result};
use crate::paths::{Paths, mkdir_p};
pub struct MindLock {
files: Vec<OneLock>,
}
struct OneLock {
inner: RwLock<File>,
path: std::path::PathBuf,
}
pub struct WriteGuard<'a> {
_guards: Vec<fd_lock::RwLockWriteGuard<'a, File>>,
}
pub struct ReadGuard<'a> {
_guards: Vec<fd_lock::RwLockReadGuard<'a, File>>,
}
pub fn open(paths: &Paths) -> Result<MindLock> {
let mut files = Vec::new();
if let Some(lock) = open_one(&paths.mind_home, paths.lock_file(), true)? {
files.push(lock);
}
for home in paths.extra_homes() {
if let Some(lock) = open_one(&home, home.join(".lock"), false)? {
files.push(lock);
}
}
Ok(MindLock { files })
}
fn open_one(
home: &std::path::Path,
lock_path: std::path::PathBuf,
required: bool,
) -> Result<Option<OneLock>> {
let made = mkdir_p(home);
let rw = OpenOptions::new()
.create(true)
.truncate(false) .read(true)
.write(true)
.open(&lock_path);
let denied = |e: &std::io::Error| {
matches!(
e.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem
)
};
let home_denied = matches!(&made, Err(MindError::Io { source, .. }) if denied(source));
let file = match rw {
Ok(f) => f,
Err(e) if denied(&e) || home_denied => {
match OpenOptions::new().read(true).open(&lock_path) {
Ok(f) => f,
Err(_) => return Ok(None),
}
}
Err(_) if !required => return Ok(None),
Err(e) => {
made?;
return Err(MindError::io(&lock_path, e));
}
};
Ok(Some(OneLock {
inner: RwLock::new(file),
path: lock_path,
}))
}
fn wait_step(busy: bool) -> Option<crate::progress::Step<'static>> {
busy.then(|| crate::progress::step("waiting for another mind command to finish"))
}
pub const IN_HOOK_ENV: &str = "MIND_IN_HOOK";
fn refuse_nested(busy: bool) -> Result<()> {
if busy && std::env::var_os(IN_HOOK_ENV).is_some_and(|v| v == "1") {
return Err(MindError::NestedInHook);
}
Ok(())
}
impl MindLock {
pub fn write(&mut self) -> Result<WriteGuard<'_>> {
let busy = self.files.iter_mut().any(|l| l.inner.try_write().is_err());
refuse_nested(busy)?;
let _step = wait_step(busy);
let mut guards = Vec::new();
for l in self.files.iter_mut() {
let path = l.path.clone();
guards.push(l.inner.write().map_err(|e| MindError::io(&path, e))?);
}
Ok(WriteGuard { _guards: guards })
}
pub fn read(&self) -> Result<ReadGuard<'_>> {
let busy = self.files.iter().any(|l| l.inner.try_read().is_err());
refuse_nested(busy)?;
let _step = wait_step(busy);
let mut guards = Vec::new();
for l in &self.files {
guards.push(l.inner.read().map_err(|e| MindError::io(&l.path, e))?);
}
Ok(ReadGuard { _guards: guards })
}
pub fn try_read(&self) -> Option<ReadGuard<'_>> {
let mut guards = Vec::new();
for l in &self.files {
guards.push(l.inner.try_read().ok()?);
}
Some(ReadGuard { _guards: guards })
}
pub fn try_write(&mut self) -> Option<WriteGuard<'_>> {
let mut guards = Vec::new();
for l in self.files.iter_mut() {
guards.push(l.inner.try_write().ok()?);
}
Some(WriteGuard { _guards: guards })
}
}
#[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_acquired = Arc::new(AtomicBool::new(false));
let a_released = Arc::new(AtomicBool::new(false));
let hold = Duration::from_millis(300);
let p_a = Arc::clone(&paths);
let acq_a = Arc::clone(&a_acquired);
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");
acq_a.store(true, Ordering::SeqCst);
std::thread::sleep(hold);
rel_a.store(true, Ordering::SeqCst);
drop(guard);
});
while !a_acquired.load(Ordering::SeqCst) {
std::thread::yield_now();
}
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:?}"),
}
}
}