browser_automation_cli/lifecycle.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! One-shot lifecycle: INIT → EXECUTE → FINALIZE → EXIT.
3//!
4//! # Workload
5//!
6//! **Coordination (not compute fan-out):** one `CancellationToken` per process
7//! invocation. Residual scavenge may use `map_cpu` when candidate paths are
8//! large (see [`crate::residual`]). FINALIZE is ordered: Browser.close → residual
9//! kill → flush. No multi-writer shared state.
10//!
11//! # Graceful shutdown model (rules_rust_encerramento_graceful_shutdown)
12//!
13//! This binary is a **one-shot CLI**, not a daemon. Shutdown is therefore
14//! **minimal + critical for pipelines**, not a full multi-subsystem coordinator:
15//!
16//! 1. **Detect** — OS signals (`SIGINT`/`SIGTERM`, Windows Ctrl-C/Break) via
17//! [`crate::browser::shutdown_signal`] wired inside
18//! [`crate::browser::block_on_browser_timeout`].
19//! 2. **Signal** — cooperative [`CancellationToken`] cancel (exit **130**).
20//! 3. **Await / finalize** — primary `OneShotSession::shutdown` (Browser.close +
21//! wait ≤5s + kill); residual ledger kill with **SIGTERM → grace → SIGKILL**;
22//! dual stream flush; process `ExitCode`.
23//!
24//! ## Deadlines
25//!
26//! | Phase | Bound |
27//! |-------|--------|
28//! | Work / navigation | CLI `--timeout` / XDG / step-timeout |
29//! | Browser.close wait | 5s (`finalize_browser`) |
30//! | Residual child grace (SIGTERM→SIGKILL) | [`FINALIZE_CHILD_GRACE`] |
31//! | Second OS signal | Forces residual [`Lifecycle::finalize`] immediately |
32//!
33//! ## Not applicable (product law)
34//!
35//! Daemon patterns: `TaskTracker` fleets, readiness probes, SIGHUP hot-reload,
36//! `sd_notify`, PID files, OpenTelemetry flush, TUI terminal restore.
37//!
38//! # Safety
39//!
40//! On Unix, finalize may send `SIGTERM` then (after grace) `SIGKILL` to a
41//! recorded Chrome PID as last resort when primary Browser.close reap did not
42//! clear the ledger. Profile dirs and Chrome Singleton side-channels are wiped
43//! **only** when recorded in this ledger (never a host-wide chrome wipe).
44
45use std::cell::RefCell;
46use std::path::PathBuf;
47use std::sync::atomic::{AtomicBool, Ordering};
48use std::sync::Arc;
49use std::time::{Duration, Instant};
50
51use tokio_util::sync::CancellationToken;
52
53/// Max wait after residual `SIGTERM` before escalating to `SIGKILL` (Unix).
54///
55/// Kept short for one-shot CLIs (agents expect fast DIE) while still giving
56/// Chrome a chance to flush profile locks before hard kill.
57pub const FINALIZE_CHILD_GRACE: Duration = Duration::from_secs(2);
58
59// Thread-local cancel + lifecycle for the active invocation (main-thread one-shot).
60// Used by block_on_browser_timeout so SIGINT/SIGTERM abort work without
61// threading Lifecycle through every call site.
62//
63// `const { RefCell::new(None) }` avoids runtime init on first access (MSRV ≥ 1.59).
64// `RefCell` is correct here: TLS is not `Sync` across threads; each thread owns its cell.
65thread_local! {
66 static CURRENT_CANCEL: RefCell<Option<CancellationToken>> = const { RefCell::new(None) };
67 static CURRENT_LIFE: RefCell<Option<Lifecycle>> = const { RefCell::new(None) };
68}
69
70/// Return the cancel token for the active invocation, or a fresh inert token.
71///
72/// Uses `try_borrow` so a re-entrant path that already holds the TLS `RefCell`
73/// cannot panic (returns a fresh inert token instead).
74pub fn current_cancel() -> CancellationToken {
75 CURRENT_CANCEL.with(|c| {
76 c.try_borrow()
77 .ok()
78 .and_then(|slot| slot.clone())
79 .unwrap_or_else(CancellationToken::new)
80 })
81}
82
83/// Return a clone of the active [`Lifecycle`], if one was registered.
84///
85/// Used by the second-signal force path to run residual finalize without
86/// threading `Lifecycle` through every browser call site.
87///
88/// Uses `try_borrow` so re-entrancy cannot panic on the TLS `RefCell`.
89pub fn current_lifecycle() -> Option<Lifecycle> {
90 CURRENT_LIFE.with(|c| c.try_borrow().ok().and_then(|slot| slot.clone()))
91}
92
93/// Ledger of resources owned by this invocation (browser, profile, temp dirs).
94#[derive(Debug, Default)]
95pub struct ResourceLedger {
96 /// True when this process launched Chrome and still owns the residual flag.
97 pub chrome_launched: bool,
98 /// Optional OS process id of launched Chrome for last-resort kill.
99 pub chrome_pid: Option<u32>,
100 /// Temporary profile directory created for this invocation, if any.
101 pub profile_dir: Option<PathBuf>,
102 /// Side-channel paths owned by this launch (e.g. SingletonLock under /tmp).
103 pub side_channels: Vec<PathBuf>,
104 /// Windows Job Object handle as usize (cfg windows); zero when unused.
105 pub windows_job_handle: usize,
106 /// Wall-clock start of this invocation (for FINALIZE scavenger window).
107 pub started_at: Option<std::time::SystemTime>,
108}
109
110/// Runtime token for cooperative cancel and FINALIZE.
111///
112/// # Ownership
113///
114/// Clones share the same `Arc` ledger and cancel token. Dropping any clone that
115/// wins the finalize CAS performs residual cleanup — treat as a resource owner
116/// (`#[must_use]`).
117#[derive(Clone)]
118#[must_use = "Lifecycle owns residual Chrome/profile cleanup; call finalize or drop"]
119pub struct Lifecycle {
120 /// Cancellation token shared with async tasks.
121 pub cancel: CancellationToken,
122 /// Whether FINALIZE already completed.
123 ///
124 /// Updated with `Ordering::SeqCst` so a single total order is visible to
125 /// Drop, signal handlers, and async tasks racing on idempotent finalize.
126 pub finalize_done: Arc<AtomicBool>,
127 /// Owned resources for residual cleanup.
128 ///
129 /// `std::sync::Mutex` (not `tokio::sync::Mutex`): critical sections are short
130 /// and never held across `.await`. Poison is always recovered via
131 /// [`Self::with_ledger_mut`] so residual FINALIZE cannot sticky-fail.
132 pub ledger: Arc<std::sync::Mutex<ResourceLedger>>,
133}
134
135impl Lifecycle {
136 /// Create a fresh lifecycle for one process invocation.
137 pub fn new() -> Self {
138 let cancel = CancellationToken::new();
139 // try_borrow_mut: never panic if a re-entrant signal path already holds TLS.
140 CURRENT_CANCEL.with(|c| {
141 if let Ok(mut slot) = c.try_borrow_mut() {
142 *slot = Some(cancel.clone());
143 }
144 });
145 let lc = Self {
146 cancel,
147 finalize_done: Arc::new(AtomicBool::new(false)),
148 ledger: Arc::new(std::sync::Mutex::new(ResourceLedger::default())),
149 };
150 CURRENT_LIFE.with(|c| {
151 if let Ok(mut slot) = c.try_borrow_mut() {
152 *slot = Some(lc.clone());
153 }
154 });
155 lc.with_ledger_mut(|ledger| {
156 ledger.started_at = Some(std::time::SystemTime::now());
157 });
158 // RES-06: BORN cross-run GC — wipe stale Singleton-only /tmp orphans
159 // left by prior one-shot invocations (disk hygiene, not process kill).
160 // Best-effort; never panics; never touches host Flatpak Chrome profiles.
161 let wiped = crate::residual::scavenge_stale_singleton_orphans();
162 if !wiped.is_empty() {
163 tracing::debug!(
164 count = wiped.len(),
165 "lifecycle BORN scavenge_stale_singleton_orphans"
166 );
167 }
168 lc
169 }
170
171 /// Mutate the resource ledger under the mutex.
172 ///
173 /// # Poison policy
174 ///
175 /// Recovers via `PoisonError::into_inner` so a prior panic cannot prevent
176 /// residual kill/wipe (rules: treat `PoisonError`, never silent-skip FINALIZE).
177 pub fn with_ledger_mut<R>(&self, f: impl FnOnce(&mut ResourceLedger) -> R) -> R {
178 let mut guard = self.ledger.lock().unwrap_or_else(|poisoned| {
179 // Best-effort residual cleanup must not abort because of poison.
180 tracing::debug!("lifecycle ledger mutex poisoned; recovering via into_inner");
181 poisoned.into_inner()
182 });
183 f(&mut guard)
184 }
185
186 /// Record that this invocation launched Chrome (residual kill target).
187 pub fn record_chrome(&self, pid: Option<u32>) {
188 self.with_ledger_mut(|ledger| {
189 ledger.chrome_launched = true;
190 ledger.chrome_pid = pid;
191 });
192 }
193
194 /// Clear residual Chrome ownership after primary close reaped the child.
195 pub fn clear_chrome(&self) {
196 self.with_ledger_mut(|ledger| {
197 ledger.chrome_launched = false;
198 ledger.chrome_pid = None;
199 });
200 }
201
202 /// Clear Chrome + profile ledger after primary session shutdown.
203 pub fn clear_chrome_and_profile(&self) {
204 self.with_ledger_mut(|ledger| {
205 ledger.chrome_launched = false;
206 ledger.chrome_pid = None;
207 ledger.profile_dir = None;
208 ledger.side_channels.clear();
209 });
210 }
211
212 /// Whether cooperative cancel was requested (signal or finalize).
213 pub fn is_cancelled(&self) -> bool {
214 self.cancel.is_cancelled()
215 }
216
217 /// Idempotent FINALIZE: last-resort residual kill if chrome still flagged.
218 ///
219 /// Primary reap is `OneShotSession::shutdown` (Browser.close + wait_or_kill).
220 pub fn finalize(&self) {
221 // SeqCst CAS: total order across Drop, second-signal path, and explicit finalize.
222 if self
223 .finalize_done
224 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
225 .is_err()
226 {
227 return;
228 }
229 self.cancel.cancel();
230 self.with_ledger_mut(|ledger| {
231 // RES-01: copy pid BEFORE take so invocation-window scavenge can
232 // still attribute Chromium side-channels under /tmp.
233 let pid_for_scavenge = ledger.chrome_pid;
234 if ledger.chrome_launched {
235 if let Some(pid) = ledger.chrome_pid.take() {
236 residual_kill_child(pid, ledger.windows_job_handle);
237 #[cfg(windows)]
238 {
239 ledger.windows_job_handle = 0;
240 }
241 }
242 }
243 ledger.chrome_launched = false;
244 let profile = ledger.profile_dir.take();
245 // RES-05: re-scan side-channels while profile/pid are still known,
246 // then wipe ledger paths + discovery hits.
247 let not_before = ledger.started_at.unwrap_or_else(std::time::SystemTime::now);
248 let extras = crate::residual::discover_owned_chromium_tmp_side_channels(
249 profile.as_deref(),
250 pid_for_scavenge,
251 not_before,
252 );
253 for p in extras {
254 if !ledger.side_channels.iter().any(|e| e == &p) {
255 ledger.side_channels.push(p);
256 }
257 }
258 if let Some(ref dir) = profile {
259 wipe_owned_path(dir);
260 }
261 let sides = std::mem::take(&mut ledger.side_channels);
262 for p in sides {
263 wipe_owned_path(&p);
264 }
265 // GAP-A002: scavenge owned Chromium tmp orphans for this invocation window.
266 let _ = crate::residual::scavenge_owned_chromium_tmp_orphans(
267 profile.as_deref(),
268 pid_for_scavenge,
269 not_before,
270 );
271 // RES-02/RES-06: second pass cross-run GC catches late Singleton
272 // side-channels that never referenced profile/pid.
273 let wiped = crate::residual::scavenge_stale_singleton_orphans();
274 if !wiped.is_empty() {
275 tracing::debug!(
276 count = wiped.len(),
277 "lifecycle FINALIZE scavenge_stale_singleton_orphans"
278 );
279 }
280 #[cfg(windows)]
281 if ledger.windows_job_handle != 0 {
282 crate::win_job::close_job(ledger.windows_job_handle);
283 ledger.windows_job_handle = 0;
284 }
285 });
286 }
287}
288
289/// Last-resort kill of a child process we launched (Chrome).
290///
291/// Unix: `SIGTERM`, poll until dead or [`FINALIZE_CHILD_GRACE`], then `SIGKILL`.
292/// Windows: Job Object terminate + close when handle is set, else pid terminate.
293fn residual_kill_child(pid: u32, windows_job_handle: usize) {
294 #[cfg(unix)]
295 {
296 let _ = windows_job_handle;
297 kill_unix_graceful(pid, FINALIZE_CHILD_GRACE);
298 }
299 #[cfg(windows)]
300 {
301 if windows_job_handle != 0 {
302 crate::win_job::terminate_job(windows_job_handle);
303 crate::win_job::close_job(windows_job_handle);
304 } else {
305 crate::win_job::terminate_pid(pid);
306 }
307 }
308 #[cfg(not(any(unix, windows)))]
309 {
310 let _ = (pid, windows_job_handle);
311 }
312}
313
314/// Send SIGTERM, wait up to `grace` while the pid exists, then SIGKILL if needed.
315///
316/// Extracted for unit tests (does not require a live child when grace is zero
317/// and pid is invalid — kill returns ESRCH).
318#[cfg(unix)]
319pub fn kill_unix_graceful(pid: u32, grace: Duration) {
320 // SAFETY:
321 // - Contract: last-resort FINALIZE SIGTERM of Chrome launched by this process.
322 // - Invariant: `pid` was recorded when we spawned Chrome; cast fits on Unix.
323 // - Caller guarantees ownership of the child tree; failure is ignored (best-effort).
324 // - See: `man 2 kill`; product prefers Browser.close before this fallback.
325 unsafe {
326 let _ = libc::kill(pid as i32, libc::SIGTERM);
327 }
328
329 let deadline = Instant::now() + grace;
330 while Instant::now() < deadline {
331 // kill(pid, 0) probes existence without delivering a signal.
332 // SAFETY: same ownership as SIGTERM; ESRCH means process is gone.
333 let alive = unsafe { libc::kill(pid as i32, 0) == 0 };
334 if !alive {
335 return;
336 }
337 std::thread::sleep(Duration::from_millis(50));
338 }
339
340 // SAFETY: same pid ownership as SIGTERM; escalate to SIGKILL if still alive.
341 unsafe {
342 let _ = libc::kill(pid as i32, libc::SIGKILL);
343 }
344}
345
346/// Remove a file or directory tree owned by this process only.
347fn wipe_owned_path(path: &std::path::Path) {
348 if !path.exists() {
349 return;
350 }
351 if path.is_dir() {
352 let _ = std::fs::remove_dir_all(path);
353 } else {
354 let _ = std::fs::remove_file(path);
355 }
356}
357
358/// Record a temporary Chrome profile directory on the ledger for FINALIZE wipe.
359pub fn mark_profile_dir(life: &Lifecycle, dir: Option<PathBuf>) {
360 if let Some(dir) = dir {
361 life.with_ledger_mut(|ledger| {
362 ledger.profile_dir = Some(dir);
363 });
364 }
365}
366
367/// Record a side-channel path (SingletonLock, etc.) owned by this launch.
368pub fn mark_side_channel(life: &Lifecycle, path: PathBuf) {
369 life.with_ledger_mut(|ledger| {
370 if !ledger.side_channels.iter().any(|p| p == &path) {
371 ledger.side_channels.push(path);
372 }
373 });
374}
375
376impl Default for Lifecycle {
377 fn default() -> Self {
378 Self::new()
379 }
380}
381
382/// Safety net: if the call site forgets explicit [`Lifecycle::finalize`], Drop still
383/// runs residual cleanup (sync only: kill/wipe; no async I/O).
384///
385/// # Drop contract
386///
387/// - **Idempotent** via `finalize_done` (`SeqCst` CAS).
388/// - **Bounded**: residual Unix path may sleep up to [`FINALIZE_CHILD_GRACE`] (not
389/// indefinite; rules forbid unbounded block in Drop).
390/// - **No panic**: kill/wipe errors are ignored (best-effort).
391/// - Clones share the same Arcs; the first Drop that wins the CAS performs work.
392impl Drop for Lifecycle {
393 fn drop(&mut self) {
394 self.finalize();
395 }
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401
402 #[test]
403 fn finalize_is_idempotent() {
404 let lc = Lifecycle::new();
405 lc.finalize();
406 lc.finalize();
407 assert!(lc.finalize_done.load(Ordering::SeqCst));
408 }
409
410 #[test]
411 fn with_ledger_mut_recovers_from_poison() {
412 let lc = Lifecycle::new();
413 // Poison the mutex deliberately (simulates a prior panic while holding it).
414 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
415 let _guard = lc.ledger.lock().unwrap();
416 panic!("poison ledger for test");
417 }));
418 assert!(lc.ledger.is_poisoned());
419 // Recovery must still apply residual ownership updates.
420 lc.record_chrome(Some(42));
421 lc.with_ledger_mut(|ledger| {
422 assert!(ledger.chrome_launched);
423 assert_eq!(ledger.chrome_pid, Some(42));
424 });
425 lc.clear_chrome();
426 lc.with_ledger_mut(|ledger| {
427 assert!(!ledger.chrome_launched);
428 assert!(ledger.chrome_pid.is_none());
429 });
430 }
431
432 #[test]
433 fn current_cancel_tracks_active_lifecycle() {
434 let lc = Lifecycle::new();
435 assert!(!current_cancel().is_cancelled());
436 lc.cancel.cancel();
437 assert!(current_cancel().is_cancelled());
438 assert!(lc.is_cancelled());
439 }
440
441 #[test]
442 fn current_lifecycle_is_registered() {
443 let lc = Lifecycle::new();
444 let cur = current_lifecycle().expect("registered");
445 assert!(!cur.finalize_done.load(Ordering::SeqCst));
446 cur.cancel.cancel();
447 assert!(lc.is_cancelled());
448 }
449
450 #[test]
451 fn finalize_child_grace_is_bounded() {
452 // One-shot residual must stay short (agents expect fast DIE).
453 assert!(FINALIZE_CHILD_GRACE <= Duration::from_secs(5));
454 assert!(FINALIZE_CHILD_GRACE >= Duration::from_millis(100));
455 }
456
457 #[cfg(unix)]
458 #[test]
459 fn kill_unix_graceful_on_missing_pid_returns_quickly() {
460 // PID 1 is init and not ours; we only assert the helper does not hang
461 // when grace is tiny. Using an extremely unlikely high pid that is dead.
462 let start = Instant::now();
463 // First free pid-ish: use a large number that should not exist.
464 kill_unix_graceful(4_294_967_294, Duration::from_millis(0));
465 assert!(start.elapsed() < Duration::from_secs(1));
466 }
467}