use std::collections::HashSet;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
const PID_FILE_EXT: &str = "pid";
pub trait ProcessProbe {
fn is_alive(&self, pid: u32) -> bool;
fn is_claude(&self, pid: u32) -> bool;
fn terminate(&self, pid: u32) -> bool;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PidDecision {
Keep,
Terminate,
RemoveStale(StaleReason),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StaleReason {
ProcessDead,
PidReused,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PidEntry {
pub session_id: String,
pub pid: u32,
}
pub fn classify_pidfile(
entry: &PidEntry,
live_session_ids: &HashSet<String>,
probe: &dyn ProcessProbe,
) -> PidDecision {
if live_session_ids.contains(&entry.session_id) {
return PidDecision::Keep;
}
if !probe.is_alive(entry.pid) {
return PidDecision::RemoveStale(StaleReason::ProcessDead);
}
if !probe.is_claude(entry.pid) {
return PidDecision::RemoveStale(StaleReason::PidReused);
}
PidDecision::Terminate
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct SweepOutcome {
pub scanned: usize,
pub terminated: usize,
pub removed_stale: usize,
pub deferred: usize,
}
#[derive(Debug, Default)]
pub struct PidOrphanGc {
prev_candidates: HashSet<String>,
}
impl PidOrphanGc {
pub fn new() -> Self {
Self::default()
}
}
#[derive(Debug, Clone)]
pub struct PidRegistry {
dir: PathBuf,
}
impl PidRegistry {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self { dir: dir.into() }
}
pub fn under(base: impl AsRef<Path>) -> Self {
Self::new(base.as_ref().join("pids"))
}
pub fn dir(&self) -> &Path {
&self.dir
}
fn pid_path(&self, session_id: &str) -> PathBuf {
self.dir.join(format!("{session_id}.{PID_FILE_EXT}"))
}
pub fn register(&self, session_id: &str, pid: u32) -> std::io::Result<()> {
std::fs::create_dir_all(&self.dir)?;
let final_path = self.pid_path(session_id);
let tmp_path = self.dir.join(format!(".{session_id}.{PID_FILE_EXT}.tmp"));
std::fs::write(&tmp_path, pid.to_string())?;
std::fs::rename(&tmp_path, &final_path)?;
debug!(session_id, pid, "pid-registry: registered");
Ok(())
}
pub fn unregister(&self, session_id: &str) -> std::io::Result<()> {
let path = self.pid_path(session_id);
match std::fs::remove_file(&path) {
Ok(()) => {
debug!(session_id, "pid-registry: unregistered");
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e),
}
}
pub fn entries(&self) -> std::io::Result<Vec<PidEntry>> {
let read_dir = match std::fs::read_dir(&self.dir) {
Ok(rd) => rd,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut out = Vec::new();
for dent in read_dir.flatten() {
let path = dent.path();
if path.extension().and_then(|e| e.to_str()) != Some(PID_FILE_EXT) {
continue;
}
let Some(session_id) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let body = match std::fs::read_to_string(&path) {
Ok(b) => b,
Err(e) => {
warn!(path = %path.display(), "pid-registry: read failed: {e}");
continue;
}
};
match body.trim().parse::<u32>() {
Ok(pid) => out.push(PidEntry {
session_id: session_id.to_string(),
pid,
}),
Err(_) => {
warn!(path = %path.display(), "pid-registry: unparsable PID; skipping");
}
}
}
Ok(out)
}
pub fn sweep_orphans(
&self,
live_session_ids: &HashSet<String>,
probe: &dyn ProcessProbe,
gc: &mut PidOrphanGc,
) -> SweepOutcome {
let entries = match self.entries() {
Ok(e) => e,
Err(e) => {
warn!("pid-registry: entries() failed; skipping sweep: {e}");
return SweepOutcome::default();
}
};
let mut outcome = SweepOutcome {
scanned: entries.len(),
..SweepOutcome::default()
};
let mut this_pass_candidates: HashSet<String> = HashSet::new();
for entry in &entries {
match classify_pidfile(entry, live_session_ids, probe) {
PidDecision::Keep => {}
PidDecision::Terminate => {
this_pass_candidates.insert(entry.session_id.clone());
if gc.prev_candidates.contains(&entry.session_id) {
let delivered = probe.terminate(entry.pid);
info!(
session_id = %entry.session_id,
pid = entry.pid,
delivered,
"pid-registry: SIGTERM orphaned claude process (confirmed on 2nd sweep)"
);
outcome.terminated += 1;
self.remove_pidfile_logged(&entry.session_id);
} else {
debug!(
session_id = %entry.session_id,
pid = entry.pid,
"pid-registry: new terminate-candidate; deferring to next sweep \
(restart-safety debounce, #1918)"
);
outcome.deferred += 1;
}
}
PidDecision::RemoveStale(reason) => {
debug!(
session_id = %entry.session_id,
pid = entry.pid,
?reason,
"pid-registry: removing stale PID file (no signal)"
);
outcome.removed_stale += 1;
self.remove_pidfile_logged(&entry.session_id);
}
}
}
gc.prev_candidates = this_pass_candidates;
debug!(
scanned = outcome.scanned,
terminated = outcome.terminated,
removed_stale = outcome.removed_stale,
deferred = outcome.deferred,
"pid-registry: orphan sweep complete"
);
outcome
}
fn remove_pidfile_logged(&self, session_id: &str) {
if let Err(e) = self.unregister(session_id) {
warn!(session_id, "pid-registry: failed to remove PID file: {e}");
}
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct OsProcessProbe;
impl ProcessProbe for OsProcessProbe {
fn is_alive(&self, pid: u32) -> bool {
crate::core::process::is_process_alive(pid)
}
fn is_claude(&self, pid: u32) -> bool {
crate::core::process::process_name_is_claude(pid)
}
fn terminate(&self, pid: u32) -> bool {
#[cfg(unix)]
{
use nix::sys::signal::{Signal, kill};
use nix::unistd::Pid;
let Ok(raw) = i32::try_from(pid) else {
return false;
};
if raw <= 0 {
return false;
}
kill(Pid::from_raw(raw), Signal::SIGTERM).is_ok()
}
#[cfg(not(unix))]
{
let _ = pid;
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct FakeProbe {
alive: HashSet<u32>,
claude: HashSet<u32>,
terminated: std::cell::RefCell<Vec<u32>>,
}
impl FakeProbe {
fn new(alive: &[u32], claude: &[u32]) -> Self {
Self {
alive: alive.iter().copied().collect(),
claude: claude.iter().copied().collect(),
terminated: std::cell::RefCell::new(Vec::new()),
}
}
}
impl ProcessProbe for FakeProbe {
fn is_alive(&self, pid: u32) -> bool {
self.alive.contains(&pid)
}
fn is_claude(&self, pid: u32) -> bool {
self.claude.contains(&pid)
}
fn terminate(&self, pid: u32) -> bool {
self.terminated.borrow_mut().push(pid);
true
}
}
fn live(ids: &[&str]) -> HashSet<String> {
ids.iter().map(|s| s.to_string()).collect()
}
fn entry(id: &str, pid: u32) -> PidEntry {
PidEntry {
session_id: id.to_string(),
pid,
}
}
#[test]
fn under_nests_pids_subdir() {
let reg = PidRegistry::under("/base/.trusty-mpm");
assert_eq!(reg.dir(), Path::new("/base/.trusty-mpm/pids"));
}
#[test]
fn register_then_entries_round_trip() {
let tmp = tempfile::TempDir::new().unwrap();
let reg = PidRegistry::new(tmp.path());
reg.register("sess-a", 1234).unwrap();
reg.register("sess-b", 5678).unwrap();
let mut got = reg.entries().unwrap();
got.sort_by(|a, b| a.session_id.cmp(&b.session_id));
assert_eq!(got, vec![entry("sess-a", 1234), entry("sess-b", 5678)]);
}
#[test]
fn register_is_idempotent() {
let tmp = tempfile::TempDir::new().unwrap();
let reg = PidRegistry::new(tmp.path());
reg.register("sess", 11).unwrap();
reg.register("sess", 22).unwrap();
assert_eq!(reg.entries().unwrap(), vec![entry("sess", 22)]);
}
#[test]
fn unregister_removes_file() {
let tmp = tempfile::TempDir::new().unwrap();
let reg = PidRegistry::new(tmp.path());
reg.register("gone", 99).unwrap();
reg.unregister("gone").unwrap();
assert!(reg.entries().unwrap().is_empty());
}
#[test]
fn unregister_missing_is_ok() {
let tmp = tempfile::TempDir::new().unwrap();
let reg = PidRegistry::new(tmp.path());
reg.unregister("never").unwrap();
}
#[test]
fn entries_on_missing_dir_is_empty() {
let reg = PidRegistry::new("/nonexistent/path/that/does/not/exist/pids");
assert!(reg.entries().unwrap().is_empty());
}
#[test]
fn entries_skips_unparsable() {
let tmp = tempfile::TempDir::new().unwrap();
let reg = PidRegistry::new(tmp.path());
reg.register("good", 7).unwrap();
std::fs::write(tmp.path().join("bad.pid"), "not-a-number").unwrap();
std::fs::write(tmp.path().join("ignore.txt"), "100").unwrap();
assert_eq!(reg.entries().unwrap(), vec![entry("good", 7)]);
}
#[test]
fn classify_keeps_tracked() {
let probe = FakeProbe::new(&[10], &[10]);
let d = classify_pidfile(&entry("alive-sess", 10), &live(&["alive-sess"]), &probe);
assert_eq!(d, PidDecision::Keep);
}
#[test]
fn classify_removes_dead() {
let probe = FakeProbe::new(&[], &[]); let d = classify_pidfile(&entry("dead", 20), &live(&[]), &probe);
assert_eq!(d, PidDecision::RemoveStale(StaleReason::ProcessDead));
}
#[test]
fn classify_removes_reused_pid() {
let probe = FakeProbe::new(&[30], &[]); let d = classify_pidfile(&entry("reused", 30), &live(&[]), &probe);
assert_eq!(d, PidDecision::RemoveStale(StaleReason::PidReused));
}
#[test]
fn classify_terminates_untracked_claude() {
let probe = FakeProbe::new(&[40], &[40]);
let d = classify_pidfile(&entry("orphan", 40), &live(&[]), &probe);
assert_eq!(d, PidDecision::Terminate);
}
#[test]
fn sweep_terminates_only_untracked_claude() {
let tmp = tempfile::TempDir::new().unwrap();
let reg = PidRegistry::new(tmp.path());
reg.register("tracked", 100).unwrap(); reg.register("orphan", 200).unwrap(); reg.register("dead", 300).unwrap(); reg.register("reused", 400).unwrap();
let probe = FakeProbe::new(&[100, 200, 400], &[100, 200]);
let mut gc = PidOrphanGc::new();
let outcome1 = reg.sweep_orphans(&live(&["tracked"]), &probe, &mut gc);
assert_eq!(outcome1.scanned, 4);
assert_eq!(outcome1.terminated, 0);
assert_eq!(outcome1.deferred, 1);
assert_eq!(outcome1.removed_stale, 2);
assert!(probe.terminated.borrow().is_empty());
let mut remaining: Vec<String> = reg
.entries()
.unwrap()
.into_iter()
.map(|e| e.session_id)
.collect();
remaining.sort();
assert_eq!(remaining, vec!["orphan".to_string(), "tracked".to_string()]);
let outcome2 = reg.sweep_orphans(&live(&["tracked"]), &probe, &mut gc);
assert_eq!(outcome2.scanned, 2);
assert_eq!(outcome2.terminated, 1);
assert_eq!(outcome2.deferred, 0);
assert_eq!(outcome2.removed_stale, 0);
assert_eq!(*probe.terminated.borrow(), vec![200]);
assert_eq!(reg.entries().unwrap(), vec![entry("tracked", 100)]);
}
#[test]
fn sweep_removes_dead_without_signal() {
let tmp = tempfile::TempDir::new().unwrap();
let reg = PidRegistry::new(tmp.path());
reg.register("dead", 7).unwrap();
let probe = FakeProbe::new(&[], &[]);
let outcome = reg.sweep_orphans(&live(&[]), &probe, &mut PidOrphanGc::new());
assert_eq!(outcome.removed_stale, 1);
assert_eq!(outcome.terminated, 0);
assert_eq!(outcome.deferred, 0);
assert!(probe.terminated.borrow().is_empty());
assert!(reg.entries().unwrap().is_empty());
}
#[test]
fn sweep_spares_reused_pid() {
let tmp = tempfile::TempDir::new().unwrap();
let reg = PidRegistry::new(tmp.path());
reg.register("reused", 8).unwrap();
let probe = FakeProbe::new(&[8], &[]); let outcome = reg.sweep_orphans(&live(&[]), &probe, &mut PidOrphanGc::new());
assert_eq!(outcome.terminated, 0);
assert_eq!(outcome.removed_stale, 1);
assert!(
probe.terminated.borrow().is_empty(),
"a reused PID must never be signalled"
);
}
#[test]
fn sweep_keeps_tracked() {
let tmp = tempfile::TempDir::new().unwrap();
let reg = PidRegistry::new(tmp.path());
reg.register("tracked", 9).unwrap();
let probe = FakeProbe::new(&[9], &[9]);
let outcome = reg.sweep_orphans(&live(&["tracked"]), &probe, &mut PidOrphanGc::new());
assert_eq!(
outcome,
SweepOutcome {
scanned: 1,
terminated: 0,
removed_stale: 0,
deferred: 0,
}
);
assert_eq!(reg.entries().unwrap(), vec![entry("tracked", 9)]);
}
#[test]
fn pid_debounce_spares_first_sighting() {
let tmp = tempfile::TempDir::new().unwrap();
let reg = PidRegistry::new(tmp.path());
reg.register("fresh-orphan", 55).unwrap();
let probe = FakeProbe::new(&[55], &[55]);
let mut gc = PidOrphanGc::new();
let outcome = reg.sweep_orphans(&live(&[]), &probe, &mut gc);
assert_eq!(outcome.terminated, 0);
assert_eq!(outcome.deferred, 1);
assert!(probe.terminated.borrow().is_empty());
assert_eq!(reg.entries().unwrap(), vec![entry("fresh-orphan", 55)]);
}
#[test]
fn pid_debounce_terminates_second_sighting() {
let tmp = tempfile::TempDir::new().unwrap();
let reg = PidRegistry::new(tmp.path());
reg.register("persistent-orphan", 66).unwrap();
let probe = FakeProbe::new(&[66], &[66]);
let mut gc = PidOrphanGc::new();
let _ = reg.sweep_orphans(&live(&[]), &probe, &mut gc);
let outcome = reg.sweep_orphans(&live(&[]), &probe, &mut gc);
assert_eq!(outcome.terminated, 1);
assert_eq!(outcome.deferred, 0);
assert_eq!(*probe.terminated.borrow(), vec![66]);
assert!(reg.entries().unwrap().is_empty());
}
#[test]
fn pid_debounce_resets_when_session_becomes_tracked() {
let tmp = tempfile::TempDir::new().unwrap();
let reg = PidRegistry::new(tmp.path());
reg.register("flaky", 77).unwrap();
let probe = FakeProbe::new(&[77], &[77]);
let mut gc = PidOrphanGc::new();
let p1 = reg.sweep_orphans(&live(&[]), &probe, &mut gc);
assert_eq!(p1.deferred, 1);
let p2 = reg.sweep_orphans(&live(&["flaky"]), &probe, &mut gc);
assert_eq!(p2.terminated, 0);
assert_eq!(p2.deferred, 0);
let p3 = reg.sweep_orphans(&live(&[]), &probe, &mut gc);
assert_eq!(p3.terminated, 0);
assert_eq!(p3.deferred, 1);
assert!(probe.terminated.borrow().is_empty());
}
}