aboutsummaryrefslogtreecommitdiff
path: root/tests/support
diff options
context:
space:
mode:
Diffstat (limited to 'tests/support')
-rw-r--r--tests/support/mod.rs418
1 files changed, 418 insertions, 0 deletions
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
new file mode 100644
index 00000000..567db154
--- /dev/null
+++ b/tests/support/mod.rs
@@ -0,0 +1,418 @@
+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<Mutex<HashSet<u32>>> = OnceLock::new();
+static RUNTIME_DIR_REGISTRY: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
+static INIT: Once = Once::new();
+static CLEANUP_GUARD: OnceLock<CleanupGuard> = 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<u32>) {
+ 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<u32>) {
+ 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<u32> = {
+ let mut registry = pid_registry_lock();
+ registry.drain().collect()
+ };
+
+ for pid in pids {
+ terminate_pid(pid);
+ }
+
+ let runtime_dirs: HashSet<PathBuf> = {
+ 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<u32>> {
+ 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<PathBuf>> {
+ RUNTIME_DIR_REGISTRY
+ .get_or_init(|| Mutex::new(HashSet::new()))
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+}
+
+fn registered_runtime_dirs_snapshot() -> HashSet<PathBuf> {
+ 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<PathBuf>,
+) -> 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, &registered_runtime_dirs) {
+ terminate_pid(pid);
+ }
+ }
+
+ Ok(())
+}
+
+fn terminate_servers_for_runtime_dirs(runtime_dirs: &HashSet<PathBuf>) {
+ 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<Vec<u32>> {
+ 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::<u32>().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<PathBuf> {
+ fs::read_link(format!("/proc/{pid}/{link}")).ok()
+}
+
+fn read_cmdline(pid: u32) -> std::io::Result<Vec<String>> {
+ 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<Option<PathBuf>> {
+ let environ = fs::read(format!("/proc/{pid}/environ"))?;
+
+ let mut socket_path: Option<PathBuf> = 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::<libc::pid_t>() 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, &registered_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, &registered_runtime_dirs),
+ "missing runtime dirs that are session-owned should be considered killable"
+ );
+ }
+}