1use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11
12use crate::clock::Clock;
13use crate::engine::Engine;
14use crate::liveness::{side_liveness, Liveness, ProcessProbe, Side};
15use crate::pairing::{self, valid_pair_id, Record};
16use crate::sweep::{startup_sweep, STALE_FILE_MS};
17use crate::transport::Transport;
18
19pub const UNKNOWN_GRACE_MS: i64 = 60 * 60 * 1000;
21pub const DEAD_SEEN_GRACE_MS: i64 = STALE_FILE_MS;
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
25pub struct GcReport {
26 pub swept: Vec<PathBuf>,
27 pub pairs_deleted: Vec<String>,
28 pub dead_seen_written: Vec<String>,
29 pub dead_seen_cleared: Vec<String>,
30 pub skipped_service_down: bool,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum Verdict {
36 Living,
38 Undecided,
40 AllDead,
42}
43
44fn mtime_ms(path: &Path) -> Option<i64> {
45 let t = std::fs::metadata(path).ok()?.modified().ok()?;
46 let d = t.duration_since(std::time::UNIX_EPOCH).ok()?;
47 Some(d.as_millis() as i64)
48}
49
50fn pair_file(home: &Path, id: &str, suffix: &str) -> PathBuf {
51 home.join(format!("pair.{id}.{suffix}"))
52}
53
54fn tui_pid_file(home: &Path, record: &Record) -> Option<PathBuf> {
57 let id = &record.pair_id;
58 let wanted = pair_file(home, id, &format!("g{}.tui-pid", record.generation));
59 if wanted.exists() {
60 return Some(wanted);
61 }
62 let prefix = format!("pair.{id}.g");
63 std::fs::read_dir(home)
64 .ok()?
65 .flatten()
66 .filter_map(|e| {
67 let name = e.file_name().into_string().ok()?;
68 let g = name
69 .strip_prefix(&prefix)?
70 .strip_suffix(".tui-pid")?
71 .parse::<u32>()
72 .ok()?;
73 Some((g, e.path()))
74 })
75 .max_by_key(|(g, _)| *g)
76 .map(|(_, p)| p)
77}
78
79fn unknown_age_ms(side: Side, record: &Record, home: &Path, now_ms: i64) -> i64 {
81 let sentinel = match side {
82 Side::Tui => tui_pid_file(home, record),
83 Side::Controller => Some(pair_file(home, &record.pair_id, "claude-pid")),
84 };
85 let origin = sentinel
86 .and_then(|p| mtime_ms(&p))
87 .unwrap_or(record.created);
88 now_ms - origin
89}
90
91fn verdict(record: &Record, home: &Path, probe: &dyn ProcessProbe, now_ms: i64) -> Verdict {
92 let mut undecided = false;
93 for side in [Side::Tui, Side::Controller] {
94 match side_liveness(side, record, home, probe, now_ms) {
95 Liveness::Alive | Liveness::Starting => return Verdict::Living,
96 Liveness::Dead | Liveness::None => {}
97 Liveness::Unknown => {
98 if unknown_age_ms(side, record, home, now_ms) <= UNKNOWN_GRACE_MS {
99 undecided = true;
100 }
101 }
102 }
103 }
104 if undecided {
105 Verdict::Undecided
106 } else {
107 Verdict::AllDead
108 }
109}
110
111fn load_record(home: &Path, id: &str) -> Option<Record> {
114 if !valid_pair_id(id) {
115 return None;
116 }
117 let meta = std::fs::symlink_metadata(pairing::record_path(home, id)).ok()?;
118 if !meta.file_type().is_file() {
119 return None;
120 }
121 pairing::read_record(home, id).ok().flatten()
122}
123
124fn read_dead_seen(home: &Path, id: &str) -> Option<i64> {
126 std::fs::read_to_string(pair_file(home, id, "dead-seen"))
127 .ok()?
128 .trim()
129 .parse()
130 .ok()
131}
132
133fn remove_dead_seen(home: &Path, id: &str) -> bool {
134 std::fs::remove_file(pair_file(home, id, "dead-seen")).is_ok()
135}
136
137fn write_dead_seen(home: &Path, id: &str, now_ms: i64) -> bool {
138 let path = pair_file(home, id, "dead-seen");
139 if path.exists() {
140 let _ = std::fs::remove_file(&path);
142 }
143 pairing::claim_file(&path, now_ms.to_string().as_bytes(), 0o600).unwrap_or(false)
144}
145
146fn pair_files_deletable(home: &Path, id: &str) -> Option<Vec<PathBuf>> {
148 let prefix = format!("pair.{id}.");
149 let mut files = Vec::new();
150 for e in std::fs::read_dir(home).ok()?.flatten() {
151 let name = e.file_name().into_string().ok()?;
152 if !name.starts_with(&prefix) {
153 continue;
154 }
155 let path = e.path();
156 if path.parent() != Some(home) || !std::fs::symlink_metadata(&path).ok()?.is_file() {
157 return None;
158 }
159 files.push(path);
160 }
161 Some(files)
162}
163
164fn delete_pair(home: &Path, id: &str) -> bool {
167 let record = pairing::record_path(home, id);
168 let Some(files) = pair_files_deletable(home, id) else {
169 tracing::warn!("gc: pair {id} has a non-regular entry; left alone");
170 return false;
171 };
172 for f in files.iter().filter(|f| **f != record) {
173 if let Err(e) = std::fs::remove_file(f) {
174 if e.kind() != std::io::ErrorKind::NotFound {
175 tracing::warn!("gc: cannot delete {}: {e}", f.display());
176 return false;
177 }
178 }
179 }
180 pairing::delete_pair_files(home, id).is_ok()
181}
182
183async fn active_sessions<T: Transport, C: Clock>(
185 engine: &Engine<T, C>,
186) -> Option<std::collections::HashSet<String>> {
187 let (data, _, _) = engine
188 .call_json("session.active", Vec::new(), None)
189 .await
190 .ok()?;
191 Some(data.as_object()?.keys().cloned().collect())
192}
193
194pub async fn gc<T: Transport, C: Clock>(
198 engine: &Engine<T, C>,
199 probe: &dyn ProcessProbe,
200 now_ms: i64,
201 before_delete: Option<&dyn Fn()>,
202) -> GcReport {
203 let home = engine.env.home.as_path();
204 let mut report = GcReport {
205 swept: startup_sweep(home, now_ms, probe).removed,
206 ..GcReport::default()
207 };
208 let Some(active) = active_sessions(engine).await else {
209 tracing::warn!("gc: session.active unreadable; no pair is touched");
210 report.skipped_service_down = true;
211 return report;
212 };
213 for id in pairing::record_ids(home) {
214 let Some(record) = load_record(home, &id) else {
215 continue;
216 };
217 match verdict(&record, home, probe, now_ms) {
218 Verdict::Living => {
219 if remove_dead_seen(home, &id) {
220 tracing::info!("gc: pair {id} is alive again; dead-seen cleared");
221 report.dead_seen_cleared.push(id);
222 }
223 }
224 Verdict::Undecided => {}
225 Verdict::AllDead => {
226 let Some(seen) = read_dead_seen(home, &id) else {
227 if write_dead_seen(home, &id, now_ms) {
228 tracing::info!("gc: pair {id} reads dead; dead-seen written");
229 report.dead_seen_written.push(id);
230 }
231 continue;
232 };
233 if now_ms - seen <= DEAD_SEEN_GRACE_MS || active.contains(&record.session_id) {
234 continue;
235 }
236 if let Some(f) = before_delete {
237 f();
238 }
239 let Some(record) = load_record(home, &id) else {
241 continue;
242 };
243 match verdict(&record, home, probe, now_ms) {
244 Verdict::Living => {
245 if remove_dead_seen(home, &id) {
246 tracing::info!(
247 "gc: pair {id} revived before deletion; dead-seen cleared"
248 );
249 report.dead_seen_cleared.push(id);
250 }
251 }
252 Verdict::Undecided => {}
253 Verdict::AllDead => {
254 let still_old = read_dead_seen(home, &id)
255 .is_some_and(|s| now_ms - s > DEAD_SEEN_GRACE_MS);
256 if still_old
257 && !active.contains(&record.session_id)
258 && delete_pair(home, &id)
259 {
260 tracing::info!("gc: pair {id} deleted");
261 report.pairs_deleted.push(id);
262 }
263 }
264 }
265 }
266 }
267 }
268 report
269}