use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonRegistryEntry {
pub project: PathBuf,
pub pid: u32,
pub socket: PathBuf,
pub started_at: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DaemonRegistry {
pub daemons: Vec<DaemonRegistryEntry>,
}
const CAS_RETRY_ATTEMPTS: usize = 3;
pub fn registry_file_path() -> PathBuf {
if let Ok(dir) = std::env::var("TLDR_DAEMON_REGISTRY_DIR") {
return PathBuf::from(dir).join("daemon-registry.json");
}
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from(".cache"))
.join("tldr")
.join("daemon-registry.json")
}
fn write_registry_atomic(registry: &DaemonRegistry) -> std::io::Result<()> {
let path = registry_file_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(registry).map_err(std::io::Error::other)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &path)?;
Ok(())
}
pub fn read_registry() -> DaemonRegistry {
migrate_from_active_if_needed();
let path = registry_file_path();
let mut registry = match std::fs::read_to_string(&path) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => DaemonRegistry::default(),
};
let original_len = registry.daemons.len();
registry.daemons.retain(|d| is_pid_alive(d.pid));
if registry.daemons.len() != original_len {
let _ = write_registry_atomic(®istry);
}
registry
}
pub fn live_entries() -> Vec<DaemonRegistryEntry> {
read_registry().daemons
}
pub fn find_entry(project: &Path) -> Option<DaemonRegistryEntry> {
let canon = project
.canonicalize()
.unwrap_or_else(|_| project.to_path_buf());
read_registry()
.daemons
.into_iter()
.find(|d| d.project == canon)
}
pub fn add_entry(project: &Path, pid: u32, socket: &Path) -> std::io::Result<()> {
let canon = project
.canonicalize()
.unwrap_or_else(|_| project.to_path_buf());
let path = registry_file_path();
for _attempt in 0..CAS_RETRY_ATTEMPTS {
let pre_mtime = std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok());
let mut registry = read_registry();
registry.daemons.retain(|d| d.project != canon);
registry.daemons.push(DaemonRegistryEntry {
project: canon.clone(),
pid,
socket: socket.to_path_buf(),
started_at: chrono::Utc::now().to_rfc3339(),
});
let post_mtime = std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok());
if pre_mtime == post_mtime {
return write_registry_atomic(®istry);
}
}
Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"daemon registry contended after 3 CAS attempts",
))
}
pub fn remove_entry(project: &Path) -> std::io::Result<()> {
let canon = project
.canonicalize()
.unwrap_or_else(|_| project.to_path_buf());
let path = registry_file_path();
for _attempt in 0..CAS_RETRY_ATTEMPTS {
let pre_mtime = std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok());
let mut registry = read_registry();
let before = registry.daemons.len();
registry.daemons.retain(|d| d.project != canon);
if registry.daemons.len() == before {
return Ok(());
}
let post_mtime = std::fs::metadata(&path)
.ok()
.and_then(|m| m.modified().ok());
if pre_mtime == post_mtime {
return write_registry_atomic(®istry);
}
}
Err(std::io::Error::new(
std::io::ErrorKind::WouldBlock,
"daemon registry contended after 3 CAS attempts",
))
}
fn migrate_from_active_if_needed() {
let registry_path = registry_file_path();
if registry_path.exists() {
return;
}
let active_path = match registry_path.parent() {
Some(p) => p.join("daemon-active.json"),
None => return,
};
if !active_path.exists() {
return;
}
let migrated = match std::fs::read_to_string(&active_path) {
Ok(content) => match serde_json::from_str::<super::daemon_active::DaemonActive>(&content) {
Ok(active) if is_pid_alive(active.pid) => Some(DaemonRegistryEntry {
project: active.project,
pid: active.pid,
socket: active.socket,
started_at: chrono::Utc::now().to_rfc3339(),
}),
_ => None,
},
Err(_) => None,
};
if let Some(entry) = migrated {
let registry = DaemonRegistry {
daemons: vec![entry],
};
let _ = write_registry_atomic(®istry);
}
let _ = std::fs::remove_file(&active_path);
}
#[cfg(unix)]
fn is_pid_alive(pid: u32) -> bool {
let rc = unsafe { libc::kill(pid as i32, 0) };
if rc == 0 {
return true;
}
matches!(
std::io::Error::last_os_error().raw_os_error(),
Some(libc::EPERM)
)
}
#[cfg(not(unix))]
fn is_pid_alive(_pid: u32) -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use tempfile::TempDir;
static REGISTRY_ENV_LOCK: Mutex<()> = Mutex::new(());
fn with_registry_dir<F: FnOnce(&Path)>(prefix: &str, f: F) {
let _guard = REGISTRY_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let tmp = TempDir::new().expect("tempdir");
std::env::set_var("TLDR_DAEMON_REGISTRY_DIR", tmp.path());
let _prefix = prefix;
f(tmp.path());
std::env::remove_var("TLDR_DAEMON_REGISTRY_DIR");
}
#[test]
fn registry_path_honors_env_override() {
with_registry_dir("env-override", |dir| {
let path = registry_file_path();
assert_eq!(path, dir.join("daemon-registry.json"));
});
}
#[test]
fn read_registry_on_missing_file_returns_empty() {
with_registry_dir("missing-file", |_dir| {
let r = read_registry();
assert!(r.daemons.is_empty());
});
}
#[test]
fn add_then_find_round_trips() {
with_registry_dir("round-trip", |dir| {
let project = dir.join("proj");
std::fs::create_dir_all(&project).unwrap();
let socket = dir.join("proj.sock");
add_entry(&project, std::process::id(), &socket).expect("add");
let found = find_entry(&project).expect("entry should exist");
assert_eq!(found.pid, std::process::id());
assert_eq!(found.socket, socket);
});
}
#[test]
fn remove_entry_drops_record() {
with_registry_dir("remove", |dir| {
let project = dir.join("proj-r");
std::fs::create_dir_all(&project).unwrap();
let socket = dir.join("proj-r.sock");
add_entry(&project, std::process::id(), &socket).expect("add");
remove_entry(&project).expect("remove");
assert!(find_entry(&project).is_none());
});
}
#[test]
fn dead_pid_entries_are_pruned_on_read() {
with_registry_dir("prune", |dir| {
let project = dir.join("proj-dead");
std::fs::create_dir_all(&project).unwrap();
let mut child = std::process::Command::new("true")
.spawn()
.expect("spawn true");
let dead_pid = child.id();
let _ = child.wait();
let socket = dir.join("proj-dead.sock");
add_entry(&project, dead_pid, &socket).expect("add");
let live = live_entries();
assert!(
live.iter().all(|d| d.pid != dead_pid),
"dead PID entry should have been pruned on read"
);
});
}
}