Skip to main content

html2pdf_api/
handle.rs

1//! RAII handle for browser instances.
2//!
3//! This module provides [`BrowserHandle`], which wraps a browser instance
4//! and automatically returns it to the pool when dropped.
5//!
6//! # Overview
7//!
8//! The handle implements the RAII (Resource Acquisition Is Initialization)
9//! pattern to ensure browsers are always returned to the pool, even if:
10//! - Your code returns early
11//! - An error occurs
12//! - A panic happens
13//!
14//! # Usage Pattern
15//!
16//! ```rust,ignore
17//! use html2pdf_api::BrowserPool;
18//!
19//! let pool = BrowserPool::builder()
20//!     .factory(Box::new(ChromeBrowserFactory::with_defaults()))
21//!     .build()?;
22//!
23//! // Get a browser handle
24//! let browser = pool.get()?;
25//!
26//! // Use it like a regular Browser (via Deref)
27//! let tab = browser.new_tab()?;
28//! tab.navigate_to("https://example.com")?;
29//!
30//! // Browser automatically returned when `browser` goes out of scope
31//! ```
32//!
33//! # Deref Behavior
34//!
35//! `BrowserHandle` implements [`Deref<Target = Browser>`](std::ops::Deref),
36//! allowing transparent access to all [`Browser`] methods:
37//!
38//! ```rust,ignore
39//! let browser = pool.get()?;
40//!
41//! // These all work directly on the handle:
42//! let tab = browser.new_tab()?;           // Browser::new_tab
43//! let tabs = browser.get_tabs();          // Browser::get_tabs
44//! let version = browser.get_version()?;   // Browser::get_version
45//! ```
46
47use std::sync::Arc;
48
49use headless_chrome::Browser;
50
51use crate::pool::BrowserPoolInner;
52use crate::tracked::TrackedBrowser;
53
54/// RAII handle for browser instances.
55///
56/// Automatically returns the browser to the pool when dropped.
57/// This ensures browsers are always returned even if the code panics.
58///
59/// # Thread Safety
60///
61/// `BrowserHandle` is `Send` but not `Sync`. This means:
62/// - ✅ You can move it to another thread
63/// - ❌ You cannot share it between threads simultaneously
64///
65/// This matches the typical usage pattern where a single request/task
66/// uses a browser exclusively.
67///
68/// # Usage
69///
70/// ```rust,ignore
71/// let browser_handle = pool.get()?;
72///
73/// // Use browser via Deref
74/// let tab = browser_handle.new_tab()?;
75/// // ... do work ...
76///
77/// // Browser automatically returned to pool when handle goes out of scope
78/// ```
79///
80/// # Explicit Drop
81///
82/// If you need to return the browser early (before end of scope),
83/// you can explicitly drop the handle:
84///
85/// ```rust,ignore
86/// let browser = pool.get()?;
87/// let tab = browser.new_tab()?;
88/// // ... do work ...
89///
90/// // Return browser early
91/// drop(browser);
92///
93/// // Browser is now back in the pool and available for others
94/// // Attempting to use `browser` here would be a compile error
95/// ```
96///
97/// # Panic Safety
98///
99/// The RAII pattern ensures browsers are returned even during panics:
100///
101/// ```rust,ignore
102/// let browser = pool.get()?;
103///
104/// // Even if this panics...
105/// some_function_that_might_panic();
106///
107/// // ...the browser is still returned to the pool during unwinding
108/// ```
109pub struct BrowserHandle {
110    /// The tracked browser (Option allows taking in Drop).
111    ///
112    /// This is `Option` so we can `take()` it in the `Drop` implementation
113    /// without requiring `&mut self` to be valid after drop.
114    tracked: Option<Arc<TrackedBrowser>>,
115
116    /// Reference to pool for returning browser.
117    ///
118    /// We keep an `Arc` reference to the pool's inner state so we can
119    /// return the browser even if the original `BrowserPool` has been dropped.
120    pool: Arc<BrowserPoolInner>,
121}
122
123impl BrowserHandle {
124    /// Create a new browser handle.
125    ///
126    /// This is called internally by [`BrowserPool::get()`](crate::BrowserPool::get).
127    /// Users should not need to call this directly.
128    ///
129    /// # Parameters
130    ///
131    /// * `tracked` - The tracked browser instance.
132    /// * `pool` - Arc reference to the pool's inner state.
133    pub(crate) fn new(tracked: Arc<TrackedBrowser>, pool: Arc<BrowserPoolInner>) -> Self {
134        Self {
135            tracked: Some(tracked),
136            pool,
137        }
138    }
139
140    /// Get the browser's unique ID.
141    ///
142    /// Useful for logging and debugging.
143    ///
144    /// # Returns
145    ///
146    /// The unique ID assigned to this browser instance.
147    ///
148    /// # Example
149    ///
150    /// ```rust,ignore
151    /// let browser = pool.get()?;
152    /// log::info!("Using browser {}", browser.id());
153    /// ```
154    pub fn id(&self) -> u64 {
155        self.tracked.as_ref().map(|t| t.id()).unwrap_or(0)
156    }
157
158    /// Get the browser's age (time since creation).
159    ///
160    /// Useful for monitoring and debugging.
161    ///
162    /// # Returns
163    ///
164    /// Duration since the browser was created.
165    ///
166    /// # Example
167    ///
168    /// ```rust,ignore
169    /// let browser = pool.get()?;
170    /// log::debug!("Browser age: {:?}", browser.age());
171    /// ```
172    pub fn age(&self) -> std::time::Duration {
173        self.tracked.as_ref().map(|t| t.age()).unwrap_or_default()
174    }
175
176    /// Get the browser's age in minutes.
177    ///
178    /// Convenience method for human-readable logging.
179    ///
180    /// # Example
181    ///
182    /// ```rust,ignore
183    /// let browser = pool.get()?;
184    /// log::info!("Browser {} is {} minutes old", browser.id(), browser.age_minutes());
185    /// ```
186    pub fn age_minutes(&self) -> u64 {
187        self.tracked.as_ref().map(|t| t.age_minutes()).unwrap_or(0)
188    }
189
190    /// Mark this browser instance as permanently unhealthy.
191    ///
192    /// This should be called if a critical internal Chrome operation fails.
193    /// Unhealthy browsers will be evicted from the pool and replaced
194    /// when this handle is dropped.
195    pub fn mark_unhealthy(&self) {
196        if let Some(tracked) = &self.tracked {
197            tracked.mark_unhealthy();
198        }
199    }
200}
201
202impl std::ops::Deref for BrowserHandle {
203    type Target = Browser;
204
205    /// Transparently access the underlying Browser.
206    ///
207    /// This allows using all [`Browser`] methods directly on the handle:
208    ///
209    /// ```rust,ignore
210    /// let browser = pool.get()?;
211    ///
212    /// // new_tab() is a Browser method, but works on BrowserHandle
213    /// let tab = browser.new_tab()?;
214    /// ```
215    ///
216    /// # Panics
217    ///
218    /// Panics if called after the browser has been returned to the pool.
219    /// This should never happen in normal usage since the handle owns
220    /// the browser until it's dropped.
221    fn deref(&self) -> &Self::Target {
222        self.tracked.as_ref().unwrap().browser()
223    }
224}
225
226impl Drop for BrowserHandle {
227    /// Automatically return browser to pool when handle is dropped.
228    ///
229    /// This is the critical RAII pattern that ensures browsers are always
230    /// returned to the pool, even if the code using them panics.
231    ///
232    /// # Implementation Details
233    ///
234    /// - Uses `Option::take()` to move the browser out of the handle
235    /// - Calls `BrowserPoolInner::return_browser()` to return it
236    /// - Safe to call multiple times (subsequent calls are no-ops)
237    fn drop(&mut self) {
238        if let Some(tracked) = self.tracked.take() {
239            log::debug!(
240                "♻️ BrowserHandle {} being dropped, returning to pool...",
241                tracked.id()
242            );
243
244            // Return to pool using static method (avoids &mut self issues)
245            BrowserPoolInner::return_browser(&self.pool, tracked);
246        }
247    }
248}
249
250impl std::fmt::Debug for BrowserHandle {
251    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        match &self.tracked {
253            Some(tracked) => f
254                .debug_struct("BrowserHandle")
255                .field("id", &tracked.id())
256                .field("age_minutes", &tracked.age_minutes())
257                .finish(),
258            None => f
259                .debug_struct("BrowserHandle")
260                .field("state", &"returned")
261                .finish(),
262        }
263    }
264}
265
266// ============================================================================
267// Unit Tests
268// ============================================================================
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use crate::config::BrowserPoolConfig;
274    use crate::factory::mock::MockBrowserFactory;
275    use crate::pool::BrowserPoolInner;
276
277    fn create_test_pool_inner() -> Arc<BrowserPoolInner> {
278        Arc::new(BrowserPoolInner::new_for_test(
279            BrowserPoolConfig::default(),
280            Box::new(MockBrowserFactory::always_fails("test")),
281            tokio::runtime::Handle::current(),
282        ))
283    }
284
285    /// Verifies that BrowserHandle methods return graceful fallbacks when empty (post-drop).
286    #[tokio::test]
287    async fn test_handle_id_returns_zero_when_tracked_is_none() {
288        let handle = BrowserHandle {
289            tracked: None,
290            pool: create_test_pool_inner(),
291        };
292        assert_eq!(handle.id(), 0);
293        assert_eq!(handle.age_minutes(), 0);
294        assert_eq!(handle.age(), std::time::Duration::default());
295    }
296
297    /// Verifies that Debug is formatted safely for a returned handle.
298    #[tokio::test]
299    async fn test_handle_debug_when_returned() {
300        let handle = BrowserHandle {
301            tracked: None,
302            pool: create_test_pool_inner(),
303        };
304        let debug_output = format!("{:?}", handle);
305        assert!(debug_output.contains("returned"));
306        assert!(!debug_output.contains("age_minutes"));
307    }
308
309    /// Verifies Debug incorporates age if active, although requires real chrome dependencies to fully run locally.
310    #[test]
311    #[ignore = "Requires launching a real Chrome process for TrackedBrowser initialization"]
312    fn test_handle_debug_when_active() {
313        // Assertions verifying that `Debug` includes `age_minutes`
314        // when `tracked: Some(...)` would live here.
315    }
316}