Skip to main content

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}