use std::path::{Path, PathBuf};
pub(crate) const SESSION_LOCK_FILE: &str = ".lock";
#[derive(Debug, Clone)]
pub struct SessionLockBusy {
pub pid: u32,
pub hostname: String,
pub started_at_unix: u64,
pub session_dir: PathBuf,
}
impl std::fmt::Display for SessionLockBusy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"session at {} is being written by another process \
(pid {}, host {}, started {}). \
If you believe this is stale, remove {}/{} and retry.",
self.session_dir.display(),
self.pid,
self.hostname,
self.started_at_unix,
self.session_dir.display(),
SESSION_LOCK_FILE,
)
}
}
impl std::error::Error for SessionLockBusy {}
pub(crate) struct SentinelInfo {
pub(crate) pid: u32,
pub(crate) hostname: String,
pub(crate) started_at_unix: u64,
}
impl SentinelInfo {
fn for_self() -> Self {
Self {
pid: std::process::id(),
hostname: current_hostname(),
started_at_unix: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
}
}
pub(crate) fn parse(text: &str) -> Option<Self> {
let mut lines = text.lines();
let pid: u32 = lines.next()?.trim().parse().ok()?;
let hostname = lines.next()?.trim().to_string();
let started_at_unix: u64 = lines.next().unwrap_or("0").trim().parse().unwrap_or(0);
Some(Self {
pid,
hostname,
started_at_unix,
})
}
pub(crate) fn serialise(&self) -> String {
format!(
"{}\n{}\n{}\n",
self.pid, self.hostname, self.started_at_unix
)
}
}
pub(crate) fn current_hostname() -> String {
let raw = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| {
std::process::Command::new("hostname")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or_default()
});
raw.replace(['\n', '\r'], "_").trim().to_string()
}
fn is_pid_alive(pid: u32) -> bool {
#[cfg(target_os = "linux")]
{
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
#[cfg(all(unix, not(target_os = "linux")))]
{
std::process::Command::new("/bin/kill")
.arg("-0")
.arg(pid.to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
{
let output = std::process::Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
.output();
match output {
Ok(o) => {
let stdout = String::from_utf8_lossy(&o.stdout);
!stdout.contains("INFO: No tasks")
}
Err(_) => true,
}
}
#[cfg(not(any(unix, target_os = "windows")))]
{
let _ = pid;
true
}
}
#[derive(Debug)]
pub struct SessionLock {
lock_path: PathBuf,
}
impl SessionLock {
pub fn acquire(session_dir: &Path) -> std::io::Result<Self> {
let lock_path = session_dir.join(SESSION_LOCK_FILE);
if lock_path.is_file() {
match std::fs::read_to_string(&lock_path) {
Ok(text) => {
if let Some(info) = SentinelInfo::parse(&text) {
let our_host = current_hostname();
if info.hostname != our_host {
return Err(std::io::Error::other(SessionLockBusy {
pid: info.pid,
hostname: info.hostname,
started_at_unix: info.started_at_unix,
session_dir: session_dir.to_path_buf(),
}));
}
if is_pid_alive(info.pid) {
return Err(std::io::Error::other(SessionLockBusy {
pid: info.pid,
hostname: info.hostname,
started_at_unix: info.started_at_unix,
session_dir: session_dir.to_path_buf(),
}));
}
eprintln!(
"warning: recovered stale session lock at {} \
(pid {} not running)",
lock_path.display(),
info.pid,
);
}
}
Err(_) => {
}
}
}
let info = SentinelInfo::for_self();
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&lock_path, info.serialise())?;
Ok(Self { lock_path })
}
pub fn path(&self) -> &Path {
&self.lock_path
}
}
impl Drop for SessionLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.lock_path);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lock_dead_pid_recovered() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let session_dir = tmp.path().join("session-B");
std::fs::create_dir_all(&session_dir).unwrap();
let stale = SentinelInfo {
pid: u32::MAX,
hostname: current_hostname(),
started_at_unix: 0,
};
std::fs::write(session_dir.join(SESSION_LOCK_FILE), stale.serialise()).unwrap();
let lock = SessionLock::acquire(&session_dir).unwrap();
let raw = std::fs::read_to_string(lock.path()).unwrap();
let parsed = SentinelInfo::parse(&raw).unwrap();
assert_eq!(parsed.pid, std::process::id());
}
#[test]
fn lock_cross_host_aborts() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let session_dir = tmp.path().join("session-C");
std::fs::create_dir_all(&session_dir).unwrap();
let cross = SentinelInfo {
pid: u32::MAX,
hostname: "definitely-not-our-host-123".to_string(),
started_at_unix: 0,
};
std::fs::write(session_dir.join(SESSION_LOCK_FILE), cross.serialise()).unwrap();
let err = SessionLock::acquire(&session_dir).expect_err("cross-host should fail");
assert!(
err.to_string().contains("definitely-not-our-host-123"),
"expected cross-host error to mention recorded host, got: {err}"
);
}
#[test]
fn lock_released_on_drop() {
let tmp = crate::test_util::IsolatedWorkspace::new();
let session_dir = tmp.path().join("session-D");
std::fs::create_dir_all(&session_dir).unwrap();
let lock = SessionLock::acquire(&session_dir).unwrap();
assert!(lock.path().exists());
drop(lock);
assert!(
!session_dir.join(SESSION_LOCK_FILE).exists(),
"sentinel must be removed on Drop"
);
let _lock2 = SessionLock::acquire(&session_dir).unwrap();
}
}