Skip to main content

browser_automation_cli/
lifecycle.rs

1//! One-shot lifecycle: INIT → EXECUTE → FINALIZE → EXIT.
2//!
3//! # Flow
4//!
5//! 1. `Lifecycle::new` creates cancel token and resource ledger
6//! 2. Commands may mark Chrome PID / profile in `ResourceLedger`
7//! 3. `Lifecycle::finalize` is idempotent and safe to call multiple times
8//! 4. `Drop` also calls finalize as a safety net
9//!
10//! # Safety
11//!
12//! On Unix, finalize may send `SIGTERM`/`SIGKILL` to a recorded Chrome PID as
13//! last resort when primary Browser.close reap did not clear the ledger.
14//! Profile dirs and Chrome Singleton side-channels are wiped **only** when
15//! recorded in this ledger (never a host-wide chrome wipe).
16
17use std::path::PathBuf;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::Arc;
20
21use tokio_util::sync::CancellationToken;
22
23/// Ledger of resources owned by this invocation (browser, profile, temp dirs).
24#[derive(Debug, Default)]
25pub struct ResourceLedger {
26    /// True when this process launched Chrome and still owns the residual flag.
27    pub chrome_launched: bool,
28    /// Optional OS process id of launched Chrome for last-resort kill.
29    pub chrome_pid: Option<u32>,
30    /// Temporary profile directory created for this invocation, if any.
31    pub profile_dir: Option<PathBuf>,
32    /// Side-channel paths owned by this launch (e.g. SingletonLock under /tmp).
33    pub side_channels: Vec<PathBuf>,
34    /// Windows Job Object handle as usize (cfg windows); zero when unused.
35    pub windows_job_handle: usize,
36    /// Wall-clock start of this invocation (for FINALIZE scavenger window).
37    pub started_at: Option<std::time::SystemTime>,
38}
39
40/// Runtime token for cooperative cancel and FINALIZE.
41#[derive(Clone)]
42pub struct Lifecycle {
43    /// Cancellation token shared with async tasks.
44    pub cancel: CancellationToken,
45    /// Whether FINALIZE already completed.
46    pub finalize_done: Arc<AtomicBool>,
47    /// Owned resources for residual cleanup.
48    pub ledger: Arc<std::sync::Mutex<ResourceLedger>>,
49}
50
51impl Lifecycle {
52    /// Create a fresh lifecycle for one process invocation.
53    pub fn new() -> Self {
54        let lc = Self {
55            cancel: CancellationToken::new(),
56            finalize_done: Arc::new(AtomicBool::new(false)),
57            ledger: Arc::new(std::sync::Mutex::new(ResourceLedger::default())),
58        };
59        if let Ok(mut ledger) = lc.ledger.lock() {
60            ledger.started_at = Some(std::time::SystemTime::now());
61        }
62        lc
63    }
64
65    /// Idempotent FINALIZE: last-resort residual kill if chrome still flagged.
66    ///
67    /// Primary reap is `OneShotSession::shutdown` (Browser.close + wait_or_kill).
68    pub fn finalize(&self) {
69        if self
70            .finalize_done
71            .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
72            .is_err()
73        {
74            return;
75        }
76        self.cancel.cancel();
77        if let Ok(mut ledger) = self.ledger.lock() {
78            if ledger.chrome_launched {
79                if let Some(pid) = ledger.chrome_pid.take() {
80                    #[cfg(unix)]
81                    unsafe {
82                        let _ = libc::kill(pid as i32, libc::SIGTERM);
83                        let _ = libc::kill(pid as i32, libc::SIGKILL);
84                    }
85                    #[cfg(windows)]
86                    {
87                        // Prefer Job Object kill when available (GAP-009).
88                        if ledger.windows_job_handle != 0 {
89                            crate::win_job::terminate_job(ledger.windows_job_handle);
90                            ledger.windows_job_handle = 0;
91                        } else {
92                            crate::win_job::terminate_pid(pid);
93                        }
94                    }
95                }
96            }
97            ledger.chrome_launched = false;
98            let profile = ledger.profile_dir.take();
99            if let Some(ref dir) = profile {
100                wipe_owned_path(dir);
101            }
102            let sides = std::mem::take(&mut ledger.side_channels);
103            for p in sides {
104                wipe_owned_path(&p);
105            }
106            // GAP-A002: scavenge owned Chromium tmp orphans for this invocation window.
107            let not_before = ledger.started_at.unwrap_or_else(std::time::SystemTime::now);
108            let chrome_pid = ledger.chrome_pid; // already taken above; None here is fine
109            let _ = crate::residual::scavenge_owned_chromium_tmp_orphans(
110                profile.as_deref(),
111                chrome_pid,
112                not_before,
113            );
114            #[cfg(windows)]
115            if ledger.windows_job_handle != 0 {
116                crate::win_job::close_job(ledger.windows_job_handle);
117                ledger.windows_job_handle = 0;
118            }
119        }
120    }
121}
122
123/// Remove a file or directory tree owned by this process only.
124fn wipe_owned_path(path: &std::path::Path) {
125    if !path.exists() {
126        return;
127    }
128    if path.is_dir() {
129        let _ = std::fs::remove_dir_all(path);
130    } else {
131        let _ = std::fs::remove_file(path);
132    }
133}
134
135/// Record a temporary Chrome profile directory on the ledger for FINALIZE wipe.
136pub fn mark_profile_dir(life: &Lifecycle, dir: Option<PathBuf>) {
137    if let Some(dir) = dir {
138        if let Ok(mut ledger) = life.ledger.lock() {
139            ledger.profile_dir = Some(dir);
140        }
141    }
142}
143
144/// Record a side-channel path (SingletonLock, etc.) owned by this launch.
145pub fn mark_side_channel(life: &Lifecycle, path: PathBuf) {
146    if let Ok(mut ledger) = life.ledger.lock() {
147        if !ledger.side_channels.iter().any(|p| p == &path) {
148            ledger.side_channels.push(path);
149        }
150    }
151}
152
153impl Default for Lifecycle {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159impl Drop for Lifecycle {
160    fn drop(&mut self) {
161        self.finalize();
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn finalize_is_idempotent() {
171        let lc = Lifecycle::new();
172        lc.finalize();
173        lc.finalize();
174        assert!(lc.finalize_done.load(Ordering::SeqCst));
175    }
176}