use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Mutex, Once, OnceLock}; use std::thread; use std::time::{Duration, Instant}; static PID_REGISTRY: OnceLock>> = OnceLock::new(); static RUNTIME_DIR_REGISTRY: OnceLock>> = OnceLock::new(); static INIT: Once = Once::new(); static CLEANUP_GUARD: OnceLock = OnceLock::new(); const WATCHDOG_SCAN_INTERVAL: Duration = Duration::from_secs(1); const RUNTIME_OWNER_MARKER: &str = ".herdr-test-owner-pid"; pub fn register_spawned_herdr_pid(pid: Option) { let Some(pid) = pid else { return; }; ensure_cleanup_hooks(); let mut registry = pid_registry_lock(); registry.insert(pid); } pub fn unregister_spawned_herdr_pid(pid: Option) { let Some(pid) = pid else { return; }; if let Some(registry) = PID_REGISTRY.get() { let mut guard = registry .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); guard.remove(&pid); } } pub fn register_runtime_dir(path: &Path) { ensure_cleanup_hooks(); let _ = fs::create_dir_all(path); let _ = fs::write( path.join(RUNTIME_OWNER_MARKER), std::process::id().to_string(), ); let mut runtime_dirs = runtime_dir_registry_lock(); runtime_dirs.insert(path.to_path_buf()); } pub fn unregister_runtime_dir(path: &Path) { if let Some(registry) = RUNTIME_DIR_REGISTRY.get() { let mut guard = registry .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); guard.remove(path); } } pub fn cleanup_test_base(base: &Path) { let runtime_dir = base.join("runtime"); let runtime_dirs = HashSet::from([runtime_dir.clone()]); terminate_servers_for_runtime_dirs(&runtime_dirs); unregister_runtime_dir(&runtime_dir); let _ = fs::remove_dir_all(base); } pub fn cleanup_registered_herdr_pids() { let pids: Vec = { let mut registry = pid_registry_lock(); registry.drain().collect() }; for pid in pids { terminate_pid(pid); } let runtime_dirs: HashSet = { let mut runtime_dirs = runtime_dir_registry_lock(); runtime_dirs.drain().collect() }; terminate_servers_for_runtime_dirs(&runtime_dirs); let _ = cleanup_servers_with_missing_runtime_dir(); } fn ensure_cleanup_hooks() { INIT.call_once(|| { let _ = cleanup_servers_with_missing_runtime_dir(); start_global_watchdog(); let _ = CLEANUP_GUARD.set(CleanupGuard); let previous_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { cleanup_registered_herdr_pids(); previous_hook(panic_info); })); let _ = ctrlc::set_handler(|| { cleanup_registered_herdr_pids(); std::process::exit(130); }); unsafe { libc::atexit(run_atexit_cleanup); } }); } fn pid_registry_lock() -> std::sync::MutexGuard<'static, HashSet> { PID_REGISTRY .get_or_init(|| Mutex::new(HashSet::new())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } fn runtime_dir_registry_lock() -> std::sync::MutexGuard<'static, HashSet> { RUNTIME_DIR_REGISTRY .get_or_init(|| Mutex::new(HashSet::new())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } fn registered_runtime_dirs_snapshot() -> HashSet { if let Some(runtime_dirs) = RUNTIME_DIR_REGISTRY.get() { runtime_dirs .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .clone() } else { HashSet::new() } } fn should_terminate_runtime_dir( runtime_dir: &Path, registered_runtime_dirs: &HashSet, ) -> bool { if !registered_runtime_dirs.contains(runtime_dir) { return false; } if !runtime_dir.exists() { return true; } !runtime_dir_owner_alive(runtime_dir) } fn start_global_watchdog() { thread::spawn(|| loop { thread::sleep(WATCHDOG_SCAN_INTERVAL); if let Err(err) = cleanup_servers_with_missing_runtime_dir() { eprintln!("herdr test cleanup watchdog error: {err}"); } }); } fn cleanup_servers_with_missing_runtime_dir() -> std::io::Result<()> { let registered_runtime_dirs = registered_runtime_dirs_snapshot(); if registered_runtime_dirs.is_empty() { return Ok(()); } for pid in iter_worktree_server_pids()? { let Some(runtime_dir) = process_runtime_dir(pid)? else { continue; }; if should_terminate_runtime_dir(&runtime_dir, ®istered_runtime_dirs) { terminate_pid(pid); } } Ok(()) } fn terminate_servers_for_runtime_dirs(runtime_dirs: &HashSet) { if runtime_dirs.is_empty() { return; } let Ok(pids) = iter_worktree_server_pids() else { return; }; for pid in pids { let Ok(runtime_dir) = process_runtime_dir(pid) else { continue; }; let Some(runtime_dir) = runtime_dir else { continue; }; if runtime_dirs.contains(&runtime_dir) { terminate_pid(pid); } } } fn iter_worktree_server_pids() -> std::io::Result> { let own_pid = std::process::id(); let mut pids = Vec::new(); for entry in fs::read_dir("/proc")? { let entry = entry?; let file_name = entry.file_name(); let Some(pid) = file_name.to_str().and_then(|name| name.parse::().ok()) else { continue; }; if pid == own_pid { continue; } if is_worktree_herdr_server_process(pid) { pids.push(pid); } } Ok(pids) } fn is_worktree_herdr_server_process(pid: u32) -> bool { let Some(exe_path) = proc_link_target(pid, "exe") else { return false; }; if !is_worktree_herdr_binary(&exe_path) { return false; } let Ok(cmdline) = read_cmdline(pid) else { return false; }; cmdline.iter().any(|arg| arg == "server") } fn proc_link_target(pid: u32, link: &str) -> Option { fs::read_link(format!("/proc/{pid}/{link}")).ok() } fn read_cmdline(pid: u32) -> std::io::Result> { let cmdline = fs::read(format!("/proc/{pid}/cmdline"))?; Ok(cmdline .split(|byte| *byte == 0) .filter(|chunk| !chunk.is_empty()) .map(|chunk| String::from_utf8_lossy(chunk).to_string()) .collect()) } fn process_runtime_dir(pid: u32) -> std::io::Result> { let environ = fs::read(format!("/proc/{pid}/environ"))?; let mut socket_path: Option = None; for entry in environ.split(|byte| *byte == 0) { if entry.is_empty() { continue; } let kv = String::from_utf8_lossy(entry); if let Some(value) = kv.strip_prefix("XDG_RUNTIME_DIR=") { return Ok(Some(PathBuf::from(value))); } if let Some(value) = kv.strip_prefix("HERDR_SOCKET_PATH=") { socket_path = Some(PathBuf::from(value)); } } Ok(socket_path.and_then(|path| path.parent().map(Path::to_path_buf))) } fn runtime_dir_owner_alive(runtime_dir: &Path) -> bool { let marker = runtime_dir.join(RUNTIME_OWNER_MARKER); let Ok(contents) = fs::read_to_string(marker) else { return false; }; let Ok(owner_pid) = contents.trim().parse::() else { return false; }; process_exists(owner_pid) } fn is_worktree_herdr_binary(path: &Path) -> bool { path.ends_with("target/debug/herdr") && path .components() .any(|component| component.as_os_str() == "herdr-worktrees") } extern "C" fn run_atexit_cleanup() { cleanup_registered_herdr_pids(); } struct CleanupGuard; impl Drop for CleanupGuard { fn drop(&mut self) { cleanup_registered_herdr_pids(); } } fn terminate_pid(pid: u32) { let pid_t = pid as libc::pid_t; if process_exists(pid_t) { unsafe { libc::kill(pid_t, libc::SIGTERM); } } if wait_for_pid_exit(pid_t, Duration::from_millis(400)) { return; } if process_exists(pid_t) { unsafe { libc::kill(pid_t, libc::SIGKILL); } } let _ = wait_for_pid_exit(pid_t, Duration::from_secs(2)); } fn wait_for_pid_exit(pid: libc::pid_t, timeout: Duration) -> bool { let deadline = Instant::now() + timeout; while Instant::now() < deadline { if !process_exists(pid) { return true; } let mut status = 0; let result = unsafe { libc::waitpid(pid, &mut status, libc::WNOHANG) }; if result == pid { return true; } if result == -1 { match std::io::Error::last_os_error().raw_os_error() { Some(libc::ECHILD) => { // Not our child (or already reaped elsewhere). Poll /proc existence // until the process is truly gone. if !process_exists(pid) { return true; } } Some(libc::ESRCH) => return true, _ => { if !process_exists(pid) { return true; } } } } thread::sleep(Duration::from_millis(20)); } !process_exists(pid) } fn process_exists(pid: libc::pid_t) -> bool { let result = unsafe { libc::kill(pid, 0) }; if result == 0 { true } else { std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) } } #[cfg(test)] mod tests { use super::*; fn unique_missing_runtime_dir(label: &str) -> PathBuf { let unique = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos(); std::env::temp_dir().join(format!( "herdr-watchdog-scoping-{label}-{}-{unique}", std::process::id() )) } #[test] fn watchdog_scoping_does_not_terminate_missing_unregistered_runtime_dir() { let runtime_dir = unique_missing_runtime_dir("unregistered"); let registered_runtime_dirs = HashSet::new(); assert!( !should_terminate_runtime_dir(&runtime_dir, ®istered_runtime_dirs), "missing runtime dirs must not be killable until they are proven session-owned" ); } #[test] fn watchdog_scoping_terminates_missing_registered_runtime_dir() { let runtime_dir = unique_missing_runtime_dir("registered"); let mut registered_runtime_dirs = HashSet::new(); registered_runtime_dirs.insert(runtime_dir.clone()); assert!( should_terminate_runtime_dir(&runtime_dir, ®istered_runtime_dirs), "missing runtime dirs that are session-owned should be considered killable" ); } }