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(&mut 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        // The detached daemon now owns the PID file. Do not remove it when
113        // this short-lived startup guard drops; ServerManager::stop removes
114        // the file after terminating the daemon.
115        self.acquired = false;
116        tracing::info!(
117            daemon_pid,
118            lock_file = %self.lock_file.display(),
119            "Wrote daemon PID to lock file"
120        );
121        Ok(())
122    }
123
124    /// Read the daemon PID from the lock file, if present.
125    pub fn read_daemon_pid(&self) -> Result<Option<u32>> {
126        if !self.lock_file.exists() {
127            return Ok(None);
128        }
129        let content = fs::read_to_string(&self.lock_file).context(format!(
130            "Failed to read PID from lock file at {}",
131            self.lock_file.display()
132        ))?;
133        let pid = content.trim().parse::<u32>().context(format!(
134            "Failed to parse PID '{}' from lock file at {}",
135            content.trim(),
136            self.lock_file.display()
137        ))?;
138        Ok(Some(pid))
139    }
140
141    /// Release the lock
142    pub fn release(&mut self) -> Result<()> {
143        if self.lock_file.exists() {
144            fs::remove_file(&self.lock_file).context("Failed to remove lock file")?;
145            tracing::info!("Released process lock");
146        }
147        self.acquired = false;
148        Ok(())
149    }
150
151    /// Check if a process with the given PID is running
152    #[cfg(unix)]
153    fn is_process_running(&self, pid: u32) -> bool {
154        // On Unix, send signal 0 to check if process exists
155        use nix::sys::signal::kill;
156        use nix::unistd::Pid;
157
158        kill(Pid::from_raw(pid as i32), None).is_ok()
159    }
160
161    #[cfg(windows)]
162    fn is_process_running(&self, pid: u32) -> bool {
163        // On Windows, use OpenProcess to check if process exists
164        use windows_sys::Win32::Foundation::CloseHandle;
165        use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION};
166
167        unsafe {
168            let handle = OpenProcess(PROCESS_QUERY_INFORMATION, 0, pid);
169            if handle == std::ptr::null_mut() {
170                return false;
171            }
172            CloseHandle(handle);
173            true
174        }
175    }
176}
177
178impl Drop for ProcessLock {
179    fn drop(&mut self) {
180        // Best-effort cleanup only while this guard owns the placeholder
181        // lock. After `write_daemon_pid`, the detached daemon owns the PID
182        // file until ServerManager::stop removes it.
183        if self.acquired {
184            let _ = self.release();
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use tempfile::TempDir;
193
194    #[test]
195    fn test_process_lock_acquire_release() {
196        let temp_dir = TempDir::new().unwrap();
197        let mut lock = ProcessLock::new(temp_dir.path());
198
199        // First acquire should succeed
200        assert!(lock.try_acquire().unwrap());
201        assert!(lock.acquired);
202
203        // Release
204        lock.release().unwrap();
205        assert!(!lock.acquired);
206
207        // Re-acquire should succeed
208        assert!(lock.try_acquire().unwrap());
209    }
210
211    #[test]
212    fn test_process_lock_double_acquire() {
213        let temp_dir = TempDir::new().unwrap();
214        let mut lock1 = ProcessLock::new(temp_dir.path());
215        let mut lock2 = ProcessLock::new(temp_dir.path());
216
217        // First acquire should succeed
218        assert!(lock1.try_acquire().unwrap());
219
220        // Second acquire should fail (already held by same PID — still "running")
221        assert!(!lock2.try_acquire().unwrap());
222
223        // Release first lock
224        lock1.release().unwrap();
225
226        // Now second acquire should succeed
227        assert!(lock2.try_acquire().unwrap());
228    }
229
230    #[test]
231    fn test_process_lock_cleanup_on_drop() {
232        let temp_dir = TempDir::new().unwrap();
233        let lock_file = temp_dir.path().join("lore").join("pid");
234
235        {
236            let mut lock = ProcessLock::new(temp_dir.path());
237            lock.try_acquire().unwrap();
238            assert!(lock_file.exists());
239        }
240
241        // Lock should be cleaned up on drop
242        assert!(!lock_file.exists());
243    }
244
245    #[test]
246    fn test_daemon_pid_write_and_read() {
247        let temp_dir = TempDir::new().unwrap();
248        let lock_file = temp_dir.path().join("lore").join("pid");
249
250        // Create parent directory
251        std::fs::create_dir_all(temp_dir.path().join("lore")).unwrap();
252
253        let mut lock = ProcessLock::new(temp_dir.path());
254        lock.write_daemon_pid(12345).unwrap();
255
256        assert!(lock_file.exists());
257        let content = std::fs::read_to_string(&lock_file).unwrap();
258        assert_eq!(content, "12345");
259
260        // Read it back
261        let read_pid = lock.read_daemon_pid().unwrap();
262        assert_eq!(read_pid, Some(12345));
263    }
264
265    #[test]
266    fn daemon_pid_survives_startup_guard_drop() {
267        let temp_dir = TempDir::new().unwrap();
268        let lock_file = temp_dir.path().join("lore").join("pid");
269
270        {
271            let mut lock = ProcessLock::new(temp_dir.path());
272            lock.try_acquire().unwrap();
273            lock.write_daemon_pid(12345).unwrap();
274        }
275
276        assert_eq!(std::fs::read_to_string(lock_file).unwrap(), "12345");
277    }
278
279    #[test]
280    fn test_read_daemon_pid_no_file() {
281        let temp_dir = TempDir::new().unwrap();
282        let lock = ProcessLock::new(temp_dir.path());
283        let pid = lock.read_daemon_pid().unwrap();
284        assert_eq!(pid, None);
285    }
286
287    #[test]
288    fn test_stale_lock_removal() {
289        let temp_dir = TempDir::new().unwrap();
290        let lock_file = temp_dir.path().join("lore").join("pid");
291
292        // Write a PID that is definitely not running (PID 1 is init on Unix,
293        // but might be PID 0 on some systems — use a large number instead)
294        std::fs::create_dir_all(lock_file.parent().unwrap()).unwrap();
295        std::fs::write(&lock_file, "9999999").unwrap();
296
297        let mut lock = ProcessLock::new(temp_dir.path());
298        // Should detect stale lock and succeed
299        assert!(lock.try_acquire().unwrap());
300        // Lock file should now contain the current process PID
301        let content = std::fs::read_to_string(&lock_file).unwrap();
302        assert_eq!(content.trim().parse::<u32>().unwrap(), std::process::id());
303    }
304}