Skip to main content

void_core/support/
lock.rs

1//! Repository lock for mutating operations.
2
3use std::fs::{self, File, OpenOptions};
4use std::io::Write;
5use std::path::{Path, PathBuf};
6use std::time::{SystemTime, UNIX_EPOCH};
7
8use super::error::{Result, VoidError};
9
10const LOCK_FILENAME: &str = "LOCK";
11
12/// Information about a stale lock (process no longer running).
13pub struct StaleLockInfo {
14    pub pid: u32,
15    pub created: u64,
16    pub path: PathBuf,
17}
18
19/// A guard that holds the repository lock until dropped.
20pub struct RepoLock {
21    path: PathBuf,
22    file: Option<File>,
23}
24
25impl RepoLock {
26    /// Acquire the repository lock, optionally forcing removal of a stale lock.
27    pub fn acquire(void_dir: &Path, force: bool) -> Result<Self> {
28        let path = void_dir.join(LOCK_FILENAME);
29
30        if force && path.exists() {
31            fs::remove_file(&path)?;
32        }
33
34        let mut file = match OpenOptions::new().write(true).create_new(true).open(&path) {
35            Ok(file) => file,
36            Err(err) => {
37                if err.kind() == std::io::ErrorKind::AlreadyExists {
38                    return Err(VoidError::RepoLocked(path.display().to_string()));
39                }
40                return Err(VoidError::Io(err));
41            }
42        };
43
44        let pid = std::process::id();
45        let created = SystemTime::now()
46            .duration_since(UNIX_EPOCH)
47            .unwrap_or_default()
48            .as_secs();
49        let content = format!("pid={pid}\ncreated={created}\n");
50        if let Err(err) = file.write_all(content.as_bytes()) {
51            let _ = fs::remove_file(&path);
52            return Err(VoidError::Io(err));
53        }
54
55        Ok(Self {
56            path,
57            file: Some(file),
58        })
59    }
60
61    /// Check if an existing lock is stale (process no longer running).
62    pub fn is_stale(void_dir: &Path) -> Result<Option<StaleLockInfo>> {
63        let path = void_dir.join(LOCK_FILENAME);
64        if !path.exists() {
65            return Ok(None);
66        }
67
68        let content = fs::read_to_string(&path)?;
69        let pid = parse_pid(&content)?;
70        let created = parse_created(&content)?;
71
72        if !is_process_running(pid) {
73            return Ok(Some(StaleLockInfo { pid, created, path }));
74        }
75
76        Ok(None)
77    }
78
79    /// Acquire lock, automatically removing stale locks.
80    pub fn acquire_auto(void_dir: &Path) -> Result<Self> {
81        if let Some(stale) = Self::is_stale(void_dir)? {
82            // Remove stale lock
83            let _ = fs::remove_file(&stale.path);
84        }
85        Self::acquire(void_dir, false)
86    }
87}
88
89fn parse_pid(content: &str) -> Result<u32> {
90    content
91        .lines()
92        .find(|l| l.starts_with("pid="))
93        .and_then(|l| l.strip_prefix("pid="))
94        .and_then(|s| s.parse().ok())
95        .ok_or_else(|| VoidError::Lock("invalid lock file format".into()))
96}
97
98fn parse_created(content: &str) -> Result<u64> {
99    content
100        .lines()
101        .find(|l| l.starts_with("created="))
102        .and_then(|l| l.strip_prefix("created="))
103        .and_then(|s| s.parse().ok())
104        .ok_or_else(|| VoidError::Lock("invalid lock file format".into()))
105}
106
107#[cfg(unix)]
108fn is_process_running(pid: u32) -> bool {
109    // kill(pid, 0) checks if process exists without sending signal
110    // Returns 0 if process exists, -1 if not
111    unsafe { libc::kill(pid as i32, 0) == 0 }
112}
113
114#[cfg(windows)]
115fn is_process_running(pid: u32) -> bool {
116    // OpenProcess returns 0 (null handle) if process doesn't exist.
117    // HANDLE is isize on Windows — compare to 0, not .is_null().
118    const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
119    extern "system" {
120        fn OpenProcess(access: u32, inherit: i32, pid: u32) -> isize;
121        fn CloseHandle(handle: isize) -> i32;
122    }
123    let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
124    if handle == 0 {
125        false
126    } else {
127        unsafe { CloseHandle(handle) };
128        true
129    }
130}
131
132#[cfg(not(any(unix, windows)))]
133fn is_process_running(_pid: u32) -> bool {
134    // Fallback: assume process is running (conservative)
135    true
136}
137
138impl Drop for RepoLock {
139    fn drop(&mut self) {
140        let _ = self.file.take();
141        let _ = fs::remove_file(&self.path);
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    #[test]
150    fn stale_lock_detected_when_pid_not_running() {
151        let temp = tempfile::TempDir::new().unwrap();
152        let void_dir = temp.path();
153
154        // Create lock file with non-existent PID
155        let lock_path = void_dir.join("LOCK");
156        fs::write(&lock_path, "pid=999999999\ncreated=1234567890\n").unwrap();
157
158        // Should detect as stale
159        let stale = RepoLock::is_stale(void_dir).unwrap();
160        assert!(stale.is_some());
161        assert_eq!(stale.unwrap().pid, 999999999);
162    }
163
164    #[test]
165    fn active_lock_not_considered_stale() {
166        let temp = tempfile::TempDir::new().unwrap();
167        let void_dir = temp.path();
168
169        // Create lock file with current process PID
170        let lock_path = void_dir.join("LOCK");
171        let content = format!("pid={}\ncreated=1234567890\n", std::process::id());
172        fs::write(&lock_path, content).unwrap();
173
174        // Should NOT be stale
175        let stale = RepoLock::is_stale(void_dir).unwrap();
176        assert!(stale.is_none());
177    }
178
179    #[test]
180    fn acquire_auto_removes_stale_lock() {
181        let temp = tempfile::TempDir::new().unwrap();
182        let void_dir = temp.path();
183
184        // Create stale lock
185        let lock_path = void_dir.join("LOCK");
186        fs::write(&lock_path, "pid=999999999\ncreated=1234567890\n").unwrap();
187
188        // acquire_auto should succeed by removing stale lock
189        let lock = RepoLock::acquire_auto(void_dir).unwrap();
190
191        // Lock should be held
192        assert!(lock_path.exists());
193
194        drop(lock);
195    }
196
197    #[test]
198    fn no_lock_returns_none() {
199        let temp = tempfile::TempDir::new().unwrap();
200        let void_dir = temp.path();
201
202        // No lock file exists
203        let stale = RepoLock::is_stale(void_dir).unwrap();
204        assert!(stale.is_none());
205    }
206
207    #[test]
208    fn parse_pid_works() {
209        let content = "pid=12345\ncreated=1234567890\n";
210        assert_eq!(parse_pid(content).unwrap(), 12345);
211    }
212
213    #[test]
214    fn parse_created_works() {
215        let content = "pid=12345\ncreated=1234567890\n";
216        assert_eq!(parse_created(content).unwrap(), 1234567890);
217    }
218
219    #[test]
220    fn invalid_lock_format_returns_error() {
221        let content = "invalid format";
222        assert!(parse_pid(content).is_err());
223        assert!(parse_created(content).is_err());
224    }
225}