agpm_cli/cache/
lock.rs

1//! File locking utilities for cache operations.
2//!
3//! This module provides thread-safe and process-safe file locking for cache directories
4//! to prevent corruption during concurrent cache operations. The locks are automatically
5//! released when 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 (e.g., network-attached storage).
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;
18use std::sync::Arc;
19use std::time::Duration;
20use tokio_retry::strategy::ExponentialBackoff;
21use tracing::debug;
22
23/// A file lock for cache operations.
24///
25/// The lock is held for the lifetime of this struct and automatically
26/// released when dropped. Lock acquisition and release are tracked
27/// for deadlock detection.
28#[derive(Debug)]
29pub struct CacheLock {
30    /// The file handle - lock is released when this is dropped
31    _file: Arc<File>,
32    /// Name of the lock for tracing
33    lock_name: String,
34}
35
36impl Drop for CacheLock {
37    fn drop(&mut self) {
38        debug!(lock_name = %self.lock_name, "File lock released");
39    }
40}
41
42impl CacheLock {
43    /// Acquires an exclusive lock for a specific source in the cache directory.
44    ///
45    /// Creates and acquires an exclusive file lock for the specified source name.
46    /// Uses non-blocking lock attempts with exponential backoff and timeout.
47    ///
48    /// # Lock File Management
49    ///
50    /// 1. Creates `.locks/` directory if needed
51    /// 2. Creates `{source_name}.lock` file
52    /// 3. Acquires exclusive access via OS file locking
53    /// 4. Keeps file handle open to maintain lock
54    ///
55    /// # Behavior
56    ///
57    /// - **Timeout**: 30-second default (configurable via `acquire_with_timeout`)
58    /// - **Non-blocking**: `try_lock_exclusive()` in async retry loop
59    /// - **Backoff**: 10ms → 20ms → 40ms... up to 500ms max
60    /// - **Fair access**: FIFO order typically
61    /// - **Interruptible**: Process signals work
62    ///
63    /// # Lock File Location
64    ///
65    /// Format: `{cache_dir}/.locks/{source_name}.lock`
66    ///
67    /// Example: `~/.agpm/cache/.locks/community.lock`
68    ///
69    /// # Errors
70    ///
71    /// - Permission denied
72    /// - Disk space exhausted
73    /// - Timeout acquiring lock
74    ///
75    /// # Platform Support
76    ///
77    /// - **Windows**: Win32 `LockFile` API
78    /// - **Unix**: POSIX `fcntl()` locking
79    ///
80    /// # Examples
81    ///
82    /// ```rust,no_run
83    /// use agpm_cli::cache::lock::CacheLock;
84    /// use std::path::Path;
85    /// # async fn example() -> anyhow::Result<()> {
86    /// # let cache_dir = Path::new("/tmp/cache");
87    /// let lock = CacheLock::acquire(cache_dir, "my-source").await?;
88    /// // Lock released on drop
89    /// # Ok(())
90    /// # }
91    /// ```
92    pub async fn acquire(cache_dir: &Path, source_name: &str) -> Result<Self> {
93        Self::acquire_with_timeout(cache_dir, source_name, default_lock_timeout()).await
94    }
95
96    /// Acquires an exclusive lock with a specified timeout.
97    ///
98    /// Uses exponential backoff (10ms → 500ms) without blocking the async runtime.
99    ///
100    /// # Errors
101    ///
102    /// Returns timeout error if lock cannot be acquired within the specified duration.
103    ///
104    /// # Examples
105    ///
106    /// ```rust,no_run
107    /// use agpm_cli::cache::lock::CacheLock;
108    /// use std::time::Duration;
109    /// use std::path::Path;
110    /// # async fn example() -> anyhow::Result<()> {
111    /// # let cache_dir = Path::new("/tmp/cache");
112    /// let lock = CacheLock::acquire_with_timeout(
113    ///     cache_dir, "my-source", Duration::from_secs(10)
114    /// ).await?;
115    /// # Ok(())
116    /// # }
117    /// ```
118    pub async fn acquire_with_timeout(
119        cache_dir: &Path,
120        source_name: &str,
121        timeout: std::time::Duration,
122    ) -> Result<Self> {
123        use tokio::fs;
124
125        let lock_name = format!("file:{}", source_name);
126        debug!(lock_name = %lock_name, "Waiting for file lock");
127
128        // Create locks directory if it doesn't exist
129        let locks_dir = cache_dir.join(".locks");
130        fs::create_dir_all(&locks_dir).await.with_context(|| {
131            format!("Failed to create locks directory: {}", locks_dir.display())
132        })?;
133
134        // Create lock file path
135        let lock_path = locks_dir.join(format!("{source_name}.lock"));
136
137        // CRITICAL: Use spawn_blocking for file open to avoid blocking tokio runtime
138        // This is essential for preventing worker thread starvation under slow I/O
139        let lock_path_clone = lock_path.clone();
140        let file = tokio::task::spawn_blocking(move || {
141            OpenOptions::new().create(true).write(true).truncate(false).open(&lock_path_clone)
142        })
143        .await
144        .with_context(|| "spawn_blocking panicked")?
145        .with_context(|| format!("Failed to open lock file: {}", lock_path.display()))?;
146
147        // Wrap file in Arc for sharing with spawn_blocking
148        let file = Arc::new(file);
149
150        // Acquire exclusive lock with timeout and exponential backoff
151        let start = std::time::Instant::now();
152
153        // Create exponential backoff strategy with platform-specific tuning:
154        // - Windows: 25ms, 50ms, 100ms, 200ms (faster retries for AV delays)
155        // - Unix: 10ms, 20ms, 40ms, ... 500ms (standard backoff)
156        let backoff = ExponentialBackoff::from_millis(STARTING_BACKOFF_DELAY_MS)
157            .max_delay(Duration::from_millis(MAX_BACKOFF_DELAY_MS));
158
159        // Add jitter to prevent thundering herd when multiple processes retry simultaneously
160        let mut rng_state: u64 = std::time::SystemTime::now()
161            .duration_since(std::time::UNIX_EPOCH)
162            .map(|d| d.as_nanos() as u64)
163            .unwrap_or(12345);
164
165        for delay in backoff {
166            // Simple xorshift for jitter: adds 0-25% random variation
167            rng_state ^= rng_state << 13;
168            rng_state ^= rng_state >> 7;
169            rng_state ^= rng_state << 17;
170            let jitter_factor = 1.0 + (rng_state % 25) as f64 / 100.0;
171            let jittered_delay =
172                Duration::from_millis((delay.as_millis() as f64 * jitter_factor) as u64);
173            // CRITICAL: Use spawn_blocking for try_lock_exclusive to avoid blocking tokio runtime
174            let file_clone = Arc::clone(&file);
175            let lock_result = tokio::task::spawn_blocking(move || file_clone.try_lock_exclusive())
176                .await
177                .with_context(|| "spawn_blocking panicked")?;
178
179            match lock_result {
180                Ok(true) => {
181                    debug!(
182                        lock_name = %lock_name,
183                        wait_ms = start.elapsed().as_millis(),
184                        "File lock acquired"
185                    );
186                    return Ok(Self {
187                        _file: file,
188                        lock_name,
189                    });
190                }
191                Ok(false) | Err(_) => {
192                    // Check remaining time before sleeping to avoid exceeding timeout
193                    let remaining = timeout.saturating_sub(start.elapsed());
194                    if remaining.is_zero() {
195                        return Err(anyhow::anyhow!(
196                            "Timeout acquiring lock for '{}' after {:?}",
197                            source_name,
198                            timeout
199                        ));
200                    }
201                    // Sleep for the shorter of jittered delay or remaining time
202                    tokio::time::sleep(jittered_delay.min(remaining)).await;
203                }
204            }
205        }
206
207        // If backoff iterator exhausted without acquiring lock, return timeout error
208        Err(anyhow::anyhow!("Timeout acquiring lock for '{}' after {:?}", source_name, timeout))
209    }
210
211    /// Acquires a shared (read) lock for a specific source in the cache directory.
212    ///
213    /// Multiple processes can hold shared locks simultaneously, but a shared lock
214    /// blocks exclusive lock acquisition. Use this for operations that can safely
215    /// run in parallel, like worktree creation (each SHA writes to a different subdir).
216    ///
217    /// # Lock Semantics
218    ///
219    /// - **Shared locks**: Multiple holders allowed simultaneously
220    /// - **Exclusive locks**: Blocked while any shared lock is held
221    /// - **Shared + Exclusive**: Shared lock blocks until exclusive is released
222    ///
223    /// # Use Cases
224    ///
225    /// - Worktree creation: Multiple SHAs can create worktrees in parallel
226    /// - Read-only operations on shared state
227    ///
228    /// # Examples
229    ///
230    /// ```rust,no_run
231    /// use agpm_cli::cache::lock::CacheLock;
232    /// use std::path::Path;
233    /// # async fn example() -> anyhow::Result<()> {
234    /// # let cache_dir = Path::new("/tmp/cache");
235    /// // Multiple processes can hold this simultaneously
236    /// let lock = CacheLock::acquire_shared(cache_dir, "bare-worktree-owner_repo").await?;
237    /// // Lock released on drop
238    /// # Ok(())
239    /// # }
240    /// ```
241    pub async fn acquire_shared(cache_dir: &Path, source_name: &str) -> Result<Self> {
242        Self::acquire_shared_with_timeout(cache_dir, source_name, default_lock_timeout()).await
243    }
244
245    /// Acquires a shared (read) lock with a specified timeout.
246    ///
247    /// Uses exponential backoff (10ms → 500ms) without blocking the async runtime.
248    ///
249    /// # Errors
250    ///
251    /// Returns timeout error if lock cannot be acquired within the specified duration.
252    pub async fn acquire_shared_with_timeout(
253        cache_dir: &Path,
254        source_name: &str,
255        timeout: std::time::Duration,
256    ) -> Result<Self> {
257        use tokio::fs;
258
259        let lock_name = format!("file-shared:{}", source_name);
260        debug!(lock_name = %lock_name, "Waiting for shared file lock");
261
262        // Create locks directory if it doesn't exist
263        let locks_dir = cache_dir.join(".locks");
264        fs::create_dir_all(&locks_dir).await.with_context(|| {
265            format!("Failed to create locks directory: {}", locks_dir.display())
266        })?;
267
268        // Create lock file path
269        let lock_path = locks_dir.join(format!("{source_name}.lock"));
270
271        // CRITICAL: Use spawn_blocking for file open to avoid blocking tokio runtime
272        let lock_path_clone = lock_path.clone();
273        let file = tokio::task::spawn_blocking(move || {
274            OpenOptions::new().create(true).write(true).truncate(false).open(&lock_path_clone)
275        })
276        .await
277        .with_context(|| "spawn_blocking panicked")?
278        .with_context(|| format!("Failed to open lock file: {}", lock_path.display()))?;
279
280        // Wrap file in Arc for sharing with spawn_blocking
281        let file = Arc::new(file);
282
283        // Acquire shared lock with timeout and exponential backoff
284        let start = std::time::Instant::now();
285
286        let backoff = ExponentialBackoff::from_millis(STARTING_BACKOFF_DELAY_MS)
287            .max_delay(Duration::from_millis(MAX_BACKOFF_DELAY_MS));
288
289        // Add jitter to prevent thundering herd
290        let mut rng_state: u64 = std::time::SystemTime::now()
291            .duration_since(std::time::UNIX_EPOCH)
292            .map(|d| d.as_nanos() as u64)
293            .unwrap_or(12345);
294
295        for delay in backoff {
296            rng_state ^= rng_state << 13;
297            rng_state ^= rng_state >> 7;
298            rng_state ^= rng_state << 17;
299            let jitter_factor = 1.0 + (rng_state % 25) as f64 / 100.0;
300            let jittered_delay =
301                Duration::from_millis((delay.as_millis() as f64 * jitter_factor) as u64);
302
303            // CRITICAL: Use spawn_blocking for try_lock_shared to avoid blocking tokio runtime
304            // Use FileExt trait method explicitly to avoid std::fs::File::try_lock_shared
305            let file_clone = Arc::clone(&file);
306            let lock_result =
307                tokio::task::spawn_blocking(move || FileExt::try_lock_shared(file_clone.as_ref()))
308                    .await
309                    .with_context(|| "spawn_blocking panicked")?;
310
311            match lock_result {
312                Ok(true) => {
313                    debug!(
314                        lock_name = %lock_name,
315                        wait_ms = start.elapsed().as_millis(),
316                        "Shared file lock acquired"
317                    );
318                    return Ok(Self {
319                        _file: file,
320                        lock_name,
321                    });
322                }
323                Ok(false) | Err(_) => {
324                    // Check remaining time before sleeping
325                    let remaining = timeout.saturating_sub(start.elapsed());
326                    if remaining.is_zero() {
327                        return Err(anyhow::anyhow!(
328                            "Timeout acquiring shared lock for '{}' after {:?}",
329                            source_name,
330                            timeout
331                        ));
332                    }
333                    tokio::time::sleep(jittered_delay.min(remaining)).await;
334                }
335            }
336        }
337
338        Err(anyhow::anyhow!(
339            "Timeout acquiring shared lock for '{}' after {:?}",
340            source_name,
341            timeout
342        ))
343    }
344}
345
346/// Cleans up stale lock files in the cache directory.
347///
348/// This function removes lock files that are older than the specified TTL.
349/// It's useful for cleaning up after crashes or processes that didn't
350/// properly release their locks.
351///
352/// # Parameters
353///
354/// * `cache_dir` - The cache directory containing the .locks subdirectory
355/// * `ttl_seconds` - Time-to-live in seconds for lock files
356///
357/// # Returns
358///
359/// Returns the number of lock files that were removed.
360///
361/// # Errors
362///
363/// Returns an error if unable to read the locks directory or access lock file metadata
364pub async fn cleanup_stale_locks(cache_dir: &Path, ttl_seconds: u64) -> Result<usize> {
365    use std::time::{Duration, SystemTime};
366    use tokio::fs;
367
368    let locks_dir = cache_dir.join(".locks");
369    if !locks_dir.exists() {
370        return Ok(0);
371    }
372
373    let mut removed_count = 0;
374    let now = SystemTime::now();
375    let ttl_duration = Duration::from_secs(ttl_seconds);
376
377    let mut entries = fs::read_dir(&locks_dir).await.context("Failed to read locks directory")?;
378
379    while let Some(entry) = entries.next_entry().await? {
380        let path = entry.path();
381
382        // Only process .lock files
383        if path.extension().and_then(|s| s.to_str()) != Some("lock") {
384            continue;
385        }
386
387        // Check file age
388        let Ok(metadata) = fs::metadata(&path).await else {
389            continue; // Skip if we can't read metadata
390        };
391
392        let Ok(modified) = metadata.modified() else {
393            continue; // Skip if we can't get modification time
394        };
395
396        // Remove if older than TTL
397        if let Ok(age) = now.duration_since(modified)
398            && age > ttl_duration
399        {
400            // Try to remove the file (it might be locked by another process)
401            if fs::remove_file(&path).await.is_ok() {
402                removed_count += 1;
403            }
404        }
405    }
406
407    Ok(removed_count)
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413    use tempfile::TempDir;
414
415    #[tokio::test]
416    async fn test_cache_lock_acquire_and_release() {
417        let temp_dir = TempDir::new().unwrap();
418        let cache_dir = temp_dir.path();
419
420        // Acquire lock
421        let lock = CacheLock::acquire(cache_dir, "test_source").await.unwrap();
422
423        // Verify lock file was created
424        let lock_path = cache_dir.join(".locks").join("test_source.lock");
425        assert!(lock_path.exists());
426
427        // Drop the lock
428        drop(lock);
429
430        // Lock file should still exist (we don't delete it)
431        assert!(lock_path.exists());
432    }
433
434    #[tokio::test]
435    async fn test_cache_lock_creates_locks_directory() {
436        let temp_dir = TempDir::new().unwrap();
437        let cache_dir = temp_dir.path();
438
439        // Locks directory shouldn't exist initially
440        let locks_dir = cache_dir.join(".locks");
441        assert!(!locks_dir.exists());
442
443        // Acquire lock - should create directory
444        let lock = CacheLock::acquire(cache_dir, "test").await.unwrap();
445
446        // Verify locks directory was created
447        assert!(locks_dir.exists());
448        assert!(locks_dir.is_dir());
449
450        // Explicitly drop the lock to release the file handle before TempDir cleanup
451        drop(lock);
452    }
453
454    #[tokio::test]
455    async fn test_cache_lock_exclusive_blocking() {
456        use std::sync::Arc;
457        use std::time::{Duration, Instant};
458        use tokio::sync::Barrier;
459
460        let temp_dir = TempDir::new().unwrap();
461        let cache_dir = Arc::new(temp_dir.path().to_path_buf());
462        let barrier = Arc::new(Barrier::new(2));
463
464        let cache_dir1 = cache_dir.clone();
465        let barrier1 = barrier.clone();
466
467        // Task 1: Acquire lock and hold it
468        let handle1 = tokio::spawn(async move {
469            let _lock = CacheLock::acquire(&cache_dir1, "exclusive_test").await.unwrap();
470            barrier1.wait().await; // Signal that lock is acquired
471            tokio::time::sleep(Duration::from_millis(100)).await; // Hold lock
472            // Lock released on drop
473        });
474
475        let cache_dir2 = cache_dir.clone();
476
477        // Task 2: Try to acquire same lock (should block)
478        let handle2 = tokio::spawn(async move {
479            barrier.wait().await; // Wait for first task to acquire lock
480            let start = Instant::now();
481            let _lock = CacheLock::acquire(&cache_dir2, "exclusive_test").await.unwrap();
482            let elapsed = start.elapsed();
483
484            // Should have blocked for at least 50ms (less than 100ms due to timing)
485            assert!(elapsed >= Duration::from_millis(50));
486        });
487
488        handle1.await.unwrap();
489        handle2.await.unwrap();
490    }
491
492    #[tokio::test]
493    async fn test_cache_lock_different_sources_dont_block() {
494        use std::sync::Arc;
495        use std::time::{Duration, Instant};
496        use tokio::sync::Barrier;
497
498        let temp_dir = TempDir::new().unwrap();
499        let cache_dir = Arc::new(temp_dir.path().to_path_buf());
500        let barrier = Arc::new(Barrier::new(2));
501
502        let cache_dir1 = cache_dir.clone();
503        let barrier1 = barrier.clone();
504
505        // Task 1: Lock source1
506        let handle1 = tokio::spawn(async move {
507            let _lock = CacheLock::acquire(&cache_dir1, "source1").await.unwrap();
508            barrier1.wait().await;
509            tokio::time::sleep(Duration::from_millis(100)).await;
510        });
511
512        let cache_dir2 = cache_dir.clone();
513
514        // Task 2: Lock source2 (different source, shouldn't block)
515        let handle2 = tokio::spawn(async move {
516            barrier.wait().await;
517            let start = Instant::now();
518            let _lock = CacheLock::acquire(&cache_dir2, "source2").await.unwrap();
519            let elapsed = start.elapsed();
520
521            // Should not block (complete quickly)
522            // Increased timeout for slower systems while still ensuring no blocking
523            assert!(
524                elapsed < Duration::from_millis(200),
525                "Lock acquisition took {:?}, expected < 200ms for non-blocking operation",
526                elapsed
527            );
528        });
529
530        handle1.await.unwrap();
531        handle2.await.unwrap();
532    }
533
534    #[tokio::test]
535    async fn test_cache_lock_path_with_special_characters() {
536        let temp_dir = TempDir::new().unwrap();
537        let cache_dir = temp_dir.path();
538
539        // Test with various special characters in source name
540        let special_names = vec![
541            "source-with-dash",
542            "source_with_underscore",
543            "source.with.dots",
544            "source@special",
545        ];
546
547        for name in special_names {
548            let lock = CacheLock::acquire(cache_dir, name).await.unwrap();
549            let expected_path = cache_dir.join(".locks").join(format!("{name}.lock"));
550            assert!(expected_path.exists());
551            drop(lock);
552        }
553    }
554
555    #[tokio::test]
556    async fn test_cache_lock_acquire_timeout() {
557        let temp_dir = TempDir::new().unwrap();
558        let cache_dir = temp_dir.path();
559
560        // First lock succeeds
561        let _lock1 = CacheLock::acquire(cache_dir, "test-source").await.unwrap();
562
563        // Second lock attempt should timeout quickly
564        let start = std::time::Instant::now();
565        let result =
566            CacheLock::acquire_with_timeout(cache_dir, "test-source", Duration::from_millis(100))
567                .await;
568
569        let elapsed = start.elapsed();
570
571        // Verify timeout occurred
572        assert!(result.is_err(), "Expected timeout error");
573
574        // Verify error message mentions timeout
575        match result {
576            Ok(_) => panic!("Expected timeout error, but got success"),
577            Err(error) => {
578                let error_msg = error.to_string();
579                assert!(
580                    error_msg.contains("Timeout") || error_msg.contains("timeout"),
581                    "Error message should mention timeout: {}",
582                    error_msg
583                );
584                assert!(
585                    error_msg.contains("test-source"),
586                    "Error message should include source name: {}",
587                    error_msg
588                );
589            }
590        }
591
592        // Verify timeout happened around the expected time (with some tolerance)
593        // Should be ~100ms, allow 50-500ms range to accommodate slow CI runners
594        assert!(elapsed >= Duration::from_millis(50), "Timeout too quick: {:?}", elapsed);
595        assert!(elapsed < Duration::from_millis(500), "Timeout too slow: {:?}", elapsed);
596    }
597
598    #[tokio::test]
599    async fn test_cache_lock_acquire_timeout_succeeds_eventually() {
600        let temp_dir = TempDir::new().unwrap();
601        let cache_dir = temp_dir.path();
602
603        // Acquire lock and release it after 50ms
604        let cache_dir_clone = cache_dir.to_path_buf();
605        let handle = tokio::spawn(async move {
606            let lock = CacheLock::acquire(&cache_dir_clone, "test-source").await.unwrap();
607            tokio::time::sleep(Duration::from_millis(50)).await;
608            drop(lock); // Release lock
609        });
610
611        // Wait a bit for the first lock to be acquired
612        tokio::time::sleep(Duration::from_millis(10)).await;
613
614        // This should succeed after the first lock is released
615        // Use 500ms timeout to give plenty of time
616        let result =
617            CacheLock::acquire_with_timeout(cache_dir, "test-source", Duration::from_millis(500))
618                .await;
619
620        assert!(result.is_ok(), "Lock should be acquired after first one is released");
621
622        // Clean up spawned task
623        handle.await.unwrap();
624    }
625
626    #[tokio::test]
627    async fn test_shared_locks_dont_block_each_other() {
628        use std::sync::Arc;
629        use std::time::{Duration, Instant};
630        use tokio::sync::Barrier;
631
632        let temp_dir = TempDir::new().unwrap();
633        let cache_dir = Arc::new(temp_dir.path().to_path_buf());
634        let barrier = Arc::new(Barrier::new(2));
635
636        let cache_dir1 = cache_dir.clone();
637        let barrier1 = barrier.clone();
638
639        // Task 1: Acquire shared lock and hold it
640        let handle1 = tokio::spawn(async move {
641            let _lock = CacheLock::acquire_shared(&cache_dir1, "shared_test").await.unwrap();
642            barrier1.wait().await; // Signal that lock is acquired
643            tokio::time::sleep(Duration::from_millis(100)).await; // Hold lock
644        });
645
646        let cache_dir2 = cache_dir.clone();
647
648        // Task 2: Acquire another shared lock on same resource (should NOT block)
649        let handle2 = tokio::spawn(async move {
650            barrier.wait().await; // Wait for first task to acquire lock
651            let start = Instant::now();
652            let _lock = CacheLock::acquire_shared(&cache_dir2, "shared_test").await.unwrap();
653            let elapsed = start.elapsed();
654
655            // Should complete quickly since shared locks don't block each other
656            assert!(
657                elapsed < Duration::from_millis(200),
658                "Shared lock took {:?}, expected < 200ms (no blocking)",
659                elapsed
660            );
661        });
662
663        handle1.await.unwrap();
664        handle2.await.unwrap();
665    }
666
667    #[tokio::test]
668    async fn test_exclusive_blocks_shared() {
669        use std::sync::Arc;
670        use std::time::{Duration, Instant};
671        use tokio::sync::Barrier;
672
673        let temp_dir = TempDir::new().unwrap();
674        let cache_dir = Arc::new(temp_dir.path().to_path_buf());
675        let barrier = Arc::new(Barrier::new(2));
676
677        let cache_dir1 = cache_dir.clone();
678        let barrier1 = barrier.clone();
679
680        // Task 1: Acquire EXCLUSIVE lock and hold it
681        let handle1 = tokio::spawn(async move {
682            let _lock = CacheLock::acquire(&cache_dir1, "exclusive_shared_test").await.unwrap();
683            barrier1.wait().await;
684            tokio::time::sleep(Duration::from_millis(100)).await;
685        });
686
687        let cache_dir2 = cache_dir.clone();
688
689        // Task 2: Try to acquire SHARED lock (should block until exclusive releases)
690        let handle2 = tokio::spawn(async move {
691            barrier.wait().await;
692            let start = Instant::now();
693            let _lock =
694                CacheLock::acquire_shared(&cache_dir2, "exclusive_shared_test").await.unwrap();
695            let elapsed = start.elapsed();
696
697            // Should have blocked for at least 50ms
698            assert!(
699                elapsed >= Duration::from_millis(50),
700                "Shared lock should have blocked: {:?}",
701                elapsed
702            );
703        });
704
705        handle1.await.unwrap();
706        handle2.await.unwrap();
707    }
708
709    #[tokio::test]
710    async fn test_shared_blocks_exclusive() {
711        use std::sync::Arc;
712        use std::time::{Duration, Instant};
713        use tokio::sync::Barrier;
714
715        let temp_dir = TempDir::new().unwrap();
716        let cache_dir = Arc::new(temp_dir.path().to_path_buf());
717        let barrier = Arc::new(Barrier::new(2));
718
719        let cache_dir1 = cache_dir.clone();
720        let barrier1 = barrier.clone();
721
722        // Task 1: Acquire SHARED lock and hold it
723        let handle1 = tokio::spawn(async move {
724            let _lock =
725                CacheLock::acquire_shared(&cache_dir1, "shared_exclusive_test").await.unwrap();
726            barrier1.wait().await;
727            tokio::time::sleep(Duration::from_millis(100)).await;
728        });
729
730        let cache_dir2 = cache_dir.clone();
731
732        // Task 2: Try to acquire EXCLUSIVE lock (should block until shared releases)
733        let handle2 = tokio::spawn(async move {
734            barrier.wait().await;
735            let start = Instant::now();
736            let _lock = CacheLock::acquire(&cache_dir2, "shared_exclusive_test").await.unwrap();
737            let elapsed = start.elapsed();
738
739            // Should have blocked for at least 50ms
740            assert!(
741                elapsed >= Duration::from_millis(50),
742                "Exclusive lock should have blocked: {:?}",
743                elapsed
744            );
745        });
746
747        handle1.await.unwrap();
748        handle2.await.unwrap();
749    }
750}