Skip to main content

soar_utils/
lock.rs

1//! File-based locking mechanism for preventing concurrent operations.
2//!
3//! This module provides a simple file-based lock using a `.lock` file to ensure
4//! that only one process can operate on a specific resource at a time.
5
6use std::{
7    fs::{self, File, OpenOptions},
8    path::{Path, PathBuf},
9};
10
11use crate::error::{LockError, LockResult};
12
13/// A file-based lock using `flock`.
14///
15/// The lock is automatically released when `FileLock` is dropped.
16pub struct FileLock {
17    _file: nix::fcntl::Flock<File>,
18    path: PathBuf,
19}
20
21impl FileLock {
22    /// Get the default lock directory for soar.
23    ///
24    /// Uses `$XDG_RUNTIME_DIR/soar/locks` or falls back to `/tmp/soar-locks`.
25    fn lock_dir() -> LockResult<PathBuf> {
26        let xdg_runtime = std::env::var("XDG_RUNTIME_DIR").ok();
27        let base = if let Some(ref runtime) = xdg_runtime {
28            PathBuf::from(runtime)
29        } else {
30            std::env::temp_dir()
31        };
32
33        let lock_dir = base.join("soar").join("locks");
34
35        if !lock_dir.exists() {
36            fs::create_dir_all(&lock_dir)?;
37        }
38
39        Ok(lock_dir)
40    }
41
42    /// Generate a lock file path for a package.
43    fn lock_path(name: &str) -> LockResult<PathBuf> {
44        let lock_dir = Self::lock_dir()?;
45
46        // Sanitize the package name to ensure a valid filename
47        let sanitize = |s: &str| {
48            s.chars()
49                .map(|c| {
50                    if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
51                        c
52                    } else {
53                        '_'
54                    }
55                })
56                .collect::<String>()
57        };
58
59        let filename = format!("{}.lock", sanitize(name));
60        Ok(lock_dir.join(filename))
61    }
62
63    /// Acquire an exclusive lock on a package.
64    ///
65    /// This will block until the lock can be acquired.
66    ///
67    /// # Arguments
68    ///
69    /// * `name` - Package name
70    ///
71    /// # Returns
72    ///
73    /// Returns a `FileLock` that will automatically release the lock when dropped.
74    pub fn acquire(name: &str) -> LockResult<Self> {
75        let lock_path = Self::lock_path(name)?;
76
77        let file = OpenOptions::new()
78            .write(true)
79            .create(true)
80            .truncate(false)
81            .open(&lock_path)?;
82
83        let file = nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusive).map_err(
84            |(_, err)| LockError::AcquireFailed(format!("{}: {}", lock_path.display(), err)),
85        )?;
86
87        Ok(FileLock {
88            path: lock_path,
89            _file: file,
90        })
91    }
92
93    /// Try to acquire an exclusive lock without blocking.
94    ///
95    /// Returns `None` if the lock is already held by another process.
96    ///
97    /// # Arguments
98    ///
99    /// * `name` - Package name
100    pub fn try_acquire(name: &str) -> LockResult<Option<Self>> {
101        let lock_path = Self::lock_path(name)?;
102
103        let file = OpenOptions::new()
104            .write(true)
105            .create(true)
106            .truncate(false)
107            .open(&lock_path)?;
108
109        match nix::fcntl::Flock::lock(file, nix::fcntl::FlockArg::LockExclusiveNonblock) {
110            Ok(file) => {
111                Ok(Some(FileLock {
112                    path: lock_path,
113                    _file: file,
114                }))
115            }
116            Err((_, err)) => {
117                if matches!(err, nix::errno::Errno::EWOULDBLOCK) {
118                    return Ok(None);
119                }
120                Err(LockError::AcquireFailed(format!(
121                    "{}: {}",
122                    lock_path.display(),
123                    err
124                )))
125            }
126        }
127    }
128
129    /// Get the path to the lock file.
130    pub fn path(&self) -> &Path {
131        &self.path
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use std::{thread, time::Duration};
138
139    use super::*;
140
141    #[test]
142    fn test_lock_path_generation() {
143        let path = FileLock::lock_path("test-pkg").unwrap();
144        assert!(path.to_string_lossy().ends_with("test-pkg.lock"));
145    }
146
147    #[test]
148    fn test_lock_sanitization() {
149        let path = FileLock::lock_path("test/pkg").unwrap();
150        assert!(path.to_string_lossy().contains("test_pkg"));
151    }
152
153    #[test]
154    fn test_exclusive_lock() {
155        let lock1 = FileLock::acquire("test-exclusive").unwrap();
156
157        let lock2 = FileLock::try_acquire("test-exclusive").unwrap();
158        assert!(lock2.is_none(), "Should not be able to acquire lock");
159
160        drop(lock1);
161
162        let lock3 = FileLock::try_acquire("test-exclusive").unwrap();
163        assert!(
164            lock3.is_some(),
165            "Should be able to acquire lock after release"
166        );
167    }
168
169    #[test]
170    fn test_concurrent_locks_different_packages() {
171        let lock1 = FileLock::acquire("pkg-a").unwrap();
172        let lock2 = FileLock::acquire("pkg-b").unwrap();
173
174        assert!(lock1.path() != lock2.path());
175    }
176
177    #[test]
178    fn test_lock_blocks_until_released() {
179        let lock1 = FileLock::acquire("test-block").unwrap();
180        let path = lock1.path().to_path_buf();
181
182        let handle = thread::spawn(move || {
183            let lock2 = FileLock::acquire("test-block").unwrap();
184            assert_eq!(lock2.path(), &path);
185        });
186
187        thread::sleep(Duration::from_millis(100));
188
189        drop(lock1);
190
191        handle.join().unwrap();
192    }
193}