//! PID-file based advisory lock preventing two processes from operating on //! the same session directory concurrently. //! //! # Flow //! //! [`SessionLock::new`] creates a handle → [`SessionLock::try_lock`] attempts //! atomic `O_CREAT|O_EXCL` creation. If the lock file already exists, the //! owning PID is checked via liveness verification. Stale locks are //! overwritten atomically (temp-file + rename + fsync). On [`Drop`], //! the lock file is removed automatically. //! //! # Components //! //! - `SessionLock` — RAII guard wrapping a lock file path and PID //! - `try_lock` — three-phase atomic acquire with stale-lock recovery //! - `unlock` / `Drop` — explicit and implicit release use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; use tracing; /// A PID-file lock (`/.lock`) tied to the current process, /// auto-removed on drop. #[derive(Debug)] pub struct SessionLock { /// Path to the `.lock` file inside the session directory. pub(crate) path: PathBuf, /// Process ID that holds (or will hold) this lock. pub(crate) 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 whether that /// PID is still alive: if the process is still running, fail to acquire; /// otherwise the lock is stale — overwrite it with our own PID and succeed. /// /// Return: `Ok(true)` if acquired, `Ok(false)` if another live /// process holds it, `Err` on I/O failure. 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()?; tracing::debug!(path = %self.path.display(), pid = self.pid, "session lock acquired"); return Ok(true); } Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => { tracing::debug!(path = %self.path.display(), "session lock already exists, checking staleness"); // 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) { tracing::warn!(stale = pid, path = %self.path.display(), "session lock held by live process"); return Ok(false); } tracing::debug!(stale = pid, "stale lock detected, overwriting"); } // 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) .truncate(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. /// /// Uses `kill(pid, 0)` on Unix via the `nix` or `libc` crate in production; /// here we provide a best-effort check using the process table. /// On non-Unix platforms this always returns `true` (conservative). fn is_alive(pid: u32) -> bool { // On Unix, signal 0 checks process existence without sending a signal. #[cfg(unix)] { // 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 validated by `try_lock`. let pid_signed: i32 = match pid.try_into() { Ok(p) => p, Err(_) => return false, }; if unsafe { libc::kill(pid_signed, 0) != 0 } { return false; } // Extra check: verify the PID belongs to a zesdex process via // /proc//exe to mitigate the PID-reuse race. 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; } } } true } #[cfg(not(unix))] { // Fallback: always assume alive (conservative). let _ = pid; 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); } }