use anyhow::{Context, Result};
use fs2::FileExt;
use sha2::{Digest, Sha256};
use std::fs::{self, File};
use std::path::PathBuf;
use std::time::{Duration, Instant};
fn lock_dir() -> PathBuf {
#[cfg(feature = "alt-folder-name")]
let folder_name = "brainwires";
#[cfg(not(feature = "alt-folder-name"))]
let folder_name = "brainwires-rag";
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(folder_name)
.join("locks")
}
fn lock_file_path(normalized_path: &str) -> PathBuf {
let mut hasher = Sha256::new();
hasher.update(normalized_path.as_bytes());
let hash = format!("{:x}", hasher.finalize());
lock_dir().join(format!("{}.lock", &hash[..16]))
}
pub struct FsLockGuard {
_file: File,
_path: PathBuf,
}
impl FsLockGuard {
pub fn try_acquire(normalized_path: &str) -> Result<Option<Self>> {
let lock_path = lock_file_path(normalized_path);
tracing::debug!(
"Attempting to acquire filesystem lock: path={}, lock_file={:?}",
normalized_path,
lock_path
);
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent).context("Failed to create lock directory")?;
}
let file = File::create(&lock_path).context("Failed to create lock file")?;
match file.try_lock_exclusive() {
Ok(()) => {
tracing::debug!(
"Acquired filesystem lock for: {} (lock_file={:?})",
normalized_path,
lock_path
);
Ok(Some(Self {
_file: file,
_path: lock_path,
}))
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
tracing::debug!(
"Filesystem lock blocked (another holder) for: {} (lock_file={:?})",
normalized_path,
lock_path
);
Ok(None)
}
Err(e) => Err(e).context("Failed to acquire filesystem lock"),
}
}
pub fn acquire_blocking(normalized_path: &str, timeout: Duration) -> Result<Option<Self>> {
let start = Instant::now();
let sleep_interval = Duration::from_millis(500);
tracing::info!(
"Waiting for filesystem lock on {} (timeout: {:?})",
normalized_path,
timeout
);
loop {
match Self::try_acquire(normalized_path)? {
Some(guard) => {
tracing::info!("Acquired filesystem lock after {:?}", start.elapsed());
return Ok(Some(guard));
}
None => {
if start.elapsed() >= timeout {
tracing::warn!(
"Timeout waiting for filesystem lock on {} after {:?}",
normalized_path,
timeout
);
return Ok(None);
}
std::thread::sleep(sleep_interval);
}
}
}
}
}
impl Drop for FsLockGuard {
fn drop(&mut self) {
tracing::debug!("Releasing filesystem lock");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn test_acquire_and_release() {
let path = "/test/path/for/locking";
let guard = FsLockGuard::try_acquire(path).unwrap();
assert!(guard.is_some());
drop(guard);
let guard2 = FsLockGuard::try_acquire(path).unwrap();
assert!(guard2.is_some());
}
#[test]
fn test_concurrent_lock_fails() {
let path = "/test/path/for/concurrent/locking";
let guard1 = FsLockGuard::try_acquire(path).unwrap();
assert!(guard1.is_some());
let path_clone = path.to_string();
let handle = thread::spawn(move || FsLockGuard::try_acquire(&path_clone).unwrap());
let result = handle.join().unwrap();
assert!(result.is_none(), "Second lock should fail");
drop(guard1);
let guard2 = FsLockGuard::try_acquire(path).unwrap();
assert!(guard2.is_some());
}
#[test]
fn test_blocking_acquire_with_timeout() {
let path = "/test/path/for/blocking/timeout";
let _guard = FsLockGuard::try_acquire(path).unwrap().unwrap();
let path_clone = path.to_string();
let handle = thread::spawn(move || {
FsLockGuard::acquire_blocking(&path_clone, Duration::from_millis(100)).unwrap()
});
let result = handle.join().unwrap();
assert!(result.is_none(), "Should timeout waiting for lock");
}
#[test]
fn test_lock_file_path_uniqueness() {
let path1 = "/path/to/project1";
let path2 = "/path/to/project2";
let path1_dup = "/path/to/project1";
let lock1 = lock_file_path(path1);
let lock2 = lock_file_path(path2);
let lock1_dup = lock_file_path(path1_dup);
assert_ne!(
lock1, lock2,
"Different paths should have different lock files"
);
assert_eq!(lock1, lock1_dup, "Same path should have same lock file");
}
#[tokio::test]
async fn test_concurrent_lock_fails_async() {
let path = "/test/path/for/async/concurrent/locking";
let path1 = path.to_string();
let guard1 = tokio::task::spawn_blocking(move || FsLockGuard::try_acquire(&path1).unwrap())
.await
.unwrap();
assert!(guard1.is_some(), "First lock should succeed");
let _held_guard = guard1.unwrap();
let path2 = path.to_string();
let guard2 = tokio::task::spawn_blocking(move || FsLockGuard::try_acquire(&path2).unwrap())
.await
.unwrap();
assert!(
guard2.is_none(),
"Second lock should fail because first is held"
);
}
}