Skip to main content

workspace/
lock.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3use tokio::sync::Mutex;
4
5/// Manages per-project mutex locks to ensure concurrent runs for the same project
6/// are safely serialized without corrupting the persistent remote workspace.
7#[derive(Clone, Default)]
8pub struct WorkspaceLockManager {
9    locks: Arc<Mutex<HashMap<String, Arc<Mutex<()>>>>>,
10}
11
12impl WorkspaceLockManager {
13    pub fn new() -> Self {
14        Self::default()
15    }
16
17    /// Retrieve the shared mutex for the given project name.
18    pub async fn get_lock(&self, project: &str) -> Arc<Mutex<()>> {
19        let mut map = self.locks.lock().await;
20        map.entry(project.to_string())
21            .or_insert_with(|| Arc::new(Mutex::new(())))
22            .clone()
23    }
24
25    /// Whether a run currently holds this project's lock.
26    ///
27    /// Non-inserting: projects with no lock entry are never locked. Used by
28    /// GC and CLEAN paths to avoid deleting workspaces that are in use.
29    pub async fn is_locked(&self, project: &str) -> bool {
30        let map = self.locks.lock().await;
31        map.get(project)
32            .map(|m| m.try_lock().is_err())
33            .unwrap_or(false)
34    }
35
36    /// Snapshot of project names whose locks are currently held. Callers
37    /// building synchronous predicates (e.g. GC filters) use this to avoid
38    /// holding the manager's map across blocking work.
39    pub async fn locked_projects(&self) -> Vec<String> {
40        let map = self.locks.lock().await;
41        map.iter()
42            .filter(|(_, mutex)| mutex.try_lock().is_err())
43            .map(|(name, _)| name.clone())
44            .collect()
45    }
46}
47
48#[cfg(test)]
49mod tests {
50    use super::*;
51
52    #[tokio::test]
53    async fn test_workspace_lock_manager_same_project_shares_mutex() {
54        let manager = WorkspaceLockManager::new();
55        let lock1 = manager.get_lock("my-project").await;
56        let lock2 = manager.get_lock("my-project").await;
57
58        // Acquire lock1
59        let guard1 = lock1.try_lock();
60        assert!(guard1.is_ok());
61
62        // lock2 should fail because it references the same underlying project mutex
63        let guard2 = lock2.try_lock();
64        assert!(guard2.is_err());
65
66        // Drop guard1 and verify lock2 can now acquire
67        drop(guard1);
68        let guard2_retry = lock2.try_lock();
69        assert!(guard2_retry.is_ok());
70    }
71
72    #[tokio::test]
73    async fn test_workspace_lock_manager_different_projects_independent() {
74        let manager = WorkspaceLockManager::new();
75        let lock1 = manager.get_lock("project-a").await;
76        let lock2 = manager.get_lock("project-b").await;
77
78        let guard1 = lock1.try_lock();
79        let guard2 = lock2.try_lock();
80
81        assert!(guard1.is_ok());
82        assert!(guard2.is_ok());
83    }
84
85    #[tokio::test]
86    async fn test_is_locked_reflects_held_locks_without_inserting() {
87        let manager = WorkspaceLockManager::new();
88
89        // Unknown projects are never locked.
90        assert!(!manager.is_locked("ghost-project").await);
91
92        let lock = manager.get_lock("busy-project").await;
93        let guard = lock.try_lock().unwrap();
94        assert!(manager.is_locked("busy-project").await);
95
96        // GC snapshots must see the held lock and nothing else.
97        let locked = manager.locked_projects().await;
98        assert_eq!(locked, vec!["busy-project".to_string()]);
99
100        drop(guard);
101        assert!(!manager.is_locked("busy-project").await);
102        assert!(manager.locked_projects().await.is_empty());
103    }
104}