//! Project lock file (ADR-0015 §10). //! //! When a project is opened, we drop a `.rdbms-playground.lock` //! file in its directory containing the owning process's PID //! and hostname. On open, we either: //! //! - take the lock if no lock file exists, //! - refuse if the existing lock points at a live PID on this //! host (another rdbms-playground TUI is running on this //! project), //! - take over if the existing lock's PID is dead, or the //! hostname differs from ours (clean handover from a crashed //! prior instance, or a stale lock left on a shared //! filesystem by a different machine). //! //! The lock is removed on clean exit (the `Drop` impl). A //! crash leaves the lock file behind; the next open detects //! the dead PID and reclaims it. use std::fs; use std::path::{Path, PathBuf}; use sysinfo::{Pid, System}; use tracing::{debug, warn}; /// On-disk lock file name. Lives directly under the project /// directory. const LOCK_FILE_NAME: &str = ".rdbms-playground.lock"; /// Acquired project lock. Releases on drop. #[derive(Debug)] pub struct Lock { path: PathBuf, } #[derive(Debug, thiserror::Error)] pub enum LockError { #[error( "project is already open in another rdbms-playground process \ (pid {pid} on host `{hostname}`); close that process or \ remove `{path}` if you're sure it's not running" )] AlreadyHeld { pid: u32, hostname: String, path: PathBuf, }, #[error("could not write lock file `{path}`: {source}")] Write { path: PathBuf, source: std::io::Error, }, #[error("could not read existing lock file `{path}`: {source}")] Read { path: PathBuf, source: std::io::Error, }, } impl Lock { /// Attempt to acquire the lock for `project_dir`. The /// directory itself must exist; `Project::open` / /// `Project::create_temp` create it before calling here. pub fn acquire(project_dir: &Path) -> Result { let path = project_dir.join(LOCK_FILE_NAME); let our_pid = std::process::id(); let our_host = local_hostname(); match fs::read_to_string(&path) { Ok(content) => { let info = parse(&content); if let Some((pid, host)) = info && host == our_host && pid_is_alive(pid) { return Err(LockError::AlreadyHeld { pid, hostname: host, path, }); } debug!(?path, "reclaiming stale lock"); } Err(e) if e.kind() == std::io::ErrorKind::NotFound => { // No existing lock; we'll create it below. } Err(e) => { return Err(LockError::Read { path, source: e }); } } let body = format!("{our_pid}|{our_host}\n"); fs::write(&path, body).map_err(|e| LockError::Write { path: path.clone(), source: e, })?; Ok(Self { path }) } /// Path of the underlying lock file (mainly for tests and /// diagnostics). #[cfg(test)] pub fn path(&self) -> &Path { &self.path } } impl Drop for Lock { fn drop(&mut self) { if let Err(e) = fs::remove_file(&self.path) { warn!(path = %self.path.display(), error = %e, "failed to remove project lock file"); } } } /// Parse a lock file body into `(pid, hostname)`. Tolerates a /// trailing newline. Returns `None` if the format is anything /// other than `|` — in that case the lock is /// treated as stale and reclaimed. fn parse(content: &str) -> Option<(u32, String)> { let line = content.lines().next()?.trim(); let (pid_str, host) = line.split_once('|')?; let pid: u32 = pid_str.parse().ok()?; if host.is_empty() { return None; } Some((pid, host.to_string())) } /// Hostname of the machine, as a UTF-8 string. Falls back to /// `"unknown-host"` if the OS won't tell us; a fallback string /// only weakens the cross-host reclaim heuristic, it doesn't /// break correctness. fn local_hostname() -> String { gethostname::gethostname() .into_string() .unwrap_or_else(|_| "unknown-host".to_string()) } /// Is a process with this PID currently running on this host? /// Uses `sysinfo` to query the OS process table. fn pid_is_alive(pid: u32) -> bool { let mut sys = System::new(); sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[Pid::from_u32(pid)]), true); sys.process(Pid::from_u32(pid)).is_some() } #[cfg(test)] mod tests { use super::*; use std::fs; fn tempdir() -> tempfile::TempDir { tempfile::tempdir().expect("create tempdir") } #[test] fn acquires_lock_when_none_exists() { let dir = tempdir(); let lock = Lock::acquire(dir.path()).expect("lock"); assert!(lock.path().exists()); let content = fs::read_to_string(lock.path()).unwrap(); let (pid, host) = parse(&content).expect("parseable body"); assert_eq!(pid, std::process::id()); assert!(!host.is_empty()); } #[test] fn drop_removes_lock_file() { let dir = tempdir(); let path = { let lock = Lock::acquire(dir.path()).expect("lock"); lock.path().to_path_buf() }; assert!(!path.exists(), "lock file should have been removed on drop"); } #[test] fn refuses_when_lock_points_at_live_self() { let dir = tempdir(); let _first = Lock::acquire(dir.path()).expect("first lock"); // The first lock writes our own PID; a second attempt // should refuse because the PID is alive on this host. let err = Lock::acquire(dir.path()).expect_err("should refuse second acquisition"); assert!(matches!(err, LockError::AlreadyHeld { .. }), "unexpected: {err:?}"); } #[test] fn reclaims_stale_lock_with_dead_pid() { let dir = tempdir(); // PID 1 is init/launchd and is alive. We need a PID that // is essentially guaranteed not to exist; use the maximum // u32 value, which is far above any realistic PID. let stale = format!("{pid}|{host}\n", pid = u32::MAX, host = local_hostname()); fs::write(dir.path().join(LOCK_FILE_NAME), stale).unwrap(); let lock = Lock::acquire(dir.path()).expect("should reclaim stale lock"); let body = fs::read_to_string(lock.path()).unwrap(); let (pid, _) = parse(&body).unwrap(); assert_eq!(pid, std::process::id()); } #[test] fn reclaims_lock_from_different_host() { let dir = tempdir(); // A lock from another host: even if the PID happens to // be live here, we reclaim because we can't tell whether // *that* PID on *that* host is alive. let foreign = "1|some-other-machine\n".to_string(); fs::write(dir.path().join(LOCK_FILE_NAME), foreign).unwrap(); let lock = Lock::acquire(dir.path()).expect("should reclaim cross-host lock"); let body = fs::read_to_string(lock.path()).unwrap(); let (_, host) = parse(&body).unwrap(); assert_eq!(host, local_hostname()); } #[test] fn reclaims_unparseable_lock() { let dir = tempdir(); fs::write(dir.path().join(LOCK_FILE_NAME), "not a real lock\n").unwrap(); let lock = Lock::acquire(dir.path()).expect("should reclaim unparseable lock"); assert!(lock.path().exists()); } #[test] fn parses_valid_body() { let (pid, host) = parse("12345|myhost\n").unwrap(); assert_eq!(pid, 12345); assert_eq!(host, "myhost"); } #[test] fn rejects_bad_bodies() { assert!(parse("").is_none()); assert!(parse("nopipe\n").is_none()); assert!(parse("notanumber|host\n").is_none()); assert!(parse("123|\n").is_none()); } }