601d3b6c51
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.
241 lines
7.9 KiB
Rust
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());
|
|
}
|
|
}
|