use anyhow::{Context, Result};
use std::fs::{self, File, OpenOptions};
use std::io::{Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use tracing::{info, warn};
#[cfg(unix)]
use nix::sys::signal::{self, Signal};
#[cfg(unix)]
use nix::unistd::Pid;
#[cfg(unix)]
use std::os::unix::io::AsRawFd;
pub fn enforce_singleton(pid_file: &Path) -> Result<PidGuard> {
#[cfg(unix)]
return unix_enforce_singleton(pid_file);
#[cfg(not(unix))]
{
warn!("PID enforcement is only supported on Unix systems");
Ok(PidGuard {
path: pid_file.to_path_buf(),
file: None,
})
}
}
#[cfg(unix)]
fn unix_enforce_singleton(pid_file: &Path) -> Result<PidGuard> {
if let Some(parent) = pid_file.parent() {
fs::create_dir_all(parent).context("Failed to create directory for PID file")?;
}
#[allow(clippy::suspicious_open_options)]
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(pid_file)
.context("Failed to open PID file")?;
let timeout = std::time::Duration::from_secs(10);
let start = std::time::Instant::now();
loop {
#[allow(deprecated)]
match nix::fcntl::flock(
file.as_raw_fd(),
nix::fcntl::FlockArg::LockExclusiveNonblock,
) {
Ok(_) => {
break;
}
Err(nix::errno::Errno::EWOULDBLOCK) => {
if start.elapsed() > timeout {
anyhow::bail!("Timed out waiting for previous sidecar instance to exit");
}
let content = fs::read_to_string(pid_file).unwrap_or_default();
let trimmed = content.trim();
if let Ok(old_pid_u32) = trimmed.parse::<u32>() {
if let Ok(old_pid) = i32::try_from(old_pid_u32) {
let pid = Pid::from_raw(old_pid);
if is_sidecar_process(old_pid_u32) {
kill_process(pid, old_pid_u32)?;
} else {
warn!(pid = old_pid, "Process holding lock does not appear to be exomonad (or cannot be verified). Waiting...");
}
}
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
Err(e) => {
return Err(e).context("Failed to acquire lock on PID file");
}
}
}
file.set_len(0).context("Failed to truncate PID file")?;
file.seek(SeekFrom::Start(0))
.context("Failed to seek PID file")?;
let current_pid = std::process::id();
file.write_all(current_pid.to_string().as_bytes())
.context("Failed to write to PID file")?;
info!(pid = current_pid, path = %pid_file.display(), "PID file locked and written");
Ok(PidGuard {
path: pid_file.to_path_buf(),
file: Some(file),
})
}
#[cfg(unix)]
fn is_sidecar_process(pid: u32) -> bool {
let output = std::process::Command::new("ps")
.arg("-p")
.arg(pid.to_string())
.arg("-o")
.arg("command=")
.output();
match output {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
stdout.contains("exomonad")
}
Err(_) => false, }
}
#[cfg(unix)]
fn kill_process(pid: Pid, old_pid: u32) -> Result<()> {
info!(
pid = old_pid,
"Attempting to terminate existing sidecar process"
);
match signal::kill(pid, Signal::SIGTERM) {
Ok(_) => {
let timeout = std::time::Duration::from_secs(5);
let start = std::time::Instant::now();
while start.elapsed() < timeout {
if signal::kill(pid, None).is_err() {
info!(pid = old_pid, "Process terminated after SIGTERM");
return Ok(());
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
warn!(
pid = old_pid,
"Process still alive after SIGTERM timeout, sending SIGKILL"
);
}
Err(nix::errno::Errno::ESRCH) => return Ok(()), Err(nix::errno::Errno::EPERM) => {
warn!(
pid = old_pid,
"Permission denied when trying to kill process"
);
return Ok(()); }
Err(e) => warn!(pid = old_pid, error = %e, "Failed to send SIGTERM"),
}
match signal::kill(pid, Signal::SIGKILL) {
Ok(_) => {
std::thread::sleep(std::time::Duration::from_millis(100));
if signal::kill(pid, None).is_ok() {
anyhow::bail!(
"Failed to kill process {} with SIGKILL (still running)",
old_pid
);
}
info!(pid = old_pid, "Process terminated after SIGKILL");
Ok(())
}
Err(nix::errno::Errno::ESRCH) => Ok(()),
Err(nix::errno::Errno::EPERM) => {
warn!(
pid = old_pid,
"Permission denied when trying to kill process (SIGKILL)"
);
Ok(())
}
Err(e) => {
warn!(pid = old_pid, error = %e, "Failed to send SIGKILL");
Ok(()) }
}
}
pub struct PidGuard {
path: PathBuf,
#[cfg(unix)]
#[allow(dead_code)]
file: Option<File>, #[cfg(not(unix))]
file: Option<()>,
}
impl PidGuard {
pub fn new(path: &Path) -> Result<Self> {
enforce_singleton(path)
}
}
impl Drop for PidGuard {
fn drop(&mut self) {
if self.path.exists() {
let _ = fs::remove_file(&self.path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_pid_guard_lifecycle() {
let dir = tempdir().unwrap();
let pid_file = dir.path().join("sidecar.pid");
{
let _guard = PidGuard::new(&pid_file).unwrap();
assert!(pid_file.exists());
let content = fs::read_to_string(&pid_file).unwrap();
assert_eq!(content.trim(), std::process::id().to_string());
}
assert!(!pid_file.exists());
}
#[cfg(unix)]
#[test]
fn test_lock_contention() {
let dir = tempdir().unwrap();
let pid_file = dir.path().join("contention.pid");
let file1 = OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&pid_file)
.unwrap();
#[allow(deprecated)]
nix::fcntl::flock(file1.as_raw_fd(), nix::fcntl::FlockArg::LockExclusive).unwrap();
let path_clone = pid_file.clone();
let handle = std::thread::spawn(move || {
enforce_singleton(&path_clone).is_ok()
});
std::thread::sleep(std::time::Duration::from_millis(500));
drop(file1);
assert!(handle.join().unwrap());
}
}