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}