use std::collections::{HashMap, HashSet};
use std::process::Command;
use tracing::{debug, info, warn};
use crate::core::names::is_managed_session_name;
pub const DEFAULT_SKIP_LOG_EVERY_SWEEPS: u32 = 60;
pub const ENV_SKIP_LOG_EVERY_SWEEPS: &str = "TRUSTY_MPM_ORPHAN_GC_SKIP_LOG_EVERY";
const IDLE_SHELL_COMMANDS: &[&str] = &[
"zsh", "bash", "sh", "fish", "dash", "tcsh", "csh", "ksh", "-zsh", "-bash", "-sh", "-fish", "-dash", "-tcsh", "-csh", "-ksh", "login",
];
pub fn is_idle_shell(pane_command: &str) -> bool {
let cmd = pane_command.trim();
IDLE_SHELL_COMMANDS.contains(&cmd)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PaneInfo {
pub session_name: String,
pub pane_current_command: String,
pub pane_pid: Option<u32>,
pub pane_id: Option<String>,
pub pane_current_path: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CwdEvidence {
Exists,
Gone,
Undeterminable,
}
pub trait CwdProbe {
fn evidence(&self, path: &str) -> CwdEvidence;
}
pub struct FsCwdProbe;
impl CwdProbe for FsCwdProbe {
fn evidence(&self, path: &str) -> CwdEvidence {
match std::fs::symlink_metadata(path) {
Ok(_) => CwdEvidence::Exists,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let Some(parent) = std::path::Path::new(path).parent() else {
return CwdEvidence::Undeterminable;
};
if std::fs::symlink_metadata(parent).is_ok() {
CwdEvidence::Gone
} else {
debug!(
path = %path,
"orphan-GC: a pane's cwd is absent and so is its parent — an unmounted \
volume answers exactly like a deleted directory (ADR-0045), so this is \
not evidence; keeping the pane (#6118)"
);
CwdEvidence::Undeterminable
}
}
Err(e) => {
warn!(
path = %path,
error = %e,
"orphan-GC: cannot determine whether a pane's cwd still exists; keeping the pane (#6118)"
);
CwdEvidence::Undeterminable
}
}
}
}
pub fn pane_cwd_evidence(pane: &PaneInfo, probe: &dyn CwdProbe) -> CwdEvidence {
match pane.pane_current_path.as_deref().map(str::trim) {
Some(p) if !p.is_empty() => probe.evidence(p),
_ => CwdEvidence::Undeterminable,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GcDecision {
Keep(KeepReason),
WarnUntrackedActive,
ReapCandidate,
ReapCandidateCwdGone,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeepReason {
NotManaged,
TrackedLegacy,
TrackedManaged,
LiveChildProcess,
}
#[derive(Debug, Default, Clone)]
pub struct TrackedNames {
pub legacy: HashSet<String>,
pub managed: HashSet<String>,
pub degraded: bool,
}
impl TrackedNames {
fn contains(&self, name: &str) -> bool {
self.legacy.contains(name) || self.managed.contains(name)
}
}
pub trait ChildLivenessProbe {
fn has_live_child(&self, pane_pid: Option<u32>) -> bool;
}
pub struct AlwaysIdleProbe;
impl ChildLivenessProbe for AlwaysIdleProbe {
fn has_live_child(&self, _pane_pid: Option<u32>) -> bool {
false
}
}
pub fn classify_session(
pane: &PaneInfo,
tracked: &TrackedNames,
probe: &dyn ChildLivenessProbe,
cwd_probe: &dyn CwdProbe,
) -> GcDecision {
if !is_managed_session_name(&pane.session_name) {
return GcDecision::Keep(KeepReason::NotManaged);
}
if tracked.legacy.contains(&pane.session_name) {
return GcDecision::Keep(KeepReason::TrackedLegacy);
}
if tracked.managed.contains(&pane.session_name) {
return GcDecision::Keep(KeepReason::TrackedManaged);
}
debug_assert!(!tracked.contains(&pane.session_name));
if !is_idle_shell(&pane.pane_current_command) {
return match pane_cwd_evidence(pane, cwd_probe) {
CwdEvidence::Gone => GcDecision::ReapCandidateCwdGone,
CwdEvidence::Exists | CwdEvidence::Undeterminable => GcDecision::WarnUntrackedActive,
};
}
if probe.has_live_child(pane.pane_pid) {
return GcDecision::Keep(KeepReason::LiveChildProcess);
}
GcDecision::ReapCandidate
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SweepPlan {
pub to_reap: Vec<String>,
pub scanned: usize,
pub kept: usize,
pub warned: usize,
pub warn_logged: usize,
pub cwd_gone: HashSet<String>,
}
#[derive(Debug, Default)]
pub struct OrphanGc {
prev_candidates: HashSet<String>,
warn_sweeps: HashMap<String, u32>,
skip_log_every: u32,
}
impl OrphanGc {
pub fn new() -> Self {
Self::with_skip_log_every(DEFAULT_SKIP_LOG_EVERY_SWEEPS)
}
pub fn from_env() -> Self {
Self::with_skip_log_every(Self::skip_log_every_from_env(|k| std::env::var(k).ok()))
}
pub fn with_skip_log_every(sweeps: u32) -> Self {
Self {
prev_candidates: HashSet::new(),
warn_sweeps: HashMap::new(),
skip_log_every: sweeps.max(1),
}
}
pub fn skip_log_every_from_env(get: impl Fn(&str) -> Option<String>) -> u32 {
get(ENV_SKIP_LOG_EVERY_SWEEPS)
.and_then(|v| v.trim().parse::<u32>().ok())
.filter(|n| *n > 0)
.unwrap_or(DEFAULT_SKIP_LOG_EVERY_SWEEPS)
}
pub fn reset_debounce(&mut self) {
self.prev_candidates.clear();
}
pub fn plan_sweep(
&mut self,
panes: &[PaneInfo],
tracked: &TrackedNames,
probe: &dyn ChildLivenessProbe,
cwd_probe: &dyn CwdProbe,
) -> SweepPlan {
let mut plan = SweepPlan {
scanned: panes.len(),
..SweepPlan::default()
};
let mut this_pass: HashSet<String> = HashSet::new();
let mut warned_this_pass: HashMap<String, u32> = HashMap::new();
for pane in panes {
match classify_session(pane, tracked, probe, cwd_probe) {
GcDecision::ReapCandidate => {
this_pass.insert(pane.session_name.clone());
}
GcDecision::ReapCandidateCwdGone => {
this_pass.insert(pane.session_name.clone());
plan.cwd_gone.insert(pane.session_name.clone());
}
GcDecision::WarnUntrackedActive => {
plan.warned += 1;
let seen = self
.warn_sweeps
.get(pane.session_name.as_str())
.copied()
.unwrap_or(0);
warned_this_pass.insert(pane.session_name.clone(), seen + 1);
if seen % self.skip_log_every == 0 {
plan.warn_logged += 1;
info!(
session = %pane.session_name,
command = %pane.pane_current_command,
sweeps_skipped = seen,
repeats_every = self.skip_log_every,
"orphan-GC: untracked active managed session — skipping"
);
}
}
GcDecision::Keep(reason) => {
plan.kept += 1;
debug!(
session = %pane.session_name,
?reason,
"orphan-GC: keeping tracked session"
);
}
}
}
for name in &this_pass {
if self.prev_candidates.contains(name) {
plan.to_reap.push(name.clone());
}
}
plan.to_reap.sort();
plan.cwd_gone.retain(|n| plan.to_reap.contains(n));
self.prev_candidates = this_pass;
self.warn_sweeps = warned_this_pass;
plan
}
}
pub struct ProcessTreeProbe;
impl ChildLivenessProbe for ProcessTreeProbe {
fn has_live_child(&self, pane_pid: Option<u32>) -> bool {
let Some(pid) = pane_pid else {
return false;
};
pgrep_has_child(pid)
}
}
fn pgrep_has_child(pid: u32) -> bool {
let result = Command::new("pgrep")
.args(["-P", &pid.to_string()])
.output();
interpret_pgrep(pid, result)
}
fn interpret_pgrep(pid: u32, result: std::io::Result<std::process::Output>) -> bool {
match result {
Ok(output) if output.status.success() => String::from_utf8_lossy(&output.stdout)
.lines()
.any(|line| line.trim().parse::<u32>().is_ok()),
Ok(_) => false,
Err(e) => {
warn!(pid, error = %e, "orphan-GC: pgrep child probe failed; keeping session");
true
}
}
}
pub fn run_sweep(
gc: &mut OrphanGc,
panes: &[PaneInfo],
tracked: &TrackedNames,
probe: &dyn ChildLivenessProbe,
cwd_probe: &dyn CwdProbe,
driver: &dyn crate::session_manager::ManagedTmuxDriver,
) -> usize {
if tracked.degraded {
gc.reset_debounce();
warn!(
"orphan-GC: tracked-names snapshot incomplete (registry read failed); skipping reap this sweep"
);
return 0;
}
let plan = gc.plan_sweep(panes, tracked, probe, cwd_probe);
let mut reaped = 0usize;
for name in &plan.to_reap {
let reason = if plan.cwd_gone.contains(name) {
"untracked managed session whose working directory is gone (orphan-GC, debounced, #6118)"
} else {
"untracked idle managed session (orphan-GC, debounced)"
};
match driver.kill_session(name) {
Ok(()) => {
reaped += 1;
info!(session = %name, reason, "reaped orphaned tmux session");
}
Err(e) => {
warn!(session = %name, error = %e, "orphan-GC kill failed");
}
}
}
debug!(
scanned = plan.scanned,
kept = plan.kept,
warned = plan.warned,
warn_logged = plan.warn_logged,
candidates = plan.to_reap.len(),
cwd_gone = plan.cwd_gone.len(),
reaped,
"orphan-GC sweep complete"
);
reaped
}
pub fn run_sweep_fs(
gc: &mut OrphanGc,
panes: &[PaneInfo],
tracked: &TrackedNames,
probe: &dyn ChildLivenessProbe,
driver: &dyn crate::session_manager::ManagedTmuxDriver,
) -> usize {
run_sweep(gc, panes, tracked, probe, &FsCwdProbe, driver)
}
#[cfg(test)]
mod tests {
use super::*;
struct AlwaysLiveProbe;
impl ChildLivenessProbe for AlwaysLiveProbe {
fn has_live_child(&self, _pane_pid: Option<u32>) -> bool {
true
}
}
struct NoCwdEvidence;
impl CwdProbe for NoCwdEvidence {
fn evidence(&self, _path: &str) -> CwdEvidence {
CwdEvidence::Undeterminable
}
}
struct AllCwdGone;
impl CwdProbe for AllCwdGone {
fn evidence(&self, _path: &str) -> CwdEvidence {
CwdEvidence::Gone
}
}
struct AllCwdExists;
impl CwdProbe for AllCwdExists {
fn evidence(&self, _path: &str) -> CwdEvidence {
CwdEvidence::Exists
}
}
fn pane(name: &str, cmd: &str) -> PaneInfo {
PaneInfo {
session_name: name.to_string(),
pane_current_command: cmd.to_string(),
pane_pid: Some(4242),
pane_id: None,
pane_current_path: None,
}
}
fn pane_at(name: &str, cmd: &str, path: &str) -> PaneInfo {
PaneInfo {
pane_current_path: Some(path.to_string()),
..pane(name, cmd)
}
}
fn tracked_with(legacy: &[&str], managed: &[&str]) -> TrackedNames {
TrackedNames {
legacy: legacy.iter().map(|s| s.to_string()).collect(),
managed: managed.iter().map(|s| s.to_string()).collect(),
degraded: false,
}
}
#[test]
fn idle_shell_commands_recognised() {
for c in [
"zsh", "bash", "sh", "-zsh", "-bash", "-sh", "fish", "dash", "tcsh", "csh", "ksh", "-fish", "-dash", "-tcsh", "-csh", "-ksh", "login", " zsh ",
] {
assert!(is_idle_shell(c), "{c:?} should be idle");
}
}
#[test]
fn process_tree_probe_none_pid_is_idle() {
assert!(!ProcessTreeProbe.has_live_child(None));
}
#[test]
fn pgrep_has_child_for_live_parent() {
use std::process::{Child, Command};
struct ChildGuard(Child);
impl Drop for ChildGuard {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
let child = Command::new("sleep")
.arg("3")
.spawn()
.expect("spawn sleep child");
let _guard = ChildGuard(child);
let me = std::process::id();
let saw_child = ProcessTreeProbe.has_live_child(Some(me));
assert!(saw_child, "probe must see a live child of the test process");
}
#[cfg(unix)]
fn fake_output(code: i32, stdout: &str) -> std::process::Output {
use std::os::unix::process::ExitStatusExt;
std::process::Output {
status: std::process::ExitStatus::from_raw(code << 8),
stdout: stdout.as_bytes().to_vec(),
stderr: Vec::new(),
}
}
#[test]
#[cfg(unix)]
fn interpret_pgrep_match() {
assert!(interpret_pgrep(123, Ok(fake_output(0, "456\n"))));
}
#[test]
#[cfg(unix)]
fn interpret_pgrep_no_match() {
assert!(!interpret_pgrep(123, Ok(fake_output(1, ""))));
assert!(!interpret_pgrep(123, Ok(fake_output(0, " \n"))));
}
#[test]
fn interpret_pgrep_fails_closed_on_error() {
let err = std::io::Error::new(std::io::ErrorKind::NotFound, "pgrep missing");
assert!(
interpret_pgrep(123, Err(err)),
"probe error must keep the session, never reap on uncertainty"
);
}
#[test]
fn active_command_not_idle() {
for c in [
"claude",
"node",
"uv",
"python",
"vim",
"claude-code",
"ssh",
] {
assert!(!is_idle_shell(c), "{c:?} should be active");
}
}
#[test]
fn classify_keeps_non_managed() {
let d = classify_session(
&pane("work", "zsh"),
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(d, GcDecision::Keep(KeepReason::NotManaged));
}
#[test]
fn classify_keeps_tracked_legacy() {
let tracked = tracked_with(&["tmpm-tracked"], &[]);
let d = classify_session(
&pane("tmpm-tracked", "zsh"),
&tracked,
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(d, GcDecision::Keep(KeepReason::TrackedLegacy));
}
#[test]
fn classify_keeps_tracked_managed() {
let tracked = tracked_with(&[], &["tmpm-managed"]);
let d = classify_session(
&pane("tmpm-managed", "zsh"),
&tracked,
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(d, GcDecision::Keep(KeepReason::TrackedManaged));
}
#[test]
fn classify_keeps_tracked_active() {
let tracked = tracked_with(&[], &["tmpm-busy"]);
let d = classify_session(
&pane("tmpm-busy", "claude"),
&tracked,
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(d, GcDecision::Keep(KeepReason::TrackedManaged));
}
#[test]
fn classify_warns_untracked_active() {
let d = classify_session(
&pane("tmpm-rogue", "claude"),
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(d, GcDecision::WarnUntrackedActive);
}
#[test]
fn classify_reaps_untracked_idle() {
let d = classify_session(
&pane("tmpm-orphan", "zsh"),
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(d, GcDecision::ReapCandidate);
}
#[test]
fn classify_keeps_live_child() {
let d = classify_session(
&pane("tmpm-spawning", "zsh"),
&TrackedNames::default(),
&AlwaysLiveProbe,
&NoCwdEvidence,
);
assert_eq!(d, GcDecision::Keep(KeepReason::LiveChildProcess));
}
#[test]
fn classify_legacy_prefix_is_managed() {
let d = classify_session(
&pane("trusty-mpm-deadbeef", "bash"),
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(d, GcDecision::ReapCandidate);
}
#[test]
fn classify_full_mix_only_d_selected() {
let panes = vec![
pane("tmpm-a-tracked-active", "claude"), pane("tmpm-b-tracked-idle", "zsh"), pane("tmpm-c-untracked-active", "node"), pane("tmpm-d-untracked-idle", "zsh"), pane("plain-shell", "zsh"), ];
let tracked = tracked_with(&[], &["tmpm-a-tracked-active", "tmpm-b-tracked-idle"]);
let mut gc = OrphanGc::new();
let p1 = gc.plan_sweep(&panes, &tracked, &AlwaysIdleProbe, &NoCwdEvidence);
assert!(
p1.to_reap.is_empty(),
"debounce must spare a first-seen candidate, got {:?}",
p1.to_reap
);
assert_eq!(p1.warned, 1, "exactly the (c) session is warned-and-kept");
assert_eq!(p1.scanned, 5);
let p2 = gc.plan_sweep(&panes, &tracked, &AlwaysIdleProbe, &NoCwdEvidence);
assert_eq!(p2.to_reap, vec!["tmpm-d-untracked-idle".to_string()]);
assert_eq!(p2.warned, 1);
}
#[test]
fn classify_reaps_active_pane_whose_cwd_is_gone() {
let d = classify_session(
&pane_at("tm-zombie", "claude", "/gone/worktree"),
&TrackedNames::default(),
&AlwaysIdleProbe,
&AllCwdGone,
);
assert_eq!(
d,
GcDecision::ReapCandidateCwdGone,
"a confirmed-gone cwd is positive evidence an untracked pane has \
nothing left to work in (#6118)"
);
}
#[test]
fn classify_keeps_active_pane_when_cwd_is_undeterminable() {
let d = classify_session(
&pane_at("tm-unknown", "claude", "/maybe/here"),
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(d, GcDecision::WarnUntrackedActive);
}
#[test]
fn classify_keeps_active_pane_whose_cwd_exists() {
let d = classify_session(
&pane_at("tm-busy", "claude", "/real/dir"),
&TrackedNames::default(),
&AlwaysIdleProbe,
&AllCwdExists,
);
assert_eq!(d, GcDecision::WarnUntrackedActive);
}
#[test]
fn classify_keeps_tracked_pane_whose_cwd_is_gone() {
let tracked = tracked_with(&[], &["tm-tracked-gone"]);
let d = classify_session(
&pane_at("tm-tracked-gone", "claude", "/gone"),
&tracked,
&AlwaysIdleProbe,
&AllCwdGone,
);
assert_eq!(d, GcDecision::Keep(KeepReason::TrackedManaged));
}
#[test]
fn classify_keeps_non_managed_pane_whose_cwd_is_gone() {
let d = classify_session(
&pane_at("my-own-work", "vim", "/gone"),
&TrackedNames::default(),
&AlwaysIdleProbe,
&AllCwdGone,
);
assert_eq!(d, GcDecision::Keep(KeepReason::NotManaged));
}
#[test]
fn pane_cwd_evidence_is_undeterminable_without_a_path() {
assert_eq!(
pane_cwd_evidence(&pane("tm-a", "claude"), &AllCwdGone),
CwdEvidence::Undeterminable
);
assert_eq!(
pane_cwd_evidence(&pane_at("tm-a", "claude", " "), &AllCwdGone),
CwdEvidence::Undeterminable
);
}
#[test]
fn fs_cwd_probe_reports_exists_for_a_real_dir() {
let tmp = tempfile::TempDir::new().expect("tempdir");
assert_eq!(
FsCwdProbe.evidence(&tmp.path().to_string_lossy()),
CwdEvidence::Exists
);
}
#[test]
fn fs_cwd_probe_reports_gone_for_a_deleted_dir() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let path = tmp.path().join("worktree");
std::fs::create_dir(&path).expect("create");
assert_eq!(
FsCwdProbe.evidence(&path.to_string_lossy()),
CwdEvidence::Exists
);
std::fs::remove_dir(&path).expect("remove");
assert_eq!(
FsCwdProbe.evidence(&path.to_string_lossy()),
CwdEvidence::Gone,
"a deleted worktree inside an intact parent is the exact live \
condition #6118 reports"
);
}
#[test]
fn fs_cwd_probe_is_undeterminable_when_the_parent_is_gone_too() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let mount = tmp.path().join("Volumes-Ext");
let workdir = mount.join("work").join("repo");
std::fs::create_dir_all(&workdir).expect("create");
assert_eq!(
FsCwdProbe.evidence(&workdir.to_string_lossy()),
CwdEvidence::Exists
);
std::fs::remove_dir_all(&mount).expect("eject");
assert_eq!(
FsCwdProbe.evidence(&workdir.to_string_lossy()),
CwdEvidence::Undeterminable,
"an absent path whose parent is also absent is not evidence about \
that path — it is an unmounted volume (ADR-0045)"
);
}
#[test]
fn fs_cwd_probe_is_undeterminable_on_a_non_notfound_error() {
let tmp = tempfile::TempDir::new().expect("tempdir");
let file = tmp.path().join("not-a-dir");
std::fs::write(&file, b"x").expect("write");
let under_a_file = file.join("child");
assert_eq!(
FsCwdProbe.evidence(&under_a_file.to_string_lossy()),
CwdEvidence::Undeterminable,
"an unexpected errno must never read as absence"
);
}
#[test]
fn fs_cwd_probe_is_undeterminable_for_a_parentless_path() {
assert_eq!(FsCwdProbe.evidence("/"), CwdEvidence::Exists);
assert_eq!(
FsCwdProbe.evidence("definitely-not-here-6118"),
CwdEvidence::Undeterminable
);
}
#[test]
fn cwd_gone_pane_is_debounced_then_reaped() {
let panes = vec![pane_at("tm-zombie", "claude", "/gone")];
let mut gc = OrphanGc::new();
let p1 = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&AllCwdGone,
);
assert!(
p1.to_reap.is_empty(),
"first sighting must never kill a pane with a live foreground process"
);
let p2 = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&AllCwdGone,
);
assert_eq!(p2.to_reap, vec!["tm-zombie".to_string()]);
assert!(
p2.cwd_gone.contains("tm-zombie"),
"the kill must be attributed to the cwd-gone reason, not to idleness"
);
}
#[test]
fn cwd_gone_debounce_resets_when_the_directory_returns() {
let panes = vec![pane_at("tm-flaky", "claude", "/maybe")];
let mut gc = OrphanGc::new();
let _ = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&AllCwdGone,
);
let p2 = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&AllCwdExists,
);
assert!(p2.to_reap.is_empty(), "got {:?}", p2.to_reap);
let p3 = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&AllCwdGone,
);
assert!(
p3.to_reap.is_empty(),
"a lapsed candidate must restart the debounce, got {:?}",
p3.to_reap
);
}
#[test]
fn cwd_gone_reason_is_dropped_while_the_debounce_holds() {
let panes = vec![pane_at("tm-zombie", "claude", "/gone")];
let mut gc = OrphanGc::new();
let p1 = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&AllCwdGone,
);
assert!(p1.cwd_gone.is_empty(), "got {:?}", p1.cwd_gone);
}
#[test]
fn skip_log_is_throttled_per_session() {
let panes = vec![pane("tm-warned", "claude")];
let mut gc = OrphanGc::with_skip_log_every(3);
let p1 = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(p1.warned, 1);
assert_eq!(p1.warn_logged, 1, "the first sighting is always logged");
for sweep in 2..=3 {
let p = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(p.warned, 1, "sweep {sweep} still counts the session");
assert_eq!(p.warn_logged, 0, "sweep {sweep} must be suppressed");
}
}
#[test]
fn skip_log_repeats_after_n_sweeps() {
let panes = vec![pane("tm-warned", "claude")];
let mut gc = OrphanGc::with_skip_log_every(3);
for _ in 0..3 {
let _ = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
}
let p4 = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(
p4.warn_logged, 1,
"the line must reappear every N sweeps so a zombie is never invisible"
);
}
#[test]
fn skip_log_every_zero_logs_every_sweep() {
let panes = vec![pane("tm-warned", "claude")];
let mut gc = OrphanGc::with_skip_log_every(0);
for _ in 0..3 {
let p = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(p.warn_logged, 1);
}
}
#[test]
fn skip_log_counter_resets_when_a_session_stops_being_warned() {
let warned = vec![pane("tm-warned", "claude")];
let mut gc = OrphanGc::with_skip_log_every(10);
let _ = gc.plan_sweep(
&warned,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
let tracked = tracked_with(&[], &["tm-warned"]);
let _ = gc.plan_sweep(&warned, &tracked, &AlwaysIdleProbe, &NoCwdEvidence);
let p3 = gc.plan_sweep(
&warned,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(p3.warn_logged, 1);
}
#[test]
fn skip_log_every_from_env_parsing() {
assert_eq!(
OrphanGc::skip_log_every_from_env(|_| None),
DEFAULT_SKIP_LOG_EVERY_SWEEPS
);
assert_eq!(
OrphanGc::skip_log_every_from_env(|_| Some(" 7 ".to_string())),
7
);
assert_eq!(
OrphanGc::skip_log_every_from_env(|_| Some("0".to_string())),
DEFAULT_SKIP_LOG_EVERY_SWEEPS
);
assert_eq!(
OrphanGc::skip_log_every_from_env(|_| Some("nope".to_string())),
DEFAULT_SKIP_LOG_EVERY_SWEEPS
);
}
#[test]
fn debounce_skips_first_observation() {
let panes = vec![pane("tmpm-fresh", "zsh")];
let mut gc = OrphanGc::new();
let plan = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert!(plan.to_reap.is_empty());
}
#[test]
fn debounce_reaps_second_observation() {
let panes = vec![pane("tmpm-persist", "zsh")];
let mut gc = OrphanGc::new();
let _ = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
let plan = gc.plan_sweep(
&panes,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(plan.to_reap, vec!["tmpm-persist".to_string()]);
}
#[test]
fn debounce_resets_when_candidate_disappears() {
let orphan = vec![pane("tmpm-flaky", "zsh")];
let mut gc = OrphanGc::new();
let _ = gc.plan_sweep(
&orphan,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
let tracked = tracked_with(&[], &["tmpm-flaky"]);
let p2 = gc.plan_sweep(&orphan, &tracked, &AlwaysIdleProbe, &NoCwdEvidence);
assert!(
p2.to_reap.is_empty(),
"a now-tracked pane must not be reaped, got {:?}",
p2.to_reap
);
let p3 = gc.plan_sweep(
&orphan,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert!(
p3.to_reap.is_empty(),
"a candidate that lapsed must restart the debounce, got {:?}",
p3.to_reap
);
}
#[test]
fn reset_debounce_clears_prev_candidates() {
let orphan = vec![pane("tmpm-flaky", "zsh")];
let mut gc = OrphanGc::new();
let _ = gc.plan_sweep(
&orphan,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
gc.reset_debounce();
let after = gc.plan_sweep(
&orphan,
&TrackedNames::default(),
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert!(
after.to_reap.is_empty(),
"reset must restart the debounce, got {:?}",
after.to_reap
);
}
#[test]
fn gc_key_is_session_name_only_not_composite() {
let tracked = tracked_with(&[], &["tmpm-my-session"]);
let decision = classify_session(
&PaneInfo {
session_name: "tmpm-my-session".to_string(),
pane_current_command: "claude".to_string(),
pane_pid: Some(45162),
pane_id: None,
pane_current_path: None,
},
&tracked,
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(
decision,
GcDecision::Keep(KeepReason::TrackedManaged),
"(a) tracked active session must be Keep, not WarnUntrackedActive \
(key mismatch regression: composite key would never match)"
);
let decision = classify_session(
&PaneInfo {
session_name: "tmpm-orphan".to_string(),
pane_current_command: "zsh".to_string(),
pane_pid: Some(99999),
pane_id: None,
pane_current_path: None,
},
&tracked,
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(
decision,
GcDecision::ReapCandidate,
"(b) untracked idle managed session must be a ReapCandidate"
);
let decision = classify_session(
&PaneInfo {
session_name: "tmpm-my-session".to_string(),
pane_current_command: "zsh".to_string(),
pane_pid: Some(55555),
pane_id: None,
pane_current_path: None,
},
&tracked,
&AlwaysIdleProbe,
&NoCwdEvidence,
);
assert_eq!(
decision,
GcDecision::Keep(KeepReason::TrackedManaged),
"(c) tracked idle session must be Keep, not ReapCandidate"
);
}
#[test]
fn gc_classifies_rows_that_came_through_the_real_parse() {
use crate::daemon::tmux::TmuxDriver;
let tracked = tracked_with(&[], &["tm-trusty-tools-01"]);
let listing = "tm-trusty-tools-01\tclaude\t10302\t%2306\t/Users/masa/trusty-tools\n\
tm-00b14f3b-dd31-4474-9\tzsh\t74149\t%1860\t/tmp/gone\n\
tm-declined-adopt-01\tclaude\t81234\t%1900\t/tmp/gone\n";
let panes = TmuxDriver::parse_managed_pane_rows(listing);
assert_eq!(panes.len(), 3, "every row must parse: {panes:?}");
assert_eq!(
classify_session(&panes[0], &tracked, &AlwaysIdleProbe, &NoCwdEvidence),
GcDecision::Keep(KeepReason::TrackedManaged),
"a tracked session must be kept even when its pane runs an agent"
);
assert_eq!(
classify_session(&panes[1], &tracked, &AlwaysIdleProbe, &NoCwdEvidence),
GcDecision::ReapCandidate,
"an untracked idle managed session parsed from a real row must be \
reapable — the live failure was that it never could be (#6529)"
);
assert_eq!(
classify_session(&panes[2], &tracked, &AlwaysIdleProbe, &AllCwdGone),
GcDecision::ReapCandidateCwdGone,
"a real row's cwd column must carry through to the cwd-gone gate (#6118)"
);
let sanitized = "tm-trusty-tools-01_claude_10302_%2306\n\
tm-00b14f3b-dd31-4474-9_zsh_74149_%1860\n";
assert!(
TmuxDriver::parse_managed_pane_rows(sanitized).is_empty(),
"a delimiter-stripped listing must yield no panes (#6529)"
);
}
}