Files
rdbms-playground/src/project/lock.rs
T
claude@clouddev1 41b7e9a049 style: format the whole tree with cargo fmt (stock defaults, #35)
One-time, mechanical reformat — no functional changes. The tree was not
rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock
`cargo fmt` defaults so a `cargo fmt --check` CI gate can follow.
Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline),
clippy clean. A .git-blame-ignore-revs entry follows so `git blame`
skips this commit.
2026-06-17 21:39:19 +00:00

276 lines
8.7 KiB
Rust

//! 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)]
pub enum LockError {
AlreadyHeld {
pid: u32,
hostname: String,
path: PathBuf,
},
Write {
path: PathBuf,
source: std::io::Error,
},
Read {
path: PathBuf,
source: std::io::Error,
},
}
impl std::fmt::Display for LockError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AlreadyHeld {
pid,
hostname,
path,
} => f.write_str(&crate::t!(
"project.lock.already_held",
pid = pid,
hostname = hostname,
path = path.display(),
)),
Self::Write { path, source } => f.write_str(&crate::t!(
"project.lock.write",
path = path.display(),
source = source,
)),
Self::Read { path, source } => f.write_str(&crate::t!(
"project.lock.read",
path = path.display(),
source = source,
)),
}
}
}
impl std::error::Error for LockError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::AlreadyHeld { .. } => None,
Self::Write { source, .. } | Self::Read { source, .. } => Some(source),
}
}
}
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<Self, LockError> {
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 `<digits>|<hostname>` — 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());
}
}