use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::time::{Duration, Instant, SystemTime};
use anyhow::{Context, Result};
use serde_json::json;
const REGISTRY_POLL_SECS: u64 = 10;
const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
const MAX_BACKOFF: Duration = Duration::from_secs(60);
const RAPID_FAIL_WINDOW: Duration = Duration::from_secs(10);
const DEFAULT_MAX_IDLE_DAYS: u64 = 7;
fn parse_max_idle(raw: Option<&str>) -> Option<Duration> {
match raw {
Some(v) => {
let days: u64 = v.trim().parse().unwrap_or(DEFAULT_MAX_IDLE_DAYS);
(days != 0).then(|| Duration::from_secs(days * 86_400))
}
None => Some(Duration::from_secs(DEFAULT_MAX_IDLE_DAYS * 86_400)),
}
}
fn max_idle_from_env() -> Option<Duration> {
parse_max_idle(
std::env::var("WIRE_ALL_SESSIONS_MAX_IDLE_DAYS")
.ok()
.as_deref(),
)
}
fn fs_last_active(home: &Path) -> Option<SystemTime> {
let state = home.join("state").join("wire");
["last_sync.json", "notify.cursor", "reactor.cursor"]
.iter()
.filter_map(|f| std::fs::metadata(state.join(f)).ok())
.filter_map(|m| m.modified().ok())
.max()
}
fn supervisor_eligible<F>(
sessions: Vec<crate::session::SessionInfo>,
max_idle: Option<Duration>,
now: SystemTime,
last_active: F,
) -> Vec<crate::session::SessionInfo>
where
F: Fn(&Path) -> Option<SystemTime>,
{
let Some(max_idle) = max_idle else {
return sessions;
};
sessions
.into_iter()
.filter(|s| {
if s.cwd.is_some() {
return true;
}
match last_active(&s.home_dir) {
Some(t) => now.duration_since(t).map(|d| d <= max_idle).unwrap_or(true),
None => false,
}
})
.collect()
}
const DEFAULT_HUSK_REAP_MAX_AGE_HOURS: u64 = 48;
const HUSK_REAP_INTERVAL: Duration = Duration::from_secs(3600);
fn parse_husk_reap_max_age(raw: Option<&str>) -> Option<Duration> {
match raw {
Some(v) => {
let hours: u64 = v.trim().parse().unwrap_or(DEFAULT_HUSK_REAP_MAX_AGE_HOURS);
(hours != 0).then(|| Duration::from_secs(hours * 3600))
}
None => Some(Duration::from_secs(DEFAULT_HUSK_REAP_MAX_AGE_HOURS * 3600)),
}
}
fn husk_reap_max_age_from_env() -> Option<Duration> {
parse_husk_reap_max_age(
std::env::var("WIRE_HUSK_REAP_MAX_AGE_HOURS")
.ok()
.as_deref(),
)
}
fn reap_husks<F>(
by_key_root: &Path,
max_age: Duration,
now: SystemTime,
bound_names: &std::collections::HashSet<String>,
daemon_live: F,
) -> Vec<PathBuf>
where
F: Fn(&Path) -> bool,
{
let mut reaped = Vec::new();
let Ok(entries) = std::fs::read_dir(by_key_root) else {
return reaped; };
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
let is_by_key_shape =
name.len() == 16 && name.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'));
if !is_by_key_shape {
continue;
}
if bound_names.contains(name) {
continue;
}
if path
.join("config")
.join("wire")
.join("private.key")
.exists()
{
continue;
}
if fs_last_active(&path).is_some() {
continue;
}
if daemon_live(&path) {
continue;
}
let old_enough = std::fs::metadata(&path)
.and_then(|m| m.modified())
.ok()
.and_then(|m| now.duration_since(m).ok())
.is_some_and(|age| age >= max_age);
if !old_enough {
continue;
}
match std::fs::remove_dir_all(&path) {
Ok(()) => reaped.push(path),
Err(e) => eprintln!("supervisor: husk reap failed for {}: {e:#}", path.display()),
}
}
reaped
}
struct ChildState {
child: Child,
spawned_at: Instant,
}
pub fn run_supervisor(interval_secs: u64, as_json: bool) -> Result<()> {
let pid_path = supervisor_pid_path()?;
if let Some(existing) = read_alive_supervisor_pid(&pid_path)? {
let msg = json!({
"status": "skipped",
"reason": "supervisor already running",
"holder_pid": existing,
});
if as_json {
println!("{msg}");
} else {
eprintln!(
"wire daemon --all-sessions: another supervisor is already running (pid {existing}); not starting a second one."
);
}
return Ok(());
}
write_supervisor_pid(&pid_path)?;
let _cleanup = SupervisorPidGuard {
path: pid_path.clone(),
};
if !as_json {
eprintln!(
"wire daemon --all-sessions: supervisor up. interval={interval_secs}s, registry-poll={REGISTRY_POLL_SECS}s. SIGINT to stop."
);
} else {
println!(
"{}",
json!({
"status": "supervisor_started",
"interval_secs": interval_secs,
"registry_poll_secs": REGISTRY_POLL_SECS,
})
);
}
let max_idle = max_idle_from_env();
eprintln!(
"supervisor: idle cutoff for unbound sessions = {}",
match max_idle {
Some(d) => format!("{} days", d.as_secs() / 86_400),
None => "disabled (spawn-for-all)".to_string(),
}
);
let husk_max_age = husk_reap_max_age_from_env();
eprintln!(
"supervisor: husk reap cutoff = {}",
match husk_max_age {
Some(d) => format!("{} hours", d.as_secs() / 3600),
None => "disabled".to_string(),
}
);
let mut last_husk_reap: Option<Instant> = None;
let mut children: HashMap<String, ChildState> = HashMap::new();
let mut session_last_exit: HashMap<String, Instant> = HashMap::new();
let mut session_backoff: HashMap<String, Duration> = HashMap::new();
loop {
let mut exited: Vec<String> = Vec::new();
for (name, state) in children.iter_mut() {
if let Ok(Some(status)) = state.child.try_wait() {
let lived = state.spawned_at.elapsed();
let rapid = lived < RAPID_FAIL_WINDOW;
eprintln!(
"supervisor: child '{name}' exited (status={status:?}, lived={}s, rapid={rapid})",
lived.as_secs()
);
let next_backoff = if rapid {
let prev = session_backoff
.get(name)
.copied()
.unwrap_or(INITIAL_BACKOFF);
(prev * 2).min(MAX_BACKOFF)
} else {
INITIAL_BACKOFF
};
session_backoff.insert(name.clone(), next_backoff);
session_last_exit.insert(name.clone(), Instant::now());
exited.push(name.clone());
}
}
for n in exited {
children.remove(&n);
}
let all_sessions = crate::session::list_sessions().unwrap_or_default();
let total_sessions = all_sessions.len();
let wanted: Vec<crate::session::SessionInfo> =
supervisor_eligible(all_sessions, max_idle, SystemTime::now(), fs_last_active);
if wanted.len() != total_sessions {
eprintln!(
"supervisor: {} of {} sessions eligible (skipped {} registry-unbound + idle > cutoff)",
wanted.len(),
total_sessions,
total_sessions - wanted.len()
);
}
if let Some(max_age) = husk_max_age
&& last_husk_reap.is_none_or(|t| t.elapsed() >= HUSK_REAP_INTERVAL)
{
last_husk_reap = Some(Instant::now());
let bound: std::collections::HashSet<String> = crate::session::read_registry()
.unwrap_or_default()
.by_cwd
.values()
.cloned()
.collect();
if let Ok(root) = crate::session::sessions_root() {
let reaped = reap_husks(
&root.join("by-key"),
max_age,
SystemTime::now(),
&bound,
|home| existing_daemon_for_session(home).unwrap_or(true),
);
if !reaped.is_empty() {
eprintln!(
"supervisor: reaped {} husk session home(s): {}",
reaped.len(),
reaped
.iter()
.filter_map(|p| p.file_name().and_then(|s| s.to_str()))
.collect::<Vec<_>>()
.join(", ")
);
}
}
}
let wanted_names: std::collections::HashSet<String> =
wanted.iter().map(|s| s.name.clone()).collect();
let to_kill: Vec<String> = children
.keys()
.filter(|n| !wanted_names.contains(n.as_str()))
.cloned()
.collect();
for name in to_kill {
if let Some(mut state) = children.remove(&name) {
eprintln!("supervisor: session '{name}' gone from registry; terminating its child");
let _ = state.child.kill();
let _ = state.child.wait();
}
}
for info in wanted {
if info.did.is_none() {
continue;
}
if children.contains_key(&info.name) {
continue;
}
if let Some(last_exit) = session_last_exit.get(&info.name) {
let wait = session_backoff
.get(&info.name)
.copied()
.unwrap_or(INITIAL_BACKOFF);
if last_exit.elapsed() < wait {
continue;
}
}
if existing_daemon_for_session(&info.home_dir)? {
continue;
}
match spawn_child_for_session(&info.name, &info.home_dir, interval_secs) {
Ok(child) => {
eprintln!(
"supervisor: spawned child for session '{}' (pid {})",
info.name,
child.id()
);
children.insert(
info.name.clone(),
ChildState {
child,
spawned_at: Instant::now(),
},
);
}
Err(e) => {
eprintln!(
"supervisor: spawn failed for session '{}': {e:#}",
info.name
);
let prev = session_backoff
.get(&info.name)
.copied()
.unwrap_or(INITIAL_BACKOFF);
session_backoff.insert(info.name.clone(), (prev * 2).min(MAX_BACKOFF));
session_last_exit.insert(info.name.clone(), Instant::now());
}
}
}
std::thread::sleep(Duration::from_secs(REGISTRY_POLL_SECS));
}
}
fn spawn_child_for_session(
name: &str,
home_dir: &std::path::Path,
interval_secs: u64,
) -> Result<Child> {
let exe = std::env::current_exe().context("resolving current exe for child fork")?;
let mut cmd = Command::new(&exe);
cmd.args(["daemon", "--interval", &interval_secs.to_string()]);
let leaks: Vec<String> = std::env::vars()
.filter(|(k, _)| k.starts_with("WIRE_"))
.map(|(k, _)| k)
.collect();
for k in leaks {
cmd.env_remove(&k);
}
cmd.env("WIRE_HOME", home_dir);
cmd.spawn().with_context(|| {
format!(
"fork-exec `wire daemon` for session '{name}' (binary {} WIRE_HOME={})",
exe.display(),
home_dir.display()
)
})
}
fn existing_daemon_for_session(home_dir: &std::path::Path) -> Result<bool> {
let pid_path = home_dir.join("state").join("wire").join("daemon.pid");
if !pid_path.exists() {
return Ok(false);
}
let body = match std::fs::read_to_string(&pid_path) {
Ok(b) => b,
Err(_) => return Ok(false),
};
let pid = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("pid").and_then(serde_json::Value::as_u64))
.or_else(|| body.trim().parse::<u64>().ok());
Ok(pid
.map(|p| crate::ensure_up::pid_is_alive(p as u32))
.unwrap_or(false))
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SupervisorState {
pub supervisor_pid: Option<u32>,
pub supervisor_alive: bool,
pub sessions: Vec<SupervisedSession>,
pub unmanaged_pids: Vec<u32>,
pub stale_binary_sessions: Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct SupervisedSession {
pub name: String,
pub home_dir: String,
pub daemon_pid: Option<u32>,
pub daemon_alive: bool,
pub last_sync_age_seconds: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub daemon_version: Option<String>,
}
pub fn read_supervisor_state() -> Result<SupervisorState> {
let pid_path = supervisor_pid_path()?;
let supervisor_pid = read_supervisor_pid(&pid_path);
let supervisor_alive = supervisor_pid
.map(crate::ensure_up::pid_is_alive)
.unwrap_or(false);
let sessions: Vec<SupervisedSession> = crate::session::list_sessions()
.unwrap_or_default()
.into_iter()
.map(|info| {
let daemon_pid = crate::session::session_daemon_pid(&info.home_dir);
let daemon_alive = daemon_pid
.map(crate::ensure_up::pid_is_alive)
.unwrap_or(false);
let last_sync_age_seconds = read_session_last_sync_age(&info.home_dir);
let daemon_version = read_session_pidfile_version(&info.home_dir);
SupervisedSession {
name: info.name,
home_dir: info.home_dir.to_string_lossy().into_owned(),
daemon_pid,
daemon_alive,
last_sync_age_seconds,
daemon_version,
}
})
.collect();
let all_daemon_pids: std::collections::HashSet<u32> =
crate::platform::find_processes_by_cmdline("wire daemon")
.into_iter()
.collect();
let known_session_pids: std::collections::HashSet<u32> = sessions
.iter()
.filter_map(|s| if s.daemon_alive { s.daemon_pid } else { None })
.collect();
let mut unmanaged_pids: Vec<u32> = all_daemon_pids
.into_iter()
.filter(|p| Some(*p) != supervisor_pid && !known_session_pids.contains(p))
.collect();
unmanaged_pids.sort_unstable();
let our_version = env!("CARGO_PKG_VERSION");
let stale_binary_sessions: Vec<String> = sessions
.iter()
.filter(|s| {
s.daemon_alive
&& s.daemon_version
.as_deref()
.map(|v| version_lt(v, our_version))
.unwrap_or(false)
})
.map(|s| s.name.clone())
.collect();
Ok(SupervisorState {
supervisor_pid,
supervisor_alive,
sessions,
unmanaged_pids,
stale_binary_sessions,
})
}
fn version_lt(a: &str, b: &str) -> bool {
let parse = |s: &str| -> Option<Vec<u32>> { s.split('.').map(|p| p.parse().ok()).collect() };
let (Some(av), Some(bv)) = (parse(a), parse(b)) else {
return false;
};
let n = av.len().max(bv.len());
for i in 0..n {
let ai = av.get(i).copied().unwrap_or(0);
let bi = bv.get(i).copied().unwrap_or(0);
if ai != bi {
return ai < bi;
}
}
false
}
fn read_session_pidfile_version(home_dir: &std::path::Path) -> Option<String> {
let pidfile = home_dir.join("state").join("wire").join("daemon.pid");
let body = std::fs::read_to_string(&pidfile).ok()?;
let v: serde_json::Value = serde_json::from_str(&body).ok()?;
v.get("version")
.and_then(serde_json::Value::as_str)
.map(str::to_string)
}
fn read_supervisor_pid(path: &std::path::Path) -> Option<u32> {
if !path.exists() {
return None;
}
let body = std::fs::read_to_string(path).ok()?;
body.trim().parse::<u32>().ok()
}
fn read_session_last_sync_age(home_dir: &std::path::Path) -> Option<u64> {
let path = home_dir.join("state").join("wire").join("last_sync.json");
let body = std::fs::read_to_string(&path).ok()?;
let v: serde_json::Value = serde_json::from_str(&body).ok()?;
let ts = v.get("ts").and_then(serde_json::Value::as_str)?;
let parsed =
time::OffsetDateTime::parse(ts, &time::format_description::well_known::Rfc3339).ok()?;
let age = (time::OffsetDateTime::now_utc() - parsed).whole_seconds();
if age < 0 {
Some(0)
} else {
Some(age as u64)
}
}
fn supervisor_pid_path() -> Result<PathBuf> {
let root = crate::session::sessions_root()
.context("resolving sessions_root for supervisor pidfile")?;
std::fs::create_dir_all(&root).with_context(|| format!("creating {root:?}"))?;
Ok(root.join("supervisor.pid"))
}
fn read_alive_supervisor_pid(path: &std::path::Path) -> Result<Option<u32>> {
if !path.exists() {
return Ok(None);
}
let body = std::fs::read_to_string(path).ok();
let pid = body.as_deref().and_then(|s| s.trim().parse::<u32>().ok());
match pid {
Some(p) if crate::ensure_up::pid_is_alive(p) => Ok(Some(p)),
_ => Ok(None),
}
}
fn write_supervisor_pid(path: &std::path::Path) -> Result<()> {
let pid = std::process::id();
std::fs::write(path, pid.to_string())
.with_context(|| format!("writing supervisor pidfile {path:?}"))?;
Ok(())
}
struct SupervisorPidGuard {
path: PathBuf,
}
impl Drop for SupervisorPidGuard {
fn drop(&mut self) {
if let Ok(body) = std::fs::read_to_string(&self.path)
&& let Ok(pid) = body.trim().parse::<u32>()
&& pid == std::process::id()
{
let _ = std::fs::remove_file(&self.path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn version_lt_dotted_integer_compare() {
assert!(version_lt("0.9.0", "0.10.0"));
assert!(version_lt("0.13.5", "0.14.1"));
assert!(version_lt("0.14.0", "0.14.1"));
assert!(!version_lt("0.14.1", "0.14.1"));
assert!(!version_lt("0.14.2", "0.14.1"));
assert!(version_lt("0.14", "0.14.1"));
assert!(!version_lt("0.14.1", "0.14"));
assert!(!version_lt("0.14.2-rc.1", "0.14.2"));
assert!(!version_lt("garbage", "0.14.1"));
assert!(!version_lt("0.14.1", "garbage"));
}
#[test]
fn read_alive_supervisor_pid_returns_none_when_missing() {
let tmp = tempdir().unwrap();
let p = tmp.path().join("supervisor.pid");
assert_eq!(read_alive_supervisor_pid(&p).unwrap(), None);
}
#[test]
fn read_alive_supervisor_pid_returns_none_for_dead_pid() {
let tmp = tempdir().unwrap();
let p = tmp.path().join("supervisor.pid");
std::fs::write(&p, "999999").unwrap();
assert_eq!(read_alive_supervisor_pid(&p).unwrap(), None);
}
#[test]
fn read_alive_supervisor_pid_returns_pid_for_self() {
let tmp = tempdir().unwrap();
let p = tmp.path().join("supervisor.pid");
let our_pid = std::process::id();
std::fs::write(&p, our_pid.to_string()).unwrap();
assert_eq!(read_alive_supervisor_pid(&p).unwrap(), Some(our_pid));
}
#[test]
fn pid_guard_only_removes_when_pid_still_matches() {
let tmp = tempdir().unwrap();
let p = tmp.path().join("supervisor.pid");
std::fs::write(&p, "12345").unwrap();
{
let _g = SupervisorPidGuard { path: p.clone() };
}
assert!(p.exists(), "guard removed a pidfile that didn't name us");
}
#[test]
fn pid_guard_removes_when_pid_matches() {
let tmp = tempdir().unwrap();
let p = tmp.path().join("supervisor.pid");
let our_pid = std::process::id();
std::fs::write(&p, our_pid.to_string()).unwrap();
{
let _g = SupervisorPidGuard { path: p.clone() };
}
assert!(!p.exists(), "guard left our own pidfile behind");
}
#[test]
fn existing_daemon_for_session_returns_false_when_pidfile_missing() {
let tmp = tempdir().unwrap();
assert!(!existing_daemon_for_session(tmp.path()).unwrap());
}
#[test]
fn existing_daemon_for_session_returns_false_for_dead_pid() {
let tmp = tempdir().unwrap();
let state = tmp.path().join("state").join("wire");
std::fs::create_dir_all(&state).unwrap();
std::fs::write(state.join("daemon.pid"), "999999").unwrap();
assert!(!existing_daemon_for_session(tmp.path()).unwrap());
}
#[test]
fn existing_daemon_for_session_returns_true_for_self_pid() {
let tmp = tempdir().unwrap();
let state = tmp.path().join("state").join("wire");
std::fs::create_dir_all(&state).unwrap();
std::fs::write(state.join("daemon.pid"), std::process::id().to_string()).unwrap();
assert!(existing_daemon_for_session(tmp.path()).unwrap());
}
fn mk_session(name: &str, cwd: Option<&str>) -> crate::session::SessionInfo {
crate::session::SessionInfo {
name: name.to_string(),
cwd: cwd.map(String::from),
home_dir: PathBuf::from(format!("/sessions/{name}")),
did: None,
handle: None,
daemon_running: false,
character: None,
}
}
#[test]
fn parse_max_idle_default_when_unset() {
assert_eq!(
parse_max_idle(None),
Some(Duration::from_secs(DEFAULT_MAX_IDLE_DAYS * 86_400))
);
}
#[test]
fn parse_max_idle_zero_disables_filter() {
assert_eq!(parse_max_idle(Some("0")), None);
}
#[test]
fn parse_max_idle_explicit_days() {
assert_eq!(
parse_max_idle(Some("3")),
Some(Duration::from_secs(3 * 86_400))
);
assert_eq!(
parse_max_idle(Some(" 14 ")),
Some(Duration::from_secs(14 * 86_400))
);
}
#[test]
fn parse_max_idle_garbage_falls_back_to_default() {
assert_eq!(
parse_max_idle(Some("not-a-number")),
Some(Duration::from_secs(DEFAULT_MAX_IDLE_DAYS * 86_400))
);
}
#[test]
fn eligible_keeps_cwd_bound_even_when_ancient() {
let now = SystemTime::now();
let ancient = now - Duration::from_secs(365 * 86_400);
let sessions = vec![mk_session("wire", Some("/Users/p/Source/wire"))];
let out = supervisor_eligible(sessions, Some(Duration::from_secs(7 * 86_400)), now, |_| {
Some(ancient)
});
assert_eq!(out.len(), 1);
assert_eq!(out[0].name, "wire");
}
#[test]
fn eligible_keeps_unbound_recent_drops_unbound_idle() {
let now = SystemTime::now();
let recent = now - Duration::from_secs(2 * 86_400);
let stale = now - Duration::from_secs(30 * 86_400);
let sessions = vec![
mk_session("rosy-rook", None), mk_session("agate-nimbus", None), ];
let out = supervisor_eligible(
sessions,
Some(Duration::from_secs(7 * 86_400)),
now,
|home| {
if home.ends_with("rosy-rook") {
Some(recent)
} else {
Some(stale)
}
},
);
let names: Vec<_> = out.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, vec!["rosy-rook"]);
}
#[test]
fn eligible_drops_unbound_with_no_activity_signal() {
let now = SystemTime::now();
let sessions = vec![mk_session("husk", None)];
let out = supervisor_eligible(sessions, Some(Duration::from_secs(7 * 86_400)), now, |_| {
None
});
assert!(out.is_empty());
}
#[test]
fn eligible_none_cutoff_keeps_everything() {
let now = SystemTime::now();
let ancient = now - Duration::from_secs(999 * 86_400);
let sessions = vec![mk_session("husk", None), mk_session("agate-nimbus", None)];
let out = supervisor_eligible(sessions, None, now, |_| Some(ancient));
assert_eq!(out.len(), 2);
}
use std::collections::HashSet;
fn mk_husk(root: &Path, name: &str) -> PathBuf {
let home = root.join(name);
std::fs::create_dir_all(home.join("state").join("wire")).unwrap();
home
}
fn far_future() -> SystemTime {
SystemTime::now() + Duration::from_secs(100 * 3600)
}
const CUTOFF_48H: Duration = Duration::from_secs(48 * 3600);
#[test]
fn reap_removes_old_identityless_unsynced_husk() {
let tmp = tempdir().unwrap();
let home = mk_husk(tmp.path(), "abcdef0123456789");
let reaped = reap_husks(
tmp.path(),
CUTOFF_48H,
far_future(),
&HashSet::new(),
|_| false,
);
assert_eq!(reaped, vec![home.clone()]);
assert!(!home.exists(), "husk dir should be gone");
}
#[test]
fn reap_keeps_identity_homes_regardless_of_age() {
let tmp = tempdir().unwrap();
let home = mk_husk(tmp.path(), "abcdef0123456789");
let cfg = home.join("config").join("wire");
std::fs::create_dir_all(&cfg).unwrap();
std::fs::write(cfg.join("private.key"), "k").unwrap();
let reaped = reap_husks(
tmp.path(),
CUTOFF_48H,
far_future(),
&HashSet::new(),
|_| false,
);
assert!(reaped.is_empty());
assert!(home.exists(), "identity-bearing home must never be reaped");
}
#[test]
fn reap_keeps_homes_that_ever_synced() {
let tmp = tempdir().unwrap();
let home = mk_husk(tmp.path(), "abcdef0123456789");
std::fs::write(home.join("state").join("wire").join("last_sync.json"), "{}").unwrap();
let reaped = reap_husks(
tmp.path(),
CUTOFF_48H,
far_future(),
&HashSet::new(),
|_| false,
);
assert!(reaped.is_empty());
assert!(home.exists(), "synced home must never be reaped");
}
#[test]
fn reap_keeps_young_husks() {
let tmp = tempdir().unwrap();
let home = mk_husk(tmp.path(), "abcdef0123456789");
let reaped = reap_husks(
tmp.path(),
CUTOFF_48H,
SystemTime::now(),
&HashSet::new(),
|_| false,
);
assert!(reaped.is_empty());
assert!(home.exists(), "young husk must get its grace window");
}
#[test]
fn reap_keeps_registry_bound_names() {
let tmp = tempdir().unwrap();
let home = mk_husk(tmp.path(), "abcdef0123456789");
let bound: HashSet<String> = ["abcdef0123456789".to_string()].into();
let reaped = reap_husks(tmp.path(), CUTOFF_48H, far_future(), &bound, |_| false);
assert!(reaped.is_empty());
assert!(home.exists(), "operator-bound home must never be reaped");
}
#[test]
fn reap_keeps_homes_with_live_daemon() {
let tmp = tempdir().unwrap();
let home = mk_husk(tmp.path(), "abcdef0123456789");
let reaped = reap_husks(
tmp.path(),
CUTOFF_48H,
far_future(),
&HashSet::new(),
|_| true,
);
assert!(reaped.is_empty());
assert!(home.exists(), "daemon-owned home must never be reaped");
}
#[test]
fn reap_ignores_non_by_key_shaped_names() {
let tmp = tempdir().unwrap();
let named = mk_husk(tmp.path(), "my-session");
let upper = mk_husk(tmp.path(), "ABCDEF0123456789");
let short = mk_husk(tmp.path(), "abcdef012345678");
let reaped = reap_husks(
tmp.path(),
CUTOFF_48H,
far_future(),
&HashSet::new(),
|_| false,
);
assert!(reaped.is_empty());
assert!(named.exists() && upper.exists() && short.exists());
}
#[test]
fn reap_missing_root_is_a_noop() {
let tmp = tempdir().unwrap();
let reaped = reap_husks(
&tmp.path().join("no-such-by-key"),
CUTOFF_48H,
far_future(),
&HashSet::new(),
|_| false,
);
assert!(reaped.is_empty());
}
#[test]
fn husk_reap_max_age_parsing() {
assert_eq!(
parse_husk_reap_max_age(None),
Some(Duration::from_secs(48 * 3600))
);
assert_eq!(parse_husk_reap_max_age(Some("0")), None);
assert_eq!(
parse_husk_reap_max_age(Some("12")),
Some(Duration::from_secs(12 * 3600))
);
assert_eq!(
parse_husk_reap_max_age(Some("soon")),
Some(Duration::from_secs(48 * 3600))
);
}
}