2026-07-12 11:28:39 +07:00
|
|
|
//! PID-file based advisory lock preventing two processes from operating on
|
|
|
|
|
//! the same session directory concurrently.
|
|
|
|
|
|
2026-07-11 13:16:10 +07:00
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::fs;
|
2026-07-13 04:10:08 +07:00
|
|
|
use std::io::Write;
|
2026-07-11 13:16:10 +07:00
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
|
|
|
|
|
/// auto-removed on drop.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub struct SessionLock {
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
pid: u32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl SessionLock {
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Construct a lock handle for a session directory (does not acquire
|
|
|
|
|
/// the lock yet — call `try_lock`).
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn new(session_dir: &Path) -> Self {
|
|
|
|
|
SessionLock {
|
|
|
|
|
path: session_dir.join(".lock"),
|
|
|
|
|
pid: std::process::id(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 04:10:08 +07:00
|
|
|
/// Attempt to acquire the session lock using an atomic file creation.
|
2026-07-12 11:28:39 +07:00
|
|
|
///
|
2026-07-13 04:10:08 +07:00
|
|
|
/// 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.
|
2026-07-12 11:28:39 +07:00
|
|
|
///
|
2026-07-13 04:10:08 +07:00
|
|
|
/// 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)`.
|
2026-07-12 11:28:39 +07:00
|
|
|
///
|
|
|
|
|
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
|
|
|
|
/// process holds it, `Err` on I/O failure.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn try_lock(&self) -> std::io::Result<bool> {
|
2026-07-13 04:10:08 +07:00
|
|
|
// 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::<u32>() {
|
|
|
|
|
if self.is_alive(pid) {
|
|
|
|
|
return Ok(false);
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-13 04:10:08 +07:00
|
|
|
|
|
|
|
|
// 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());
|
|
|
|
|
}
|
2026-07-11 13:16:10 +07:00
|
|
|
Ok(true)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Explicitly release the lock by removing the lock file.
|
2026-07-11 13:16:10 +07:00
|
|
|
pub fn unlock(&self) {
|
|
|
|
|
let _ = fs::remove_file(&self.path);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:55:02 +07:00
|
|
|
/// 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).
|
2026-07-11 13:16:10 +07:00
|
|
|
fn is_alive(&self, pid: u32) -> bool {
|
2026-07-12 10:23:26 +07:00
|
|
|
// 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`.
|
2026-07-12 11:55:02 +07:00
|
|
|
if unsafe { libc::kill(pid as i32, 0) != 0 } {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
// Extra check: verify the PID belongs to a zesdex process via
|
|
|
|
|
// /proc/<pid>/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/{}/exe", pid));
|
|
|
|
|
match std::fs::read_link(&proc_exe) {
|
|
|
|
|
Ok(target) => match std::env::current_exe() {
|
|
|
|
|
Ok(exe) => {
|
|
|
|
|
if target != exe {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(_) => { /* cannot resolve own exe, trust kill check */ }
|
|
|
|
|
},
|
|
|
|
|
Err(_) => { /* /proc unavailable, trust kill check */ }
|
|
|
|
|
}
|
|
|
|
|
true
|
2026-07-11 13:16:10 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Drop for SessionLock {
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Release the lock automatically when the guard goes out of scope,
|
|
|
|
|
/// so an ungracefully-exited process doesn't leave a dangling lock.
|
2026-07-11 13:16:10 +07:00
|
|
|
fn drop(&mut self) {
|
|
|
|
|
let _ = fs::remove_file(&self.path);
|
|
|
|
|
}
|
|
|
|
|
}
|