Skip to main content

oxicode_agent/tools/browse/
tab_guard.rs

1//! RAII guard that ensures a browser tab is properly closed.
2//!
3//! Prevents tab leaks by tracking lifecycle and warning on implicit drops.
4//! Use `TabGuard::close().await` for explicit async close, or `into_inner()`
5//! to transfer ownership.
6
7use super::engine::BrowserTab;
8
9/// RAII wrapper around `Box<dyn BrowserTab>`.
10///
11/// # Leak Prevention
12///
13/// If dropped without calling [`close`](TabGuard::close) or
14/// [`into_inner`](TabGuard::into_inner), a `tracing::warn` is emitted.
15/// Since Rust's `Drop` cannot be async, the tab itself cannot be closed
16/// synchronously — always prefer `guard.close().await` before the guard
17/// goes out of scope.
18///
19/// # Example
20///
21/// ```ignore
22/// let guard = TabGuard::new(engine.new_tab().await?);
23/// let page = guard.tab().goto(url).await?;
24/// // ... use tab ...
25/// guard.close().await; // explicit async close
26/// ```
27pub struct TabGuard {
28    tab: Option<Box<dyn BrowserTab>>,
29    explicitly_consumed: bool,
30}
31
32impl TabGuard {
33    /// Create a new guard wrapping an opened tab.
34    pub fn new(tab: Box<dyn BrowserTab>) -> Self {
35        Self {
36            tab: Some(tab),
37            explicitly_consumed: false,
38        }
39    }
40
41    /// Access the underlying tab reference.
42    ///
43    /// # Panics
44    ///
45    /// Panics if the guard has already been consumed (via `close` or `into_inner`).
46    pub fn tab(&self) -> &dyn BrowserTab {
47        // SAFETY: `as_ref().map(...)` returns None only after this guard was
48        // already consumed (a use-after-consume bug); the guard contract
49        // forbids it.
50        #[allow(clippy::expect_used)]
51        self.tab
52            .as_ref()
53            .map(|t| t.as_ref() as &dyn BrowserTab)
54            .expect("TabGuard: tab already consumed")
55    }
56
57    /// Explicitly close the tab and consume the guard.
58    ///
59    /// If `close()` fails on the underlying tab, a warning is logged but
60    /// no error is propagated — the guard is still consumed.
61    pub async fn close(mut self) {
62        self.explicitly_consumed = true;
63        if let Some(tab) = self.tab.take() {
64            tab.clear_progress_callback();
65            if let Err(e) = tab.close().await {
66                tracing::warn!("TabGuard: tab close failed: {}", e);
67            }
68        }
69    }
70
71    /// Take ownership of the tab without closing it.
72    ///
73    /// Useful when transferring tab ownership to a longer-lived scope
74    /// (e.g., multi-step script execution).
75    pub fn into_inner(mut self) -> Box<dyn BrowserTab> {
76        self.explicitly_consumed = true;
77        // SAFETY: `take()` returns None only after this guard was already
78        // consumed (a use-after-consume bug); the guard contract forbids it.
79        #[allow(clippy::expect_used)]
80        self.tab.take().expect("TabGuard: tab already consumed")
81    }
82}
83
84impl Drop for TabGuard {
85    fn drop(&mut self) {
86        if !self.explicitly_consumed {
87            tracing::warn!(
88                "TabGuard dropped without explicit close — tab may leak. \
89                 Call .close().await or .into_inner() to prevent this."
90            );
91        }
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::tools::browse::engine::{BrowserError, PageContent};
99    use async_trait::async_trait;
100    use serde_json::Value;
101    use std::sync::Arc;
102    use std::sync::atomic::{AtomicBool, Ordering};
103
104    // ── Mock tab for unit tests ─────────────────────────────────
105
106    struct MockTab {
107        closed: Arc<AtomicBool>,
108    }
109
110    impl MockTab {
111        fn new() -> (Self, Arc<AtomicBool>) {
112            let closed = Arc::new(AtomicBool::new(false));
113            (
114                Self {
115                    closed: closed.clone(),
116                },
117                closed,
118            )
119        }
120    }
121
122    #[async_trait]
123    impl BrowserTab for MockTab {
124        async fn goto(&self, _url: &str) -> Result<PageContent, BrowserError> {
125            Ok(PageContent::empty())
126        }
127        async fn click(&self, _selector: &str) -> Result<(), BrowserError> {
128            Ok(())
129        }
130        async fn type_(&self, _selector: &str, _text: &str) -> Result<(), BrowserError> {
131            Ok(())
132        }
133        async fn fill(&self, _selector: &str, _value: &str) -> Result<(), BrowserError> {
134            Ok(())
135        }
136        async fn press(&self, _combo: &str) -> Result<(), BrowserError> {
137            Ok(())
138        }
139        async fn wait_for(&self, _selector: &str, _timeout_ms: u64) -> Result<(), BrowserError> {
140            Ok(())
141        }
142        async fn content(&self) -> Result<PageContent, BrowserError> {
143            Ok(PageContent::empty())
144        }
145        async fn query_all(&self, _selector: &str) -> Result<Vec<String>, BrowserError> {
146            Ok(vec![])
147        }
148        async fn evaluate(&self, _js: &str) -> Result<Value, BrowserError> {
149            Ok(Value::Null)
150        }
151        async fn screenshot(&self, _width: u32) -> Result<Vec<u8>, BrowserError> {
152            Ok(vec![])
153        }
154        async fn close(&self) -> Result<(), BrowserError> {
155            self.closed.store(true, Ordering::SeqCst);
156            Ok(())
157        }
158        async fn back(&self) -> Result<PageContent, BrowserError> {
159            Ok(PageContent::empty())
160        }
161        async fn forward(&self) -> Result<PageContent, BrowserError> {
162            Ok(PageContent::empty())
163        }
164        async fn reload(&self) -> Result<PageContent, BrowserError> {
165            Ok(PageContent::empty())
166        }
167        async fn select_option(&self, _selector: &str, _value: &str) -> Result<(), BrowserError> {
168            Ok(())
169        }
170        async fn check(&self, _selector: &str) -> Result<(), BrowserError> {
171            Ok(())
172        }
173        async fn uncheck(&self, _selector: &str) -> Result<(), BrowserError> {
174            Ok(())
175        }
176        async fn clear(&self, _selector: &str) -> Result<(), BrowserError> {
177            Ok(())
178        }
179        async fn hover(&self, _selector: &str) -> Result<(), BrowserError> {
180            Ok(())
181        }
182        async fn double_click(&self, _selector: &str) -> Result<(), BrowserError> {
183            Ok(())
184        }
185        async fn right_click(&self, _selector: &str) -> Result<(), BrowserError> {
186            Ok(())
187        }
188        async fn scroll(&self, _delta_x: f64, _delta_y: f64) -> Result<(), BrowserError> {
189            Ok(())
190        }
191        async fn scroll_into_view(&self, _selector: &str) -> Result<(), BrowserError> {
192            Ok(())
193        }
194        async fn drag(&self, _from: &str, _to: &str) -> Result<(), BrowserError> {
195            Ok(())
196        }
197        async fn upload_file(&self, _selector: &str, _path: &str) -> Result<(), BrowserError> {
198            Ok(())
199        }
200        async fn get_value(&self, _selector: &str) -> Result<String, BrowserError> {
201            Ok(String::new())
202        }
203        async fn evaluate_await(&self, _js: &str) -> Result<Value, BrowserError> {
204            Ok(Value::Null)
205        }
206        fn is_closed(&self) -> bool {
207            self.closed.load(Ordering::SeqCst)
208        }
209    }
210
211    // ── Tests ────────────────────────────────────────────────────
212
213    #[tokio::test]
214    async fn test_guard_close_success() {
215        let (mock, closed_flag) = MockTab::new();
216        let guard = TabGuard::new(Box::new(mock));
217        assert!(!closed_flag.load(Ordering::SeqCst));
218        guard.close().await;
219        assert!(closed_flag.load(Ordering::SeqCst));
220    }
221
222    #[tokio::test]
223    async fn test_guard_into_inner() {
224        let (mock, closed_flag) = MockTab::new();
225        let guard = TabGuard::new(Box::new(mock));
226        let _tab = guard.into_inner();
227        // Tab not closed — ownership transferred
228        assert!(!closed_flag.load(Ordering::SeqCst));
229    }
230
231    #[test]
232    fn test_guard_drop_without_close_warns() {
233        let (mock, _) = MockTab::new();
234        let guard = TabGuard::new(Box::new(mock));
235        // Drop without close — should log warning but not panic
236        drop(guard);
237    }
238
239    #[tokio::test]
240    async fn test_guard_tab_access() {
241        let (mock, _) = MockTab::new();
242        let guard = TabGuard::new(Box::new(mock));
243        // Should be able to access the tab
244        let result = guard.tab().goto("https://example.com").await;
245        assert!(result.is_ok());
246        guard.close().await;
247    }
248}