use std::fs::{self, File};
use std::path::{Path, PathBuf};
use std::time::Duration;
use fs2::FileExt;
pub struct FileLock {
file: File,
}
impl FileLock {
pub fn new(file: File) -> Self {
Self { file }
}
}
impl Drop for FileLock {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.file);
}
}
const LOCKS_DIR: &str = ".locks";
pub fn repo_lock_path(workspace_dir: &Path, repo_id: &str) -> PathBuf {
workspace_dir
.join(LOCKS_DIR)
.join(format!("{repo_id}.lock"))
}
pub fn acquire_file_lock(lock_path: &Path) -> anyhow::Result<FileLock> {
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent)?;
}
let file = File::create(lock_path)?;
file.try_lock_exclusive()?;
Ok(FileLock::new(file))
}
const LOCK_ATTEMPTS: u32 = 5;
const LOCK_RETRY_DELAY: Duration = Duration::from_millis(20);
pub async fn acquire_file_lock_with_retry(lock_path: &Path) -> anyhow::Result<FileLock> {
let mut attempt = 1;
loop {
match acquire_file_lock(lock_path) {
Ok(lock) => return Ok(lock),
Err(e) if attempt >= LOCK_ATTEMPTS => return Err(e),
Err(_) => {
attempt += 1;
tokio::time::sleep(LOCK_RETRY_DELAY).await;
}
}
}
}
pub fn is_lock_held(lock_path: &Path) -> bool {
let Ok(file) = File::open(lock_path) else {
return false;
};
match file.try_lock_exclusive() {
Ok(()) => {
let _ = FileExt::unlock(&file);
false
}
Err(_) => true,
}
}
pub fn is_lock_stale(lock_path: &Path, threshold: Duration) -> bool {
if let Ok(metadata) = fs::metadata(lock_path)
&& let Ok(modified) = metadata.modified()
&& let Ok(elapsed) = modified.elapsed()
{
return elapsed > threshold;
}
false
}
pub fn remove_stale_lock(lock_path: &Path) -> bool {
if lock_path.exists() {
if let Err(e) = fs::remove_file(lock_path) {
tracing::warn!("Failed to remove stale lock {}: {e}", lock_path.display());
return false;
}
tracing::warn!("Removed stale lock: {}", lock_path.display());
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use tempfile::TempDir;
#[test]
fn test_acquire_lock_succeeds_on_first_attempt() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join(".knot.lock");
let lock = acquire_file_lock(&lock_path);
assert!(lock.is_ok());
}
#[test]
fn test_acquire_lock_fails_when_already_held() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join(".knot.lock");
let _lock1 = acquire_file_lock(&lock_path).unwrap();
let lock2 = acquire_file_lock(&lock_path);
assert!(lock2.is_err());
}
#[test]
fn test_lock_released_on_drop() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join(".knot.lock");
{
let _lock = acquire_file_lock(&lock_path).unwrap();
}
let lock2 = acquire_file_lock(&lock_path);
assert!(lock2.is_ok());
}
#[test]
fn test_fresh_lock_not_stale() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join(".knot.lock");
File::create(&lock_path).unwrap();
assert!(!is_lock_stale(&lock_path, Duration::from_secs(3600)));
}
#[test]
fn test_remove_stale_lock_removes_file() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join(".knot.lock");
File::create(&lock_path).unwrap();
assert!(lock_path.exists());
assert!(remove_stale_lock(&lock_path));
assert!(!lock_path.exists());
}
#[test]
fn test_remove_stale_lock_nonexistent() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join("nonexistent.lock");
assert!(!remove_stale_lock(&lock_path));
}
#[test]
fn test_repo_lock_path_is_outside_repo_dir() {
let workspace = Path::new("/ws");
let lock_path = repo_lock_path(workspace, "alpha");
assert_eq!(lock_path, workspace.join(".locks").join("alpha.lock"));
let repo_dir = crate::models::repo_local_path("/ws", "alpha");
assert!(!lock_path.starts_with(&repo_dir));
}
#[test]
fn test_repo_lock_path_does_not_collide_with_repo_named_locks() {
let workspace = Path::new("/ws");
let repo_dir = crate::models::repo_local_path("/ws", "locks");
assert!(!repo_lock_path(workspace, "alpha").starts_with(&repo_dir));
}
#[test]
fn test_lock_survives_wipe_of_repo_dir() {
let workspace = TempDir::new().unwrap();
let ws = workspace.path().to_string_lossy().into_owned();
let repo_dir = crate::models::repo_local_path(&ws, "alpha");
fs::create_dir_all(&repo_dir).unwrap();
let lock_path = repo_lock_path(workspace.path(), "alpha");
let _held = acquire_file_lock(&lock_path).unwrap();
fs::remove_dir_all(&repo_dir).unwrap();
assert!(lock_path.exists());
assert!(
acquire_file_lock(&lock_path).is_err(),
"a second node must not acquire the lock after the repo dir is wiped"
);
}
#[test]
fn test_is_lock_held_tracks_the_holder() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join("alpha.lock");
assert!(!is_lock_held(&lock_path), "a missing file is never held");
let held = acquire_file_lock(&lock_path).unwrap();
assert!(is_lock_held(&lock_path));
drop(held);
assert!(!is_lock_held(&lock_path), "the file outlives the lock");
assert!(
acquire_file_lock(&lock_path).is_ok(),
"the probe must release the lock it took"
);
}
#[tokio::test]
async fn test_acquire_with_retry_waits_out_a_brief_holder() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join("alpha.lock");
let held = acquire_file_lock(&lock_path).unwrap();
let release = tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(30)).await;
drop(held);
});
assert!(acquire_file_lock_with_retry(&lock_path).await.is_ok());
release.await.unwrap();
}
#[tokio::test]
async fn test_acquire_with_retry_reports_a_lasting_holder() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join("alpha.lock");
let _held = acquire_file_lock(&lock_path).unwrap();
assert!(acquire_file_lock_with_retry(&lock_path).await.is_err());
}
#[test]
fn test_lock_creates_parent_directory() {
let dir = TempDir::new().unwrap();
let lock_path = dir.path().join("subdir").join(".knot.lock");
let lock = acquire_file_lock(&lock_path);
assert!(lock.is_ok());
assert!(lock_path.exists());
}
}