use solo_core::{Error, Result};
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
fn is_pid_alive(pid: u32) -> bool {
let sys = System::new_with_specifics(
RefreshKind::new().with_processes(ProcessRefreshKind::new()),
);
sys.process(Pid::from_u32(pid)).is_some()
}
#[derive(Debug)]
pub struct Lockfile {
path: PathBuf,
_handle: File,
}
impl Lockfile {
pub fn acquire(path: &Path) -> Result<Self> {
match Self::try_create(path) {
Ok(lf) => Ok(lf),
Err(Error::Conflict(_)) => {
Self::try_recover_stale(path)?;
Self::try_create(path)
}
Err(e) => Err(e),
}
}
fn try_recover_stale(path: &Path) -> Result<()> {
let body = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(_) => {
return Err(Self::held_error(path, None));
}
};
let pid = body.trim().parse::<u32>().ok();
let alive = match pid {
Some(p) => is_pid_alive(p),
None => false,
};
if alive {
return Err(Self::held_error(path, pid));
}
tracing::warn!(
?pid,
path = %path.display(),
"stale lockfile detected (pid not alive); removing"
);
std::fs::remove_file(path)
.map_err(|e| Error::storage(format!("remove stale lockfile {}: {e}", path.display())))?;
Ok(())
}
fn try_create(path: &Path) -> Result<Self> {
let mut handle = OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|e| match e.kind() {
std::io::ErrorKind::AlreadyExists => Self::held_error(path, None),
_ => Error::storage(format!("open lockfile {}: {e}", path.display())),
})?;
let pid = std::process::id();
write!(handle, "{pid}")
.map_err(|e| Error::storage(format!("write pid to lockfile: {e}")))?;
handle
.sync_all()
.map_err(|e| Error::storage(format!("fsync lockfile: {e}")))?;
Ok(Self {
path: path.to_path_buf(),
_handle: handle,
})
}
fn held_error(path: &Path, pid: Option<u32>) -> Error {
let pid_msg = match pid {
Some(p) => format!(" (held by pid {p})"),
None => String::new(),
};
Error::conflict(format!(
"lockfile {} already exists{pid_msg} — another Solo process is \
running. If you're sure no other instance is alive, remove the \
file manually.",
path.display()
))
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for Lockfile {
fn drop(&mut self) {
if let Err(e) = std::fs::remove_file(&self.path) {
tracing::warn!(
error = %e,
path = %self.path.display(),
"failed to remove lockfile on drop"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn acquire_creates_file_with_pid() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.lock");
let _lock = Lockfile::acquire(&path).unwrap();
assert!(path.exists());
let body = std::fs::read_to_string(&path).unwrap();
let pid: u32 = body.parse().expect("pid should be a number");
assert_eq!(pid, std::process::id());
}
#[test]
fn second_acquire_fails_with_conflict() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.lock");
let _lock = Lockfile::acquire(&path).unwrap();
let err = Lockfile::acquire(&path).unwrap_err();
assert!(matches!(err, Error::Conflict(_)), "got: {err:?}");
}
#[test]
fn drop_removes_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.lock");
{
let _lock = Lockfile::acquire(&path).unwrap();
assert!(path.exists());
}
assert!(!path.exists(), "lockfile should be removed on drop");
}
#[test]
fn re_acquire_after_drop_succeeds() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.lock");
{
let _lock = Lockfile::acquire(&path).unwrap();
}
let _lock2 = Lockfile::acquire(&path).unwrap();
}
#[test]
fn stale_lockfile_with_dead_pid_is_recovered() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.lock");
std::fs::write(&path, format!("{}", u32::MAX)).unwrap();
let lock = Lockfile::acquire(&path).unwrap();
assert!(path.exists());
let body = std::fs::read_to_string(&path).unwrap();
let pid: u32 = body.trim().parse().unwrap();
assert_eq!(pid, std::process::id());
drop(lock);
}
#[test]
fn stale_lockfile_with_unparseable_body_is_recovered() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.lock");
std::fs::write(&path, b"<garbage from a partial write>").unwrap();
let _lock = Lockfile::acquire(&path).unwrap();
}
#[test]
fn live_pid_is_not_recovered() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("solo.lock");
std::fs::write(&path, format!("{}", std::process::id())).unwrap();
let err = Lockfile::acquire(&path).unwrap_err();
assert!(matches!(err, Error::Conflict(_)), "got: {err:?}");
assert!(path.exists());
}
}