Skip to main content

opencode_cloud_core/singleton/
mod.rs

1//! Singleton enforcement via PID lock
2//!
3//! Ensures only one instance of opencode-cloud can run at a time.
4//! Uses a PID file with stale detection - if a previous process crashed
5//! without cleaning up, the stale lock is automatically removed.
6
7use std::fs::{self, File};
8use std::io::{Read, Write};
9use std::path::PathBuf;
10
11use thiserror::Error;
12
13/// Errors that can occur during singleton lock operations
14#[derive(Error, Debug)]
15pub enum SingletonError {
16    /// Another instance is already running
17    #[error("Another instance is already running (PID: {0})")]
18    AlreadyRunning(u32),
19
20    /// Failed to create the lock directory
21    #[error("Failed to create lock directory: {0}")]
22    CreateDirFailed(String),
23
24    /// Failed to create or manage the lock file
25    #[error("Failed to create lock file: {0}")]
26    LockFailed(String),
27
28    /// The lock file path could not be determined
29    #[error("Invalid lock file path")]
30    InvalidPath,
31}
32
33/// A guard that holds the singleton instance lock
34///
35/// The lock is automatically released when this struct is dropped.
36/// The PID file is removed on drop to allow other instances to start.
37pub struct InstanceLock {
38    pid_path: PathBuf,
39}
40
41impl InstanceLock {
42    /// Attempt to acquire the singleton lock
43    ///
44    /// # Returns
45    /// - `Ok(InstanceLock)` if the lock was successfully acquired
46    /// - `Err(SingletonError::AlreadyRunning(pid))` if another instance is running
47    /// - `Err(SingletonError::*)` for other errors
48    ///
49    /// # Stale Lock Detection
50    /// If a PID file exists but the process is no longer running,
51    /// the stale file is automatically cleaned up before acquiring the lock.
52    pub fn acquire(pid_path: PathBuf) -> Result<Self, SingletonError> {
53        // Ensure parent directory exists
54        if let Some(parent) = pid_path.parent() {
55            fs::create_dir_all(parent)
56                .map_err(|e| SingletonError::CreateDirFailed(e.to_string()))?;
57        }
58
59        // Check if PID file exists
60        if pid_path.exists() {
61            // Read existing PID
62            let mut file =
63                File::open(&pid_path).map_err(|e| SingletonError::LockFailed(e.to_string()))?;
64            let mut contents = String::new();
65            file.read_to_string(&mut contents)
66                .map_err(|e| SingletonError::LockFailed(e.to_string()))?;
67
68            if let Ok(pid) = contents.trim().parse::<u32>() {
69                // Check if process is still running
70                if is_process_running(pid) {
71                    return Err(SingletonError::AlreadyRunning(pid));
72                }
73                // Stale PID file - process not running, remove it
74                tracing::info!("Removing stale PID file (PID {} not running)", pid);
75            }
76            // Remove stale/invalid PID file
77            fs::remove_file(&pid_path).map_err(|e| SingletonError::LockFailed(e.to_string()))?;
78        }
79
80        // Write our PID
81        let mut file =
82            File::create(&pid_path).map_err(|e| SingletonError::LockFailed(e.to_string()))?;
83        write!(file, "{}", std::process::id())
84            .map_err(|e| SingletonError::LockFailed(e.to_string()))?;
85
86        tracing::debug!("Acquired singleton lock at: {}", pid_path.display());
87
88        Ok(Self { pid_path })
89    }
90
91    /// Explicitly release the lock
92    ///
93    /// This is called automatically on drop, but can be called explicitly
94    /// if you want to release the lock early.
95    pub fn release(self) {
96        // Dropping self will call Drop::drop which removes the file
97    }
98
99    /// Get the path to the PID file
100    pub fn pid_path(&self) -> &PathBuf {
101        &self.pid_path
102    }
103}
104
105impl Drop for InstanceLock {
106    fn drop(&mut self) {
107        if let Err(e) = fs::remove_file(&self.pid_path) {
108            tracing::warn!("Failed to remove PID file on drop: {}", e);
109        } else {
110            tracing::debug!("Released singleton lock: {}", self.pid_path.display());
111        }
112    }
113}
114
115/// Check if a process with the given PID is currently running
116///
117/// Uses platform-specific methods to check process existence:
118/// - Unix: `kill(pid, 0)` - signal 0 checks existence without sending signal
119/// - Windows: OpenProcess API (deferred to v2)
120fn is_process_running(pid: u32) -> bool {
121    #[cfg(unix)]
122    {
123        // On Unix, sending signal 0 checks if process exists
124        // without actually sending a signal
125        match std::process::Command::new("kill")
126            .args(["-0", &pid.to_string()])
127            .output()
128        {
129            Ok(output) => output.status.success(),
130            Err(_) => {
131                // Fallback: check /proc on Linux
132                #[cfg(target_os = "linux")]
133                {
134                    std::path::Path::new(&format!("/proc/{pid}")).exists()
135                }
136                #[cfg(not(target_os = "linux"))]
137                {
138                    // On macOS, if kill -0 fails, assume process doesn't exist
139                    false
140                }
141            }
142        }
143    }
144
145    #[cfg(windows)]
146    {
147        // Windows support deferred to v2
148        // For now, assume process is not running if we can't check
149        false
150    }
151
152    #[cfg(not(any(unix, windows)))]
153    {
154        // Unknown platform - assume not running
155        false
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use tempfile::TempDir;
163
164    #[test]
165    fn test_acquire_creates_pid_file() {
166        let temp_dir = TempDir::new().unwrap();
167        let pid_path = temp_dir.path().join("test.pid");
168
169        let lock = InstanceLock::acquire(pid_path.clone()).unwrap();
170
171        // Verify PID file exists
172        assert!(pid_path.exists());
173
174        // Verify it contains our PID
175        let contents = std::fs::read_to_string(&pid_path).unwrap();
176        let written_pid: u32 = contents.trim().parse().unwrap();
177        assert_eq!(written_pid, std::process::id());
178
179        // Drop the lock
180        drop(lock);
181
182        // Verify PID file was removed
183        assert!(!pid_path.exists());
184    }
185
186    #[test]
187    fn test_acquire_fails_when_already_locked() {
188        let temp_dir = TempDir::new().unwrap();
189        let pid_path = temp_dir.path().join("test.pid");
190
191        // Acquire first lock
192        let _lock1 = InstanceLock::acquire(pid_path.clone()).unwrap();
193
194        // Try to acquire second lock - should fail
195        let result = InstanceLock::acquire(pid_path.clone());
196        assert!(matches!(result, Err(SingletonError::AlreadyRunning(_))));
197    }
198
199    #[test]
200    fn test_stale_lock_cleanup() {
201        let temp_dir = TempDir::new().unwrap();
202        let pid_path = temp_dir.path().join("test.pid");
203
204        // Write a fake PID file with a PID that doesn't exist
205        // Using PID 999999 which is very unlikely to be running
206        std::fs::write(&pid_path, "999999").unwrap();
207
208        // Should be able to acquire lock (stale PID will be cleaned up)
209        let lock = InstanceLock::acquire(pid_path.clone());
210
211        // On Unix, this should succeed because 999999 likely isn't running
212        // On Windows or if 999999 happens to be running, this might fail
213        // which is acceptable - the test demonstrates the stale detection works
214        if lock.is_ok() {
215            assert!(pid_path.exists());
216            let contents = std::fs::read_to_string(&pid_path).unwrap();
217            let written_pid: u32 = contents.trim().parse().unwrap();
218            assert_eq!(written_pid, std::process::id());
219        }
220    }
221
222    #[test]
223    fn test_is_process_running_with_current_process() {
224        let current_pid = std::process::id();
225        assert!(is_process_running(current_pid));
226    }
227
228    #[test]
229    fn test_is_process_running_with_invalid_pid() {
230        // PID 0 is the kernel, PID 1 is init - use a very high unlikely PID
231        let unlikely_pid = 4_000_000_000;
232        assert!(!is_process_running(unlikely_pid));
233    }
234
235    #[test]
236    fn test_creates_parent_directories() {
237        let temp_dir = TempDir::new().unwrap();
238        let pid_path = temp_dir
239            .path()
240            .join("deep")
241            .join("nested")
242            .join("dir")
243            .join("test.pid");
244
245        let lock = InstanceLock::acquire(pid_path.clone()).unwrap();
246        assert!(pid_path.exists());
247        drop(lock);
248    }
249}