agpm_cli/installer/
project_lock.rs

1//! Project-level file locking for cross-process coordination.
2//!
3//! This module provides process-safe file locking for project operations
4//! like resource installation. The locks are automatically released when
5//! the lock object is dropped.
6//!
7//! # Async Safety
8//!
9//! All file operations are wrapped in `spawn_blocking` to avoid blocking the tokio
10//! runtime. This is critical for preventing worker thread starvation under high
11//! parallelism with slow I/O.
12
13use crate::constants::{MAX_BACKOFF_DELAY_MS, STARTING_BACKOFF_DELAY_MS, default_lock_timeout};
14use anyhow::{Context, Result};
15use fs4::fs_std::FileExt;
16use std::fs::{File, OpenOptions};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use std::time::Duration;
20use tokio_retry::strategy::ExponentialBackoff;
21use tracing::debug;
22
23/// A file lock for project-level operations.
24///
25/// Provides cross-process synchronization for operations like resource
26/// installation. Uses OS-level file locking via the fs4 crate.
27///
28/// # Lock File Location
29///
30/// Lock files are stored in `{project_dir}/.agpm/.locks/{lock_name}.lock`
31///
32/// # Example
33///
34/// ```rust,no_run
35/// use agpm_cli::installer::project_lock::ProjectLock;
36/// use std::path::Path;
37///
38/// # async fn example() -> anyhow::Result<()> {
39/// let project_dir = Path::new("/path/to/project");
40///
41/// // Acquire resource lock for file writes
42/// let _lock = ProjectLock::acquire(project_dir, "resource").await?;
43///
44/// // Perform file operations...
45/// // Lock is automatically released when _lock goes out of scope
46/// # Ok(())
47/// # }
48/// ```
49#[derive(Debug)]
50pub struct ProjectLock {
51    /// The file handle - lock is released when this is dropped
52    _file: Arc<File>,
53    /// Name of the lock for tracing
54    lock_name: String,
55    /// Path to the lock file for cleanup on drop
56    lock_path: PathBuf,
57}
58
59impl Drop for ProjectLock {
60    fn drop(&mut self) {
61        debug!(lock_name = %self.lock_name, "Project lock released");
62        // Clean up the lock file to prevent accumulation
63        if let Err(e) = std::fs::remove_file(&self.lock_path) {
64            // Only log if it's not a "file not found" error (race condition with another cleanup)
65            if e.kind() != std::io::ErrorKind::NotFound {
66                debug!(lock_name = %self.lock_name, error = %e, "Failed to remove lock file");
67            }
68        }
69    }
70}
71
72impl ProjectLock {
73    /// Acquires an exclusive lock for a project operation.
74    ///
75    /// Creates and acquires an exclusive file lock for the specified lock name.
76    /// Uses non-blocking lock attempts with exponential backoff and timeout.
77    ///
78    /// # Lock File Management
79    ///
80    /// 1. Creates `.agpm/.locks/` directory if needed
81    /// 2. Creates `{lock_name}.lock` file
82    /// 3. Acquires exclusive access via OS file locking
83    /// 4. Keeps file handle open to maintain lock
84    ///
85    /// # Behavior
86    ///
87    /// - **Timeout**: 30-second default (configurable via `acquire_with_timeout`)
88    /// - **Non-blocking**: `try_lock_exclusive()` in async retry loop
89    /// - **Backoff**: 10ms → 20ms → 40ms... up to 500ms max
90    ///
91    /// # Errors
92    ///
93    /// - Permission denied creating lock directory
94    /// - Disk space exhausted
95    /// - Timeout acquiring lock
96    ///
97    /// # Platform Support
98    ///
99    /// - **Windows**: Win32 `LockFile` API
100    /// - **Unix**: POSIX `fcntl()` locking
101    pub async fn acquire(project_dir: &Path, lock_name: &str) -> Result<Self> {
102        Self::acquire_with_timeout(project_dir, lock_name, default_lock_timeout()).await
103    }
104
105    /// Acquires an exclusive lock with a specified timeout.
106    ///
107    /// Uses exponential backoff (10ms → 500ms) without blocking the async runtime.
108    ///
109    /// # Errors
110    ///
111    /// Returns timeout error if lock cannot be acquired within the specified duration.
112    pub async fn acquire_with_timeout(
113        project_dir: &Path,
114        lock_name: &str,
115        timeout: Duration,
116    ) -> Result<Self> {
117        use tokio::fs;
118
119        let display_name = format!("project:{}", lock_name);
120        debug!(lock_name = %display_name, "Waiting for project lock");
121
122        // Create .agpm/.locks directory if it doesn't exist
123        let locks_dir = project_dir.join(".agpm").join(".locks");
124        fs::create_dir_all(&locks_dir).await.with_context(|| {
125            format!("Failed to create project locks directory: {}", locks_dir.display())
126        })?;
127
128        // Create lock file path
129        let lock_path = locks_dir.join(format!("{lock_name}.lock"));
130
131        // CRITICAL: Use spawn_blocking for file open to avoid blocking tokio runtime
132        let lock_path_clone = lock_path.clone();
133        let file = tokio::task::spawn_blocking(move || {
134            OpenOptions::new().create(true).write(true).truncate(false).open(&lock_path_clone)
135        })
136        .await
137        .with_context(|| "spawn_blocking panicked")?
138        .with_context(|| format!("Failed to open lock file: {}", lock_path.display()))?;
139
140        // Wrap file in Arc for sharing with spawn_blocking
141        let file = Arc::new(file);
142
143        // Acquire exclusive lock with timeout and exponential backoff
144        let start = std::time::Instant::now();
145
146        // Create exponential backoff strategy: 10ms, 20ms, 40ms... capped at 500ms
147        let backoff = ExponentialBackoff::from_millis(STARTING_BACKOFF_DELAY_MS)
148            .max_delay(Duration::from_millis(MAX_BACKOFF_DELAY_MS));
149
150        for delay in backoff {
151            // CRITICAL: Use spawn_blocking for try_lock_exclusive to avoid blocking tokio runtime
152            let file_clone = Arc::clone(&file);
153            let lock_result = tokio::task::spawn_blocking(move || file_clone.try_lock_exclusive())
154                .await
155                .with_context(|| "spawn_blocking panicked")?;
156
157            match lock_result {
158                Ok(true) => {
159                    debug!(
160                        lock_name = %display_name,
161                        wait_ms = start.elapsed().as_millis(),
162                        "Project lock acquired"
163                    );
164                    return Ok(Self {
165                        _file: file,
166                        lock_name: display_name,
167                        lock_path,
168                    });
169                }
170                Ok(false) | Err(_) => {
171                    // Check remaining time before sleeping to avoid exceeding timeout
172                    let remaining = timeout.saturating_sub(start.elapsed());
173                    if remaining.is_zero() {
174                        return Err(anyhow::anyhow!(
175                            "Timeout acquiring project lock '{}' after {:?}",
176                            lock_name,
177                            timeout
178                        ));
179                    }
180                    // Sleep for the shorter of delay or remaining time
181                    tokio::time::sleep(delay.min(remaining)).await;
182                }
183            }
184        }
185
186        // If backoff iterator exhausted without acquiring lock, return timeout error
187        Err(anyhow::anyhow!("Timeout acquiring project lock '{}' after {:?}", lock_name, timeout))
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use tempfile::TempDir;
195
196    #[tokio::test]
197    async fn test_project_lock_acquire_and_release() {
198        let temp_dir = TempDir::new().unwrap();
199        let project_dir = temp_dir.path();
200
201        // Acquire lock
202        let lock = ProjectLock::acquire(project_dir, "test").await.unwrap();
203
204        // Verify lock file was created
205        let lock_path = project_dir.join(".agpm").join(".locks").join("test.lock");
206        assert!(lock_path.exists());
207
208        // Drop the lock
209        drop(lock);
210
211        // Lock file should be deleted on drop
212        assert!(!lock_path.exists());
213    }
214
215    #[tokio::test]
216    async fn test_project_lock_creates_directories() {
217        let temp_dir = TempDir::new().unwrap();
218        let project_dir = temp_dir.path();
219
220        // Directories shouldn't exist initially
221        let locks_dir = project_dir.join(".agpm").join(".locks");
222        assert!(!locks_dir.exists());
223
224        // Acquire lock - should create directories
225        let lock = ProjectLock::acquire(project_dir, "test").await.unwrap();
226
227        // Verify directories were created
228        assert!(locks_dir.exists());
229        assert!(locks_dir.is_dir());
230
231        drop(lock);
232    }
233
234    #[tokio::test]
235    async fn test_project_lock_exclusive_blocking() {
236        use std::sync::Arc;
237        use std::time::{Duration, Instant};
238        use tokio::sync::Barrier;
239
240        let temp_dir = TempDir::new().unwrap();
241        let project_dir = Arc::new(temp_dir.path().to_path_buf());
242        let barrier = Arc::new(Barrier::new(2));
243
244        let project_dir1 = project_dir.clone();
245        let barrier1 = barrier.clone();
246
247        // Task 1: Acquire lock and hold it
248        let handle1 = tokio::spawn(async move {
249            let _lock = ProjectLock::acquire(&project_dir1, "exclusive_test").await.unwrap();
250            barrier1.wait().await; // Signal that lock is acquired
251            tokio::time::sleep(Duration::from_millis(100)).await; // Hold lock
252            // Lock released on drop
253        });
254
255        let project_dir2 = project_dir.clone();
256
257        // Task 2: Try to acquire same lock (should block)
258        let handle2 = tokio::spawn(async move {
259            barrier.wait().await; // Wait for first task to acquire lock
260            let start = Instant::now();
261            let _lock = ProjectLock::acquire(&project_dir2, "exclusive_test").await.unwrap();
262            let elapsed = start.elapsed();
263
264            // Should have blocked for at least 50ms (less than 100ms due to timing)
265            assert!(elapsed >= Duration::from_millis(50));
266        });
267
268        handle1.await.unwrap();
269        handle2.await.unwrap();
270    }
271
272    #[tokio::test]
273    async fn test_project_lock_different_names_dont_block() {
274        use std::sync::Arc;
275        use std::time::{Duration, Instant};
276        use tokio::sync::Barrier;
277
278        let temp_dir = TempDir::new().unwrap();
279        let project_dir = Arc::new(temp_dir.path().to_path_buf());
280        let barrier = Arc::new(Barrier::new(2));
281
282        let project_dir1 = project_dir.clone();
283        let barrier1 = barrier.clone();
284
285        // Task 1: Lock "lock1"
286        let handle1 = tokio::spawn(async move {
287            let _lock = ProjectLock::acquire(&project_dir1, "lock1").await.unwrap();
288            barrier1.wait().await;
289            tokio::time::sleep(Duration::from_millis(100)).await;
290        });
291
292        let project_dir2 = project_dir.clone();
293
294        // Task 2: Lock "lock2" (different name, shouldn't block)
295        let handle2 = tokio::spawn(async move {
296            barrier.wait().await;
297            let start = Instant::now();
298            let _lock = ProjectLock::acquire(&project_dir2, "lock2").await.unwrap();
299            let elapsed = start.elapsed();
300
301            // Should not block (complete quickly)
302            assert!(
303                elapsed < Duration::from_millis(200),
304                "Lock acquisition took {:?}, expected < 200ms for non-blocking operation",
305                elapsed
306            );
307        });
308
309        handle1.await.unwrap();
310        handle2.await.unwrap();
311    }
312
313    #[tokio::test]
314    async fn test_project_lock_acquire_timeout() {
315        let temp_dir = TempDir::new().unwrap();
316        let project_dir = temp_dir.path();
317
318        // First lock succeeds
319        let _lock1 = ProjectLock::acquire(project_dir, "test").await.unwrap();
320
321        // Second lock attempt should timeout quickly
322        let start = std::time::Instant::now();
323        let result =
324            ProjectLock::acquire_with_timeout(project_dir, "test", Duration::from_millis(100))
325                .await;
326
327        let elapsed = start.elapsed();
328
329        // Verify timeout occurred
330        assert!(result.is_err(), "Expected timeout error");
331
332        // Verify error message mentions timeout
333        let error_msg = result.unwrap_err().to_string();
334        assert!(
335            error_msg.contains("Timeout") || error_msg.contains("timeout"),
336            "Error message should mention timeout: {}",
337            error_msg
338        );
339
340        // Verify timeout happened around the expected time
341        assert!(elapsed >= Duration::from_millis(50), "Timeout too quick: {:?}", elapsed);
342        assert!(elapsed < Duration::from_millis(500), "Timeout too slow: {:?}", elapsed);
343    }
344}