Skip to main content

nap_core/server/
lock.rs

1// SPDX-FileCopyrightText: 2026 Digital Creations
2// SPDX-License-Identifier: MIT
3//! Cross-platform process locking mechanism
4//!
5//! Ensures only one local Lore daemon is started per NAP home directory
6//! using PID file-based locking with automatic cleanup.
7
8use anyhow::{Context, Result};
9use std::fs;
10use std::path::Path;
11use std::process;
12
13/// Process lock to prevent multiple concurrent daemon startups
14pub struct ProcessLock {
15    lock_file: std::path::PathBuf,
16    acquired: bool,
17}
18
19impl ProcessLock {
20    /// Create a new process lock for the given NAP home directory
21    pub fn new(nap_home: &Path) -> Self {
22        let lock_file = nap_home.join("lore").join("pid");
23        Self {
24            lock_file,
25            acquired: false,
26        }
27    }
28
29    /// Try to acquire the lock
30    ///
31    /// Returns Ok(true) if lock was acquired (no other daemon running),
32    /// Ok(false) if already held by another running process.
33    ///
34    /// **Important**: This only checks for an existing running daemon.
35    /// Call [`write_daemon_pid`] after spawning the daemon to record its PID.
36    pub fn try_acquire(&mut self) -> Result<bool> {
37        // Create lock directory if needed
38        if let Some(parent) = self.lock_file.parent() {
39            fs::create_dir_all(parent)
40                .context("Failed to create lock directory for process lock")?;
41        }
42
43        // Check if lock file exists
44        if self.lock_file.exists() {
45            // Read existing PID
46            let existing_pid = fs::read_to_string(&self.lock_file).with_context(|| {
47                format!(
48                    "Failed to read PID lock file at {}",
49                    self.lock_file.display()
50                )
51            })?;
52
53            let existing_pid: u32 = existing_pid.trim().parse().with_context(|| {
54                format!(
55                    "Failed to parse PID from lock file at {}",
56                    self.lock_file.display()
57                )
58            })?;
59
60            // Check if process is still running
61            if self.is_process_running(existing_pid) {
62                tracing::warn!(
63                    pid = existing_pid,
64                    lock_file = %self.lock_file.display(),
65                    "Lore server is already running (lock held by PID {})",
66                    existing_pid
67                );
68                return Ok(false);
69            } else {
70                // Stale lock - daemon process died without cleanup
71                tracing::info!(
72                    pid = existing_pid,
73                    lock_file = %self.lock_file.display(),
74                    "Removing stale lock file (PID {} no longer running)",
75                    existing_pid
76                );
77                fs::remove_file(&self.lock_file).with_context(|| {
78                    format!(
79                        "Failed to remove stale lock file at {}",
80                        self.lock_file.display()
81                    )
82                })?;
83            }
84        }
85
86        // Lock is available. Write a placeholder PID (the caller).
87        // The real daemon PID should be written via write_daemon_pid()
88        // after the daemon process is spawned.
89        let current_pid = process::id();
90        fs::write(&self.lock_file, current_pid.to_string()).with_context(|| {
91            format!("Failed to write lock file at {}", self.lock_file.display())
92        })?;
93
94        self.acquired = true;
95        tracing::info!(
96            pid = current_pid,
97            "Acquired process lock (placeholder PID written)"
98        );
99        Ok(true)
100    }
101
102    /// Write the actual daemon PID to the lock file.
103    ///
104    /// Call this after spawning the Lore daemon process to replace
105    /// the placeholder PID with the real daemon PID.
106    pub fn write_daemon_pid(&self, daemon_pid: u32) -> Result<()> {
107        fs::write(&self.lock_file, daemon_pid.to_string()).context(format!(
108            "Failed to write daemon PID {} to lock file at {}",
109            daemon_pid,
110            self.lock_file.display()
111        ))?;
112        tracing::info!(
113            daemon_pid,
114            lock_file = %self.lock_file.display(),
115            "Wrote daemon PID to lock file"
116        );
117        Ok(())
118    }
119
120    /// Read the daemon PID from the lock file, if present.
121    pub fn read_daemon_pid(&self) -> Result<Option<u32>> {
122        if !self.lock_file.exists() {
123            return Ok(None);
124        }
125        let content = fs::read_to_string(&self.lock_file).context(format!(
126            "Failed to read PID from lock file at {}",
127            self.lock_file.display()
128        ))?;
129        let pid = content.trim().parse::<u32>().context(format!(
130            "Failed to parse PID '{}' from lock file at {}",
131            content.trim(),
132            self.lock_file.display()
133        ))?;
134        Ok(Some(pid))
135    }
136
137    /// Release the lock
138    pub fn release(&mut self) -> Result<()> {
139        if self.acquired && self.lock_file.exists() {
140            fs::remove_file(&self.lock_file).context("Failed to remove lock file")?;
141            self.acquired = false;
142            tracing::info!("Released process lock");
143        }
144        Ok(())
145    }
146
147    /// Check if a process with the given PID is running
148    #[cfg(unix)]
149    fn is_process_running(&self, pid: u32) -> bool {
150        // On Unix, send signal 0 to check if process exists
151        use nix::sys::signal::kill;
152        use nix::unistd::Pid;
153
154        kill(Pid::from_raw(pid as i32), None).is_ok()
155    }
156
157    #[cfg(windows)]
158    fn is_process_running(&self, pid: u32) -> bool {
159        // On Windows, use OpenProcess to check if process exists
160        use windows_sys::Win32::Foundation::CloseHandle;
161        use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION};
162
163        unsafe {
164            let handle = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid);
165            if handle == std::ptr::null_mut() {
166                return false;
167            }
168            CloseHandle(handle);
169            true
170        }
171    }
172}
173
174impl Drop for ProcessLock {
175    fn drop(&mut self) {
176        // Best-effort cleanup on drop
177        let _ = self.release();
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use tempfile::TempDir;
185
186    #[test]
187    fn test_process_lock_acquire_release() {
188        let temp_dir = TempDir::new().unwrap();
189        let mut lock = ProcessLock::new(temp_dir.path());
190
191        // First acquire should succeed
192        assert!(lock.try_acquire().unwrap());
193        assert!(lock.acquired);
194
195        // Release
196        lock.release().unwrap();
197        assert!(!lock.acquired);
198
199        // Re-acquire should succeed
200        assert!(lock.try_acquire().unwrap());
201    }
202
203    #[test]
204    fn test_process_lock_double_acquire() {
205        let temp_dir = TempDir::new().unwrap();
206        let mut lock1 = ProcessLock::new(temp_dir.path());
207        let mut lock2 = ProcessLock::new(temp_dir.path());
208
209        // First acquire should succeed
210        assert!(lock1.try_acquire().unwrap());
211
212        // Second acquire should fail (already held by same PID — still "running")
213        assert!(!lock2.try_acquire().unwrap());
214
215        // Release first lock
216        lock1.release().unwrap();
217
218        // Now second acquire should succeed
219        assert!(lock2.try_acquire().unwrap());
220    }
221
222    #[test]
223    fn test_process_lock_cleanup_on_drop() {
224        let temp_dir = TempDir::new().unwrap();
225        let lock_file = temp_dir.path().join("lore").join("pid");
226
227        {
228            let mut lock = ProcessLock::new(temp_dir.path());
229            lock.try_acquire().unwrap();
230            assert!(lock_file.exists());
231        }
232
233        // Lock should be cleaned up on drop
234        assert!(!lock_file.exists());
235    }
236
237    #[test]
238    fn test_daemon_pid_write_and_read() {
239        let temp_dir = TempDir::new().unwrap();
240        let lock_file = temp_dir.path().join("lore").join("pid");
241
242        // Create parent directory
243        std::fs::create_dir_all(temp_dir.path().join("lore")).unwrap();
244
245        let lock = ProcessLock::new(temp_dir.path());
246        lock.write_daemon_pid(12345).unwrap();
247
248        assert!(lock_file.exists());
249        let content = std::fs::read_to_string(&lock_file).unwrap();
250        assert_eq!(content, "12345");
251
252        // Read it back
253        let read_pid = lock.read_daemon_pid().unwrap();
254        assert_eq!(read_pid, Some(12345));
255    }
256
257    #[test]
258    fn test_read_daemon_pid_no_file() {
259        let temp_dir = TempDir::new().unwrap();
260        let lock = ProcessLock::new(temp_dir.path());
261        let pid = lock.read_daemon_pid().unwrap();
262        assert_eq!(pid, None);
263    }
264
265    #[test]
266    fn test_stale_lock_removal() {
267        let temp_dir = TempDir::new().unwrap();
268        let lock_file = temp_dir.path().join("lore").join("pid");
269
270        // Write a PID that is definitely not running (PID 1 is init on Unix,
271        // but might be PID 0 on some systems — use a large number instead)
272        std::fs::create_dir_all(lock_file.parent().unwrap()).unwrap();
273        std::fs::write(&lock_file, "9999999").unwrap();
274
275        let mut lock = ProcessLock::new(temp_dir.path());
276        // Should detect stale lock and succeed
277        assert!(lock.try_acquire().unwrap());
278        // Lock file should now contain the current process PID
279        let content = std::fs::read_to_string(&lock_file).unwrap();
280        assert_eq!(content.trim().parse::<u32>().unwrap(), std::process::id());
281    }
282}