use crate::constants::{MAX_BACKOFF_DELAY_MS, STARTING_BACKOFF_DELAY_MS, default_lock_timeout};
use anyhow::{Context, Result};
use fs4::fs_std::FileExt;
use std::fs::{File, OpenOptions};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio_retry::strategy::ExponentialBackoff;
use tracing::debug;
#[derive(Debug)]
pub struct ProjectLock {
_file: Arc<File>,
lock_name: String,
lock_path: PathBuf,
}
impl Drop for ProjectLock {
fn drop(&mut self) {
debug!(lock_name = %self.lock_name, "Project lock released");
if let Err(e) = std::fs::remove_file(&self.lock_path) {
if e.kind() != std::io::ErrorKind::NotFound {
debug!(lock_name = %self.lock_name, error = %e, "Failed to remove lock file");
}
}
}
}
impl ProjectLock {
pub async fn acquire(project_dir: &Path, lock_name: &str) -> Result<Self> {
Self::acquire_with_timeout(project_dir, lock_name, default_lock_timeout()).await
}
pub async fn acquire_with_timeout(
project_dir: &Path,
lock_name: &str,
timeout: Duration,
) -> Result<Self> {
use tokio::fs;
let display_name = format!("project:{}", lock_name);
debug!(lock_name = %display_name, "Waiting for project lock");
let locks_dir = project_dir.join(".agpm").join(".locks");
fs::create_dir_all(&locks_dir).await.with_context(|| {
format!("Failed to create project locks directory: {}", locks_dir.display())
})?;
let lock_path = locks_dir.join(format!("{lock_name}.lock"));
let lock_path_clone = lock_path.clone();
let file = tokio::task::spawn_blocking(move || {
OpenOptions::new().create(true).write(true).truncate(false).open(&lock_path_clone)
})
.await
.with_context(|| "spawn_blocking panicked")?
.with_context(|| format!("Failed to open lock file: {}", lock_path.display()))?;
let file = Arc::new(file);
let start = std::time::Instant::now();
let backoff = ExponentialBackoff::from_millis(STARTING_BACKOFF_DELAY_MS)
.max_delay(Duration::from_millis(MAX_BACKOFF_DELAY_MS));
for delay in backoff {
let file_clone = Arc::clone(&file);
let lock_result = tokio::task::spawn_blocking(move || file_clone.try_lock_exclusive())
.await
.with_context(|| "spawn_blocking panicked")?;
match lock_result {
Ok(true) => {
debug!(
lock_name = %display_name,
wait_ms = start.elapsed().as_millis(),
"Project lock acquired"
);
return Ok(Self {
_file: file,
lock_name: display_name,
lock_path,
});
}
Ok(false) | Err(_) => {
let remaining = timeout.saturating_sub(start.elapsed());
if remaining.is_zero() {
return Err(anyhow::anyhow!(
"Timeout acquiring project lock '{}' after {:?}",
lock_name,
timeout
));
}
tokio::time::sleep(delay.min(remaining)).await;
}
}
}
Err(anyhow::anyhow!("Timeout acquiring project lock '{}' after {:?}", lock_name, timeout))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn test_project_lock_acquire_and_release() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let lock = ProjectLock::acquire(project_dir, "test").await.unwrap();
let lock_path = project_dir.join(".agpm").join(".locks").join("test.lock");
assert!(lock_path.exists());
drop(lock);
assert!(!lock_path.exists());
}
#[tokio::test]
async fn test_project_lock_creates_directories() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let locks_dir = project_dir.join(".agpm").join(".locks");
assert!(!locks_dir.exists());
let lock = ProjectLock::acquire(project_dir, "test").await.unwrap();
assert!(locks_dir.exists());
assert!(locks_dir.is_dir());
drop(lock);
}
#[tokio::test]
async fn test_project_lock_exclusive_blocking() {
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Barrier;
let temp_dir = TempDir::new().unwrap();
let project_dir = Arc::new(temp_dir.path().to_path_buf());
let barrier = Arc::new(Barrier::new(2));
let project_dir1 = project_dir.clone();
let barrier1 = barrier.clone();
let handle1 = tokio::spawn(async move {
let _lock = ProjectLock::acquire(&project_dir1, "exclusive_test").await.unwrap();
barrier1.wait().await; tokio::time::sleep(Duration::from_millis(100)).await; });
let project_dir2 = project_dir.clone();
let handle2 = tokio::spawn(async move {
barrier.wait().await; let start = Instant::now();
let _lock = ProjectLock::acquire(&project_dir2, "exclusive_test").await.unwrap();
let elapsed = start.elapsed();
assert!(elapsed >= Duration::from_millis(50));
});
handle1.await.unwrap();
handle2.await.unwrap();
}
#[tokio::test]
async fn test_project_lock_different_names_dont_block() {
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Barrier;
let temp_dir = TempDir::new().unwrap();
let project_dir = Arc::new(temp_dir.path().to_path_buf());
let barrier = Arc::new(Barrier::new(2));
let project_dir1 = project_dir.clone();
let barrier1 = barrier.clone();
let handle1 = tokio::spawn(async move {
let _lock = ProjectLock::acquire(&project_dir1, "lock1").await.unwrap();
barrier1.wait().await;
tokio::time::sleep(Duration::from_millis(100)).await;
});
let project_dir2 = project_dir.clone();
let handle2 = tokio::spawn(async move {
barrier.wait().await;
let start = Instant::now();
let _lock = ProjectLock::acquire(&project_dir2, "lock2").await.unwrap();
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_millis(200),
"Lock acquisition took {:?}, expected < 200ms for non-blocking operation",
elapsed
);
});
handle1.await.unwrap();
handle2.await.unwrap();
}
#[tokio::test]
async fn test_project_lock_acquire_timeout() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path();
let _lock1 = ProjectLock::acquire(project_dir, "test").await.unwrap();
let start = std::time::Instant::now();
let result =
ProjectLock::acquire_with_timeout(project_dir, "test", Duration::from_millis(100))
.await;
let elapsed = start.elapsed();
assert!(result.is_err(), "Expected timeout error");
let error_msg = result.unwrap_err().to_string();
assert!(
error_msg.contains("Timeout") || error_msg.contains("timeout"),
"Error message should mention timeout: {}",
error_msg
);
assert!(elapsed >= Duration::from_millis(50), "Timeout too quick: {:?}", elapsed);
assert!(elapsed < Duration::from_millis(500), "Timeout too slow: {:?}", elapsed);
}
}