Files
rdbms-playground/src/project/lock.rs
T
claude@clouddev1 601d3b6c51 Iteration 1: file-backed projects with auto-named temps, lock file, and L1 CLI
Replaces the in-memory database with an on-disk project. Startup either
opens a project at the positional CLI path (L1) or creates an auto-named
temp project (<YYYYMMDD>-<word>-<word>-<word>) under the OS-standard
data directory or a --data-dir override. The new project::Project type
owns the directory skeleton and a PID+hostname lock file with
stale-lock takeover via sysinfo. The status bar now shows
"Project: <Display Name>", derived by a small kebab/snake/camel
prettifier. Per-command persistence to YAML/CSV/history.log is NOT
yet wired -- that's Iteration 2; for now playground.db carries the
state across quits.

Tests: 257 passing (231 lib + 9 new integration + 17 existing),
0 failing, 0 skipped. Clippy clean with nursery lints.
2026-05-07 20:21:52 +00:00

241 lines
7.9 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, 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<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());
}
}