1use std::path::Path;
23use std::time::Duration;
24
25use stackless_core::paths::Paths;
26use stackless_core::state::{ReapAttempt, ReapDecision, Store};
27use stackless_core::types::TcpPort;
28use tokio::time::{self, MissedTickBehavior};
29
30const TICK: Duration = Duration::from_secs(60);
31const DOWN_BUDGET: Duration = Duration::from_secs(900);
32
33pub async fn run(paths: Paths, proxy_port: TcpPort) {
36 let mut interval = time::interval(TICK);
37 interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
40 loop {
41 interval.tick().await;
42 tick(&paths, proxy_port).await;
43 }
44}
45
46async fn tick(paths: &Paths, proxy_port: TcpPort) {
55 let worklist = plan_reaps(paths);
56 let exe = std::env::current_exe().map_err(|err| format!("cannot resolve binary path: {err}"));
57 for instance in worklist {
58 let outcome = match &exe {
59 Ok(path) => run_down(&instance, path, paths, proxy_port).await,
60 Err(err) => Err(err.clone()),
61 };
62 record_outcome(paths, &instance, outcome);
63 }
64 if let Ok(store) = Store::open_with_paths(paths) {
65 gc_tombstones(&store, paths);
66 }
67}
68
69fn plan_reaps(paths: &Paths) -> Vec<String> {
73 let Ok(store) = Store::open_with_paths(paths) else {
74 return Vec::new();
75 };
76 let expired = store.expired_instances().unwrap_or_default();
77 let now = Store::now_secs();
78 expired
79 .into_iter()
80 .filter(|instance| {
81 let lock_held = store.lock_holder_alive(instance).unwrap_or(false);
82 let prior = store.reap_attempt(instance).ok().flatten();
83 matches!(
84 ReapDecision::decide(now, lock_held, prior.as_ref()),
85 ReapDecision::Reap
86 )
87 })
88 .collect()
89}
90
91fn record_outcome(paths: &Paths, instance: &str, outcome: Result<(), String>) {
95 let Ok(store) = Store::open_with_paths(paths) else {
96 return;
97 };
98 match outcome {
99 Ok(()) => {
100 let _ = store.clear_reap_failure(instance);
101 }
102 Err(reason) => {
103 let _ = store.record_reap_failure(instance, &reason);
104 let attempts = store
105 .reap_attempt(instance)
106 .ok()
107 .flatten()
108 .map(|a| a.attempts)
109 .unwrap_or(1);
110 eprintln!(
111 "stackless reaper: reap of {instance:?} failed: {reason} \
112 (attempt {attempts}, retrying in {}s)",
113 ReapAttempt::backoff_after(attempts).as_secs()
114 );
115 }
116 }
117}
118
119async fn run_down(
127 instance: &str,
128 executable: &Path,
129 paths: &Paths,
130 proxy_port: TcpPort,
131) -> Result<(), String> {
132 let port = proxy_port.get().to_string();
133 let mut cmd = tokio::process::Command::new(executable);
134 cmd.args(["down", instance, "--json"])
135 .arg("--state-dir")
136 .arg(paths.state_dir())
137 .arg("--proxy-port")
138 .arg(&port)
139 .env("STACKLESS_NO_SELF_UPDATE", "1")
140 .stdout(std::process::Stdio::piped())
141 .stderr(std::process::Stdio::piped());
142 #[cfg(unix)]
143 {
144 cmd.process_group(0);
145 }
146 let child = cmd
147 .spawn()
148 .map_err(|err| format!("cannot spawn `down`: {err}"))?;
149 let pid = child.id();
150 match tokio::time::timeout(DOWN_BUDGET, child.wait_with_output()).await {
151 Ok(Ok(output)) => {
152 if output.status.success() {
153 return Ok(());
154 }
155 let stdout = String::from_utf8_lossy(&output.stdout);
156 Err(reap_failure_reason(
157 &stdout,
158 &format!("`down` exited with {}", output.status),
159 ))
160 }
161 Ok(Err(err)) => Err(format!("cannot wait for `down`: {err}")),
162 Err(_) => {
163 if let Some(pid) = pid {
164 stackless_core::process::kill_process_tree(pid);
165 }
166 Err(format!(
167 "reaper.down_timeout: `down {instance}` exceeded {}s",
168 DOWN_BUDGET.as_secs()
169 ))
170 }
171 }
172}
173
174pub(crate) fn reap_failure_reason(stdout: &str, fallback: &str) -> String {
177 let Some(start) = stdout.find('{') else {
178 let last = stdout
179 .lines()
180 .rev()
181 .map(str::trim)
182 .find(|line| !line.is_empty() && *line != "}" && *line != "{");
183 return last.unwrap_or(fallback).to_owned();
184 };
185 let Ok(value) = serde_json::from_str::<serde_json::Value>(&stdout[start..]) else {
186 return fallback.to_owned();
187 };
188 let code = value
189 .pointer("/error/code")
190 .and_then(serde_json::Value::as_str)
191 .unwrap_or("");
192 let message = value
193 .pointer("/error/message")
194 .and_then(serde_json::Value::as_str)
195 .unwrap_or("");
196 match (code.is_empty(), message.is_empty()) {
197 (true, true) => fallback.to_owned(),
198 (false, true) => code.to_owned(),
199 (true, false) => message.to_owned(),
200 (false, false) => format!("{code}: {message}"),
201 }
202}
203
204fn gc_tombstones(store: &Store, paths: &Paths) {
205 for instance in store.gc_due_tombstones().unwrap_or_default() {
206 let logs = paths.logs_dir(&instance);
209 if logs.exists() {
210 let _ = std::fs::remove_dir_all(&logs);
211 }
212 if let Err(err) = store.delete_instance(&instance) {
213 eprintln!("stackless reaper: GC of tombstone {instance:?} failed: {err}");
214 }
215 }
216}
217
218pub async fn tick_once(paths: &Paths, proxy_port: TcpPort) {
221 tick(paths, proxy_port).await;
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use stackless_core::state::TOMBSTONE_GC_WINDOW;
228 use std::collections::BTreeMap;
229 use std::time::SystemTime;
230
231 fn temp_store() -> (tempfile::TempDir, Paths, Store) {
232 let dir = tempfile::tempdir().expect("tempdir");
233 let paths = Paths::new(dir.path());
234 let store = Store::open_with_paths(&paths).expect("open");
235 (dir, paths, store)
236 }
237
238 const DEF: &str = "[stack]\nname = \"t\"\n[services.web]\nsource = { repo = \"https://example.invalid/x\", ref = \"main\" }\nhealth = { path = \"/\" }\n[services.web.mock]\nrun = \"true\"\n";
239
240 #[test]
241 fn reaper_skips_an_instance_holding_its_lock() {
242 let (_dir, _paths, store) = temp_store();
243 store
244 .create_instance("held", "mock", DEF, &BTreeMap::new(), "", false)
245 .expect("create");
246 store
247 .renew_lease("held", Duration::from_secs(0))
248 .expect("renew");
249 let _claim = store.claim_lock("held", "up").expect("claim");
251 assert_eq!(store.expired_instances().expect("expired"), vec!["held"]);
252 let lock_held = store.lock_holder_alive("held").expect("alive");
253 assert!(lock_held);
254 assert_eq!(
256 ReapDecision::decide(Store::now_secs(), lock_held, None),
257 ReapDecision::SkipLocked
258 );
259 }
260
261 #[test]
262 fn gc_removes_only_tombstones_past_the_window() {
263 let (_dir, paths, store) = temp_store();
264 store
265 .create_instance("old", "mock", DEF, &BTreeMap::new(), "", false)
266 .expect("create old");
267 store
268 .create_instance("recent", "mock", DEF, &BTreeMap::new(), "", false)
269 .expect("create recent");
270 store.tombstone_instance("old").expect("tombstone old");
271 store
272 .tombstone_instance("recent")
273 .expect("tombstone recent");
274 let now = SystemTime::now()
276 .duration_since(SystemTime::UNIX_EPOCH)
277 .expect("clock")
278 .as_secs() as i64;
279 let stale = now - TOMBSTONE_GC_WINDOW.as_secs() as i64 - 1;
280 store
281 .conn_for_tests()
282 .execute(
283 "UPDATE instances SET tombstoned_at = ?1 WHERE name = 'old'",
284 [stale],
285 )
286 .expect("backdate");
287 assert_eq!(store.gc_due_tombstones().expect("due"), vec!["old"]);
288 gc_tombstones(&store, &paths);
289 assert!(store.instance("old").expect("q").is_none());
290 assert!(store.instance("recent").expect("q").is_some());
291 }
292
293 #[test]
294 fn reap_reason_reads_pretty_json_envelope_not_closing_brace() {
295 let stdout = r#"{
296 "ok": false,
297 "error": {
298 "schema_version": 1,
299 "code": "stripe.projects.timeout",
300 "message": "stripe projects timed out after 90s",
301 "remediation": "kill leftover stripe processes"
302 }
303}"#;
304 let reason = reap_failure_reason(stdout, "`down` exited with 1");
305 assert_eq!(
306 reason,
307 "stripe.projects.timeout: stripe projects timed out after 90s"
308 );
309 assert!(!reason.contains('}'));
310 }
311
312 #[test]
313 fn reap_reason_falls_back_when_stdout_is_empty() {
314 assert_eq!(
315 reap_failure_reason(" \n", "`down` exited with 1"),
316 "`down` exited with 1"
317 );
318 }
319}