#![allow(clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap)] //! PID-file based advisory lock preventing two processes from operating on //! the same session directory concurrently. use std::path::{Path, PathBuf}; use std::fs; use std::io::Write; /// A PID-file lock (`/.lock`) tied to the current process, /// auto-removed on drop. pub struct SessionLock { path: PathBuf, pid: u32, } impl SessionLock { /// Construct a lock handle for a session directory (does not acquire /// the lock yet — call `try_lock`). pub fn new(session_dir: &Path) -> Self { SessionLock { path: session_dir.join(".lock"), pid: std::process::id(), } } /// Attempt to acquire the session lock using an atomic file creation. /// /// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that /// succeeds, the lock is ours — write our PID and return ok. If the /// file already exists, read the PID inside it and check `is_alive`: /// if that process is still running, fail to acquire; otherwise the /// lock is stale — overwrite it with our own PID and succeed. /// /// Why: `create_new(true)` is atomic on POSIX (unlike the previous /// read-then-write pattern which had a TOCTOU race between checking /// `path.exists()` and writing). The stale-lock recovery path reads /// the stale PID and verifies liveness via `kill(pid, 0)`. /// /// Return: `Ok(true)` if acquired, `Ok(false)` if another live /// process holds it, `Err` on I/O failure. #[allow(clippy::suspicious_open_options)] pub fn try_lock(&self) -> std::io::Result { // Phase 1: try atomic create. If it succeeds, the lock is ours. match fs::OpenOptions::new() .create_new(true) .write(true) .open(&self.path) { Ok(mut file) => { write!(file, "{}", self.pid)?; file.sync_all()?; return Ok(true); } Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => { // Lock file exists — check if it's stale. } Err(e) => return Err(e), } // Phase 2: lock file exists — check liveness of the owning process. let content = fs::read_to_string(&self.path).unwrap_or_default(); if let Ok(pid) = content.trim().parse::() { if self.is_alive(pid) { return Ok(false); } } // Phase 3: stale lock — overwrite it atomically (best-effort). // Use a temp file + rename to avoid partial writes corrupting the lock. let tmp = self.path.with_extension("lock.tmp"); { let mut tmp_file = fs::OpenOptions::new() .create(true) .write(true) .open(&tmp)?; write!(tmp_file, "{}", self.pid)?; tmp_file.sync_all()?; } fs::rename(&tmp, &self.path)?; // Sync the parent directory so the rename survives a crash. if let Some(parent) = self.path.parent() { let _ = fs::File::open(parent).and_then(|d| d.sync_all()); } Ok(true) } /// Explicitly release the lock by removing the lock file. pub fn unlock(&self) { let _ = fs::remove_file(&self.path); } /// Check whether a process with the given PID is currently alive and /// is actually a zesdex process (not a recycled PID from a different /// program). #[allow(clippy::unused_self)] fn is_alive(&self, pid: u32) -> bool { // SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks // whether the process exists and the caller has permission to signal // it. The integer argument is a PID already validated by `try_lock`. if unsafe { libc::kill(pid as i32, 0) != 0 } { return false; } // Extra check: verify the PID belongs to a zesdex process via // /proc//exe to mitigate the PID-reuse race (a recycled PID // from a different program would answer kill but shouldn't hold // our lock). This is best-effort — /proc may not be available // on all platforms. let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe")); if let Ok(target) = std::fs::read_link(&proc_exe) { if let Ok(exe) = std::env::current_exe() { if target != exe { return false; } } else { /* cannot resolve own exe, trust kill check */ } } else { /* /proc unavailable, trust kill check */ } true } } impl Drop for SessionLock { /// Release the lock automatically when the guard goes out of scope, /// so an ungracefully-exited process doesn't leave a dangling lock. fn drop(&mut self) { let _ = fs::remove_file(&self.path); } }