use crate::config;
use anyhow::{anyhow, Result};
use std::path::{Path, PathBuf};
pub struct WatchLock {
path: PathBuf,
started_at: String,
}
pub struct LockInfo {
pub pid: u32,
pub host: String,
pub version: Option<String>,
pub started_at: Option<String>,
pub age_secs: u64,
pub alive: bool,
pub same_host: bool,
pub fresh: bool,
}
#[derive(PartialEq, Eq, Debug, Clone, Copy)]
pub enum WatchState {
Healthy,
NotWatching,
Stale,
Outdated,
OtherHost,
}
pub fn classify(info: &Option<LockInfo>, cur_version: &str) -> WatchState {
match info {
None => WatchState::NotWatching,
Some(i) if !i.same_host => WatchState::OtherHost,
Some(i) if !(i.alive && i.fresh) => WatchState::Stale,
Some(i) if i.version.as_deref() != Some(cur_version) => WatchState::Outdated,
Some(_) => WatchState::Healthy,
}
}
pub fn inspect(hub: &str, role: &str, stale_secs: u64) -> Option<LockInfo> {
let path = lock_path(hub, role).ok()?;
if !path.exists() {
return None;
}
let v: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).ok()?).ok()?;
let pid = v.get("pid")?.as_u64()? as u32;
let host = v.get("host").and_then(|x| x.as_str()).unwrap_or_default().to_string();
let version = v.get("version").and_then(|x| x.as_str()).map(String::from);
let started_at = v.get("started_at").and_then(|x| x.as_str()).map(String::from);
let same_host = host == config::hostname().unwrap_or_default();
let age = age_secs(&path);
Some(LockInfo {
alive: same_host && process_alive(pid),
same_host,
fresh: age < stale_secs,
age_secs: age,
pid,
host,
version,
started_at,
})
}
fn lock_path(hub: &str, role: &str) -> Result<PathBuf> {
let role = if role.is_empty() { "_all" } else { role };
Ok(config::home()?
.join(".confer")
.join("watch")
.join(hub)
.join(format!("{role}.json")))
}
fn process_alive(pid: u32) -> bool {
std::process::Command::new("kill")
.args(["-0", &pid.to_string()])
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn read_info(path: &Path) -> Option<(u32, String)> {
let v: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?;
Some((v.get("pid")?.as_u64()? as u32, v.get("host")?.as_str()?.to_string()))
}
fn age_secs(path: &Path) -> u64 {
std::fs::metadata(path)
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.elapsed().ok())
.map(|d| d.as_secs())
.unwrap_or(u64::MAX)
}
impl WatchLock {
pub fn acquire(hub: &str, role: &str, stale_secs: u64, replace: bool) -> Result<Self> {
let path = lock_path(hub, role)?;
if let Some(p) = path.parent() {
std::fs::create_dir_all(p)?;
}
let label = if role.is_empty() { "<all>" } else { role };
if path.exists() {
let this_host = config::hostname().unwrap_or_default();
let info = read_info(&path);
let same_host = info.as_ref().is_some_and(|(_, h)| *h == this_host);
let pid = info.as_ref().map(|(p, _)| *p);
let alive = same_host && pid.is_some_and(process_alive);
let fresh = age_secs(&path) < stale_secs;
if alive && fresh {
if !replace {
return Err(anyhow!(
"another confer watch for role '{label}' is already running on {this_host} \
(pid {}). It owns the cursor — a second watcher would race it and silently \
drop events. Stop it, or run `confer watch --replace` to take over.",
pid.unwrap_or(0)
));
}
if let Some(p) = pid {
let _ = std::process::Command::new("kill").arg(p.to_string())
.stderr(std::process::Stdio::null()).status();
eprintln!("confer watch: --replace killed the existing watcher (pid {p}) for role '{label}'.");
}
} else {
eprintln!(
"confer watch: reclaimed a stale watch lock for role '{label}' \
(previous watcher not running or unresponsive)."
);
}
}
let lock = WatchLock {
path,
started_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
};
lock.write()?;
Ok(lock)
}
fn write(&self) -> Result<()> {
let info = serde_json::json!({
"pid": std::process::id(),
"host": config::hostname().unwrap_or_default(),
"version": env!("CONFER_GIT_SHA"),
"started_at": self.started_at,
});
std::fs::write(&self.path, serde_json::to_string_pretty(&info)?)?;
Ok(())
}
pub fn heartbeat(&self) {
let _ = self.write();
}
}
impl Drop for WatchLock {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}