use std::fs::{self, File};
use std::io::{Read, Write};
use std::path::PathBuf;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum SingletonError {
#[error("Another instance is already running (PID: {0})")]
AlreadyRunning(u32),
#[error("Failed to create lock directory: {0}")]
CreateDirFailed(String),
#[error("Failed to create lock file: {0}")]
LockFailed(String),
#[error("Invalid lock file path")]
InvalidPath,
}
pub struct InstanceLock {
pid_path: PathBuf,
}
impl InstanceLock {
pub fn acquire(pid_path: PathBuf) -> Result<Self, SingletonError> {
if let Some(parent) = pid_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| SingletonError::CreateDirFailed(e.to_string()))?;
}
if pid_path.exists() {
let mut file =
File::open(&pid_path).map_err(|e| SingletonError::LockFailed(e.to_string()))?;
let mut contents = String::new();
file.read_to_string(&mut contents)
.map_err(|e| SingletonError::LockFailed(e.to_string()))?;
if let Ok(pid) = contents.trim().parse::<u32>() {
if is_process_running(pid) {
return Err(SingletonError::AlreadyRunning(pid));
}
tracing::info!("Removing stale PID file (PID {} not running)", pid);
}
fs::remove_file(&pid_path).map_err(|e| SingletonError::LockFailed(e.to_string()))?;
}
let mut file =
File::create(&pid_path).map_err(|e| SingletonError::LockFailed(e.to_string()))?;
write!(file, "{}", std::process::id())
.map_err(|e| SingletonError::LockFailed(e.to_string()))?;
tracing::debug!("Acquired singleton lock at: {}", pid_path.display());
Ok(Self { pid_path })
}
pub fn release(self) {
}
pub fn pid_path(&self) -> &PathBuf {
&self.pid_path
}
}
impl Drop for InstanceLock {
fn drop(&mut self) {
if let Err(e) = fs::remove_file(&self.pid_path) {
tracing::warn!("Failed to remove PID file on drop: {}", e);
} else {
tracing::debug!("Released singleton lock: {}", self.pid_path.display());
}
}
}
fn is_process_running(pid: u32) -> bool {
#[cfg(unix)]
{
match std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.output()
{
Ok(output) => output.status.success(),
Err(_) => {
#[cfg(target_os = "linux")]
{
std::path::Path::new(&format!("/proc/{pid}")).exists()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
}
}
#[cfg(windows)]
{
false
}
#[cfg(not(any(unix, windows)))]
{
false
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_acquire_creates_pid_file() {
let temp_dir = TempDir::new().unwrap();
let pid_path = temp_dir.path().join("test.pid");
let lock = InstanceLock::acquire(pid_path.clone()).unwrap();
assert!(pid_path.exists());
let contents = std::fs::read_to_string(&pid_path).unwrap();
let written_pid: u32 = contents.trim().parse().unwrap();
assert_eq!(written_pid, std::process::id());
drop(lock);
assert!(!pid_path.exists());
}
#[test]
fn test_acquire_fails_when_already_locked() {
let temp_dir = TempDir::new().unwrap();
let pid_path = temp_dir.path().join("test.pid");
let _lock1 = InstanceLock::acquire(pid_path.clone()).unwrap();
let result = InstanceLock::acquire(pid_path.clone());
assert!(matches!(result, Err(SingletonError::AlreadyRunning(_))));
}
#[test]
fn test_stale_lock_cleanup() {
let temp_dir = TempDir::new().unwrap();
let pid_path = temp_dir.path().join("test.pid");
std::fs::write(&pid_path, "999999").unwrap();
let lock = InstanceLock::acquire(pid_path.clone());
if lock.is_ok() {
assert!(pid_path.exists());
let contents = std::fs::read_to_string(&pid_path).unwrap();
let written_pid: u32 = contents.trim().parse().unwrap();
assert_eq!(written_pid, std::process::id());
}
}
#[test]
fn test_is_process_running_with_current_process() {
let current_pid = std::process::id();
assert!(is_process_running(current_pid));
}
#[test]
fn test_is_process_running_with_invalid_pid() {
let unlikely_pid = 4_000_000_000;
assert!(!is_process_running(unlikely_pid));
}
#[test]
fn test_creates_parent_directories() {
let temp_dir = TempDir::new().unwrap();
let pid_path = temp_dir
.path()
.join("deep")
.join("nested")
.join("dir")
.join("test.pid");
let lock = InstanceLock::acquire(pid_path.clone()).unwrap();
assert!(pid_path.exists());
drop(lock);
}
}