use std::collections::HashSet;
use std::process::Command;
use tracing::{debug, info, warn};
use crate::core::names::is_managed_session_name;
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>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GcDecision {
Keep(KeepReason),
WarnUntrackedActive,
ReapCandidate,
}
#[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,
) -> 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 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,
}
#[derive(Debug, Default)]
pub struct OrphanGc {
prev_candidates: HashSet<String>,
}
impl OrphanGc {
pub fn new() -> Self {
Self::default()
}
pub fn reset_debounce(&mut self) {
self.prev_candidates.clear();
}
pub fn plan_sweep(
&mut self,
panes: &[PaneInfo],
tracked: &TrackedNames,
probe: &dyn ChildLivenessProbe,
) -> SweepPlan {
let mut plan = SweepPlan {
scanned: panes.len(),
..SweepPlan::default()
};
let mut this_pass: HashSet<String> = HashSet::new();
for pane in panes {
match classify_session(pane, tracked, probe) {
GcDecision::ReapCandidate => {
this_pass.insert(pane.session_name.clone());
}
GcDecision::WarnUntrackedActive => {
plan.warned += 1;
info!(
session = %pane.session_name,
command = %pane.pane_current_command,
"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();
self.prev_candidates = 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,
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);
let mut reaped = 0usize;
for name in &plan.to_reap {
match driver.kill_session(name) {
Ok(()) => {
reaped += 1;
info!(
session = %name,
reason = "untracked idle managed session (orphan-GC, debounced)",
"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,
candidates = plan.to_reap.len(),
reaped,
"orphan-GC sweep complete"
);
reaped
}
#[cfg(test)]
mod tests {
use super::*;
struct AlwaysLiveProbe;
impl ChildLivenessProbe for AlwaysLiveProbe {
fn has_live_child(&self, _pane_pid: Option<u32>) -> bool {
true
}
}
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,
}
}
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,
);
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);
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);
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);
assert_eq!(d, GcDecision::Keep(KeepReason::TrackedManaged));
}
#[test]
fn classify_warns_untracked_active() {
let d = classify_session(
&pane("tmpm-rogue", "claude"),
&TrackedNames::default(),
&AlwaysIdleProbe,
);
assert_eq!(d, GcDecision::WarnUntrackedActive);
}
#[test]
fn classify_reaps_untracked_idle() {
let d = classify_session(
&pane("tmpm-orphan", "zsh"),
&TrackedNames::default(),
&AlwaysIdleProbe,
);
assert_eq!(d, GcDecision::ReapCandidate);
}
#[test]
fn classify_keeps_live_child() {
let d = classify_session(
&pane("tmpm-spawning", "zsh"),
&TrackedNames::default(),
&AlwaysLiveProbe,
);
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,
);
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);
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);
assert_eq!(p2.to_reap, vec!["tmpm-d-untracked-idle".to_string()]);
assert_eq!(p2.warned, 1);
}
#[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);
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);
let plan = gc.plan_sweep(&panes, &TrackedNames::default(), &AlwaysIdleProbe);
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);
let tracked = tracked_with(&[], &["tmpm-flaky"]);
let p2 = gc.plan_sweep(&orphan, &tracked, &AlwaysIdleProbe);
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);
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);
gc.reset_debounce();
let after = gc.plan_sweep(&orphan, &TrackedNames::default(), &AlwaysIdleProbe);
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,
},
&tracked,
&AlwaysIdleProbe,
);
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,
},
&tracked,
&AlwaysIdleProbe,
);
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,
},
&tracked,
&AlwaysIdleProbe,
);
assert_eq!(
decision,
GcDecision::Keep(KeepReason::TrackedManaged),
"(c) tracked idle session must be Keep, not ReapCandidate"
);
}
}