#![allow(dead_code)]
use std::fs::{File, OpenOptions};
use std::io;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use fs2::FileExt;
use thiserror::Error;
const POLL_INTERVAL: Duration = Duration::from_millis(50);
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Debug, Error)]
pub enum LockError {
#[error("lock acquisition timed out after {0:?}")]
Timeout(Duration),
#[error("lock file I/O failed at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: io::Error,
},
}
#[derive(Debug)]
pub struct MacotLock {
file: File,
path: PathBuf,
}
impl MacotLock {
pub fn acquire(project_root: &Path, timeout: Duration) -> Result<Self, LockError> {
let (file, path) = open_lock_file(project_root)?;
let start = Instant::now();
loop {
match file.try_lock_exclusive() {
Ok(()) => return Ok(Self { file, path }),
Err(err) if !is_would_block(&err) => {
return Err(LockError::Io { path, source: err });
}
Err(_) => {
if start.elapsed() >= timeout {
return Err(LockError::Timeout(timeout));
}
std::thread::sleep(POLL_INTERVAL);
}
}
}
}
pub fn try_acquire(project_root: &Path) -> Result<Option<Self>, LockError> {
let (file, path) = open_lock_file(project_root)?;
match file.try_lock_exclusive() {
Ok(()) => Ok(Some(Self { file, path })),
Err(err) if is_would_block(&err) => Ok(None),
Err(err) => Err(LockError::Io { path, source: err }),
}
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for MacotLock {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.file);
}
}
fn open_lock_file(project_root: &Path) -> Result<(File, PathBuf), LockError> {
let dir = project_root.join(".macot");
std::fs::create_dir_all(&dir).map_err(|source| LockError::Io {
path: dir.clone(),
source,
})?;
let path = dir.join(".lock");
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.map_err(|source| LockError::Io {
path: path.clone(),
source,
})?;
Ok((file, path))
}
fn is_would_block(err: &io::Error) -> bool {
matches!(err.kind(), io::ErrorKind::WouldBlock)
|| err
.raw_os_error()
.map(|c| c == libc_eagain())
.unwrap_or(false)
}
const fn libc_eagain() -> i32 {
11
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::thread;
use tempfile::TempDir;
#[test]
fn acquire_creates_lock_file_in_macot_dir() {
let tmp = TempDir::new().unwrap();
let lock = MacotLock::acquire(tmp.path(), DEFAULT_TIMEOUT).expect("acquire");
let expected = tmp.path().join(".macot").join(".lock");
assert_eq!(
lock.path(),
expected.as_path(),
"acquire: lock path should be <root>/.macot/.lock"
);
assert!(expected.exists(), "acquire: lock file should be created");
}
#[test]
fn second_try_acquire_returns_none_while_first_held() {
let tmp = TempDir::new().unwrap();
let _held = MacotLock::acquire(tmp.path(), DEFAULT_TIMEOUT).unwrap();
let path = tmp.path().to_path_buf();
let result = thread::spawn(move || MacotLock::try_acquire(&path).unwrap())
.join()
.unwrap();
assert!(
result.is_none(),
"try_acquire: second holder must observe contention"
);
}
#[test]
fn acquire_succeeds_after_first_holder_drops() {
let tmp = TempDir::new().unwrap();
let held = MacotLock::acquire(tmp.path(), DEFAULT_TIMEOUT).unwrap();
let path = tmp.path().to_path_buf();
let handle = thread::spawn(move || {
thread::sleep(Duration::from_millis(150));
drop(held);
});
let waiter = MacotLock::acquire(&path, Duration::from_secs(2));
handle.join().unwrap();
assert!(
waiter.is_ok(),
"acquire: waiter must succeed once holder releases"
);
}
#[test]
fn acquire_times_out_when_lock_remains_held() {
let tmp = TempDir::new().unwrap();
let _held = MacotLock::acquire(tmp.path(), DEFAULT_TIMEOUT).unwrap();
let path = tmp.path().to_path_buf();
let waiter = thread::spawn(move || MacotLock::acquire(&path, Duration::from_millis(200)))
.join()
.unwrap();
match waiter {
Err(LockError::Timeout(d)) => {
assert_eq!(
d,
Duration::from_millis(200),
"timeout: should report configured deadline"
);
}
other => panic!("acquire: expected Timeout, got {other:?}"),
}
}
#[test]
fn drop_releases_lock_for_subsequent_acquire() {
let tmp = TempDir::new().unwrap();
{
let _l = MacotLock::acquire(tmp.path(), DEFAULT_TIMEOUT).unwrap();
}
let again = MacotLock::acquire(tmp.path(), Duration::from_millis(100));
assert!(
again.is_ok(),
"drop: should release the lock for subsequent acquisition"
);
}
#[test]
fn parallel_acquirers_observe_serialised_state() {
let tmp = Arc::new(TempDir::new().unwrap());
let counter_path = tmp.path().join(".macot").join("counter");
std::fs::create_dir_all(tmp.path().join(".macot")).unwrap();
std::fs::write(&counter_path, "0").unwrap();
let mut handles = Vec::new();
for _ in 0..4 {
let root = tmp.path().to_path_buf();
let counter = counter_path.clone();
handles.push(thread::spawn(move || {
let _lock = MacotLock::acquire(&root, Duration::from_secs(2)).unwrap();
let current: u32 = std::fs::read_to_string(&counter).unwrap().parse().unwrap();
thread::sleep(Duration::from_millis(20));
std::fs::write(&counter, (current + 1).to_string()).unwrap();
}));
}
for h in handles {
h.join().unwrap();
}
let final_value: u32 = std::fs::read_to_string(&counter_path)
.unwrap()
.parse()
.unwrap();
assert_eq!(
final_value, 4,
"parallel: serialised increments must reach 4, got {final_value}"
);
}
}