41b7e9a049
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.
276 lines
8.7 KiB
Rust
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());
|
|
}
|
|
}
|