agpm_cli/installer/
project_lock.rs1use 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#[derive(Debug)]
50pub struct ProjectLock {
51 _file: Arc<File>,
53 lock_name: String,
55 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 if let Err(e) = std::fs::remove_file(&self.lock_path) {
64 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 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 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 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 let lock_path = locks_dir.join(format!("{lock_name}.lock"));
130
131 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 let file = Arc::new(file);
142
143 let start = std::time::Instant::now();
145
146 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 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 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 tokio::time::sleep(delay.min(remaining)).await;
182 }
183 }
184 }
185
186 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 let lock = ProjectLock::acquire(project_dir, "test").await.unwrap();
203
204 let lock_path = project_dir.join(".agpm").join(".locks").join("test.lock");
206 assert!(lock_path.exists());
207
208 drop(lock);
210
211 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 let locks_dir = project_dir.join(".agpm").join(".locks");
222 assert!(!locks_dir.exists());
223
224 let lock = ProjectLock::acquire(project_dir, "test").await.unwrap();
226
227 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 let handle1 = tokio::spawn(async move {
249 let _lock = ProjectLock::acquire(&project_dir1, "exclusive_test").await.unwrap();
250 barrier1.wait().await; tokio::time::sleep(Duration::from_millis(100)).await; });
254
255 let project_dir2 = project_dir.clone();
256
257 let handle2 = tokio::spawn(async move {
259 barrier.wait().await; let start = Instant::now();
261 let _lock = ProjectLock::acquire(&project_dir2, "exclusive_test").await.unwrap();
262 let elapsed = start.elapsed();
263
264 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 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 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 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 let _lock1 = ProjectLock::acquire(project_dir, "test").await.unwrap();
320
321 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 assert!(result.is_err(), "Expected timeout error");
331
332 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 assert!(elapsed >= Duration::from_millis(50), "Timeout too quick: {:?}", elapsed);
342 assert!(elapsed < Duration::from_millis(500), "Timeout too slow: {:?}", elapsed);
343 }
344}