stygian_browser/pool.rs
1//! Browser instance pool with warmup, health checks, and idle eviction
2//!
3//! # Architecture
4//!
5//! ```text
6//! ┌──────────────────────────────────────────────────────┐
7//! │ BrowserPool │
8//! │ │
9//! │ Semaphore (max_size slots) │
10//! │ ┌──────────────────────────────────────────────┐ │
11//! │ │ idle: VecDeque<PoolEntry> │ │
12//! │ │ entry: { instance, last_used: Instant } │ │
13//! │ └──────────────────────────────────────────────┘ │
14//! │ active_count: Arc<AtomicUsize> │
15//! └──────────────────────────────────────────────────────┘
16//! ```
17//!
18//! **Acquisition flow**
19//! 1. Try to pop a healthy idle entry.
20//! 2. If none idle and `active < max_size`, launch a fresh `BrowserInstance`.
21//! 3. Otherwise wait up to `acquire_timeout` for an idle slot.
22//!
23//! **Release flow**
24//! 1. Run a health-check on the returned instance.
25//! 2. If healthy and `idle < max_size`, push it back to the idle queue.
26//! 3. Otherwise shut it down and decrement the active counter.
27//!
28//! # Example
29//!
30//! ```no_run
31//! use stygian_browser::{BrowserConfig, BrowserPool};
32//!
33//! # async fn run() -> stygian_browser::error::Result<()> {
34//! let config = BrowserConfig::default();
35//! let pool = BrowserPool::new(config).await?;
36//!
37//! let stats = pool.stats();
38//! println!("Pool ready — idle: {}", stats.idle);
39//!
40//! let handle = pool.acquire().await?;
41//! handle.release().await;
42//! # Ok(())
43//! # }
44//! ```
45
46use std::sync::{
47 Arc,
48 atomic::{AtomicUsize, Ordering},
49};
50use std::time::Instant;
51
52use tokio::sync::{Mutex, Semaphore};
53use tokio::time::{sleep, timeout};
54use tracing::{debug, info, warn};
55
56use crate::{
57 BrowserConfig,
58 browser::BrowserInstance,
59 error::{BrowserError, Result},
60};
61
62// ─── PoolEntry ────────────────────────────────────────────────────────────────
63
64struct PoolEntry {
65 instance: BrowserInstance,
66 last_used: Instant,
67}
68
69// ─── PoolInner ────────────────────────────────────────────────────────────────
70
71struct PoolInner {
72 idle: std::collections::VecDeque<PoolEntry>,
73}
74
75// ─── BrowserPool ──────────────────────────────────────────────────────────────
76
77/// Thread-safe pool of reusable [`BrowserInstance`]s.
78///
79/// Maintains a warm set of idle browsers ready for immediate acquisition
80/// (`<100ms`), and lazily launches new instances when demand spikes.
81///
82/// # Example
83///
84/// ```no_run
85/// use stygian_browser::{BrowserConfig, BrowserPool};
86///
87/// # async fn run() -> stygian_browser::error::Result<()> {
88/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
89/// let handle = pool.acquire().await?;
90/// handle.release().await;
91/// # Ok(())
92/// # }
93/// ```
94pub struct BrowserPool {
95 config: Arc<BrowserConfig>,
96 semaphore: Arc<Semaphore>,
97 inner: Arc<Mutex<PoolInner>>,
98 active_count: Arc<AtomicUsize>,
99 max_size: usize,
100}
101
102impl BrowserPool {
103 /// Create a new pool and pre-warm `config.pool.min_size` browser instances.
104 ///
105 /// Warmup failures are logged but not fatal — the pool will start smaller
106 /// and grow lazily.
107 ///
108 /// # Example
109 ///
110 /// ```no_run
111 /// use stygian_browser::{BrowserPool, BrowserConfig};
112 ///
113 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
114 /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
115 /// # Ok(())
116 /// # }
117 /// ```
118 pub async fn new(config: BrowserConfig) -> Result<Arc<Self>> {
119 let max_size = config.pool.max_size;
120 let min_size = config.pool.min_size;
121
122 let pool = Self {
123 config: Arc::new(config),
124 semaphore: Arc::new(Semaphore::new(max_size)),
125 inner: Arc::new(Mutex::new(PoolInner {
126 idle: std::collections::VecDeque::new(),
127 })),
128 active_count: Arc::new(AtomicUsize::new(0)),
129 max_size,
130 };
131
132 // Warmup: pre-launch min_size instances
133 info!("Warming browser pool: min_size={min_size}, max_size={max_size}");
134 for i in 0..min_size {
135 match BrowserInstance::launch((*pool.config).clone()).await {
136 Ok(instance) => {
137 pool.active_count.fetch_add(1, Ordering::Relaxed);
138 pool.inner.lock().await.idle.push_back(PoolEntry {
139 instance,
140 last_used: Instant::now(),
141 });
142 debug!("Warmed browser {}/{min_size}", i + 1);
143 }
144 Err(e) => {
145 warn!("Warmup browser {i} failed (non-fatal): {e}");
146 }
147 }
148 }
149
150 // Spawn idle-eviction task
151 let eviction_inner = pool.inner.clone();
152 let eviction_active = pool.active_count.clone();
153 let idle_timeout = pool.config.pool.idle_timeout;
154 let eviction_min = min_size;
155
156 tokio::spawn(async move {
157 loop {
158 sleep(idle_timeout / 2).await;
159
160 let mut guard = eviction_inner.lock().await;
161 let now = Instant::now();
162 let idle_count = guard.idle.len();
163 let active = eviction_active.load(Ordering::Relaxed);
164
165 let evict_count = if active > eviction_min {
166 (active - eviction_min).min(idle_count)
167 } else {
168 0
169 };
170
171 let mut evicted = 0usize;
172 let mut kept: std::collections::VecDeque<PoolEntry> =
173 std::collections::VecDeque::new();
174
175 while let Some(entry) = guard.idle.pop_front() {
176 if evicted < evict_count && now.duration_since(entry.last_used) >= idle_timeout
177 {
178 // Drop entry — BrowserInstance shutdown happens in background
179 tokio::spawn(async move {
180 let _ = entry.instance.shutdown().await;
181 });
182 eviction_active.fetch_sub(1, Ordering::Relaxed);
183 evicted += 1;
184 } else {
185 kept.push_back(entry);
186 }
187 }
188
189 guard.idle = kept;
190 drop(guard);
191
192 if evicted > 0 {
193 info!("Evicted {evicted} idle browsers (idle_timeout={idle_timeout:?})");
194 }
195 }
196 });
197
198 Ok(Arc::new(pool))
199 }
200
201 // ─── Acquire ──────────────────────────────────────────────────────────────
202
203 /// Acquire a browser handle from the pool.
204 ///
205 /// - If a healthy idle browser is available it is returned immediately.
206 /// - If `active < max_size` a new browser is launched.
207 /// - Otherwise waits up to `pool.acquire_timeout`.
208 ///
209 /// # Errors
210 ///
211 /// Returns [`BrowserError::PoolExhausted`] if no browser becomes available
212 /// within `pool.acquire_timeout`.
213 ///
214 /// # Example
215 ///
216 /// ```no_run
217 /// use stygian_browser::{BrowserPool, BrowserConfig};
218 ///
219 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
220 /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
221 /// let handle = pool.acquire().await?;
222 /// handle.release().await;
223 /// # Ok(())
224 /// # }
225 /// ```
226 pub async fn acquire(self: &Arc<Self>) -> Result<BrowserHandle> {
227 #[cfg(feature = "metrics")]
228 let acquire_start = std::time::Instant::now();
229
230 let result = self.acquire_impl().await;
231
232 #[cfg(feature = "metrics")]
233 {
234 let elapsed = acquire_start.elapsed();
235 crate::metrics::METRICS.record_acquisition(elapsed);
236 crate::metrics::METRICS.set_pool_size(
237 i64::try_from(self.active_count.load(Ordering::Relaxed)).unwrap_or(i64::MAX),
238 );
239 }
240
241 result
242 }
243
244 async fn acquire_impl(self: &Arc<Self>) -> Result<BrowserHandle> {
245 let acquire_timeout = self.config.pool.acquire_timeout;
246 let active = self.active_count.load(Ordering::Relaxed);
247 let max = self.max_size;
248
249 // Fast path: try idle queue first
250 {
251 let mut guard = self.inner.lock().await;
252 while let Some(entry) = guard.idle.pop_front() {
253 if entry.instance.is_healthy_cached() {
254 self.active_count.fetch_add(0, Ordering::Relaxed); // already counted
255 debug!(
256 "Reusing idle browser (uptime={:?})",
257 entry.instance.uptime()
258 );
259 return Ok(BrowserHandle::new(entry.instance, Arc::clone(self)));
260 }
261 // Unhealthy idle entry — dispose in background
262 #[cfg(feature = "metrics")]
263 crate::metrics::METRICS.record_crash();
264 let active_count = self.active_count.clone();
265 tokio::spawn(async move {
266 let _ = entry.instance.shutdown().await;
267 active_count.fetch_sub(1, Ordering::Relaxed);
268 });
269 }
270 }
271
272 // Slow path: launch new or wait
273 if active < max {
274 // Acquire semaphore permit (non-blocking since active < max)
275 // Inline permit — no named binding to avoid significant_drop_tightening
276 timeout(acquire_timeout, self.semaphore.acquire())
277 .await
278 .map_err(|_| BrowserError::PoolExhausted { active, max })?
279 .map_err(|_| BrowserError::PoolExhausted { active, max })?
280 .forget(); // We track capacity manually via active_count
281 self.active_count.fetch_add(1, Ordering::Relaxed);
282
283 let instance = match BrowserInstance::launch((*self.config).clone()).await {
284 Ok(i) => i,
285 Err(e) => {
286 self.active_count.fetch_sub(1, Ordering::Relaxed);
287 self.semaphore.add_permits(1);
288 return Err(e);
289 }
290 };
291
292 info!(
293 "Launched fresh browser (pool active={})",
294 self.active_count.load(Ordering::Relaxed)
295 );
296 return Ok(BrowserHandle::new(instance, Arc::clone(self)));
297 }
298
299 // Pool full — wait for a release
300 timeout(acquire_timeout, async {
301 loop {
302 sleep(std::time::Duration::from_millis(50)).await;
303 let mut guard = self.inner.lock().await;
304 if let Some(entry) = guard.idle.pop_front() {
305 drop(guard);
306 if entry.instance.is_healthy_cached() {
307 return Ok(BrowserHandle::new(entry.instance, Arc::clone(self)));
308 }
309 #[cfg(feature = "metrics")]
310 crate::metrics::METRICS.record_crash();
311 let active_count = self.active_count.clone();
312 tokio::spawn(async move {
313 let _ = entry.instance.shutdown().await;
314 active_count.fetch_sub(1, Ordering::Relaxed);
315 });
316 }
317 }
318 })
319 .await
320 .map_err(|_| BrowserError::PoolExhausted { active, max })?
321 }
322
323 // ─── Release ──────────────────────────────────────────────────────────────
324
325 /// Return a browser instance to the pool (called by [`BrowserHandle::release`]).
326 async fn release(&self, instance: BrowserInstance) {
327 // Health-check before returning to idle queue
328 if instance.is_healthy_cached() {
329 let mut guard = self.inner.lock().await;
330 if guard.idle.len() < self.max_size {
331 guard.idle.push_back(PoolEntry {
332 instance,
333 last_used: Instant::now(),
334 });
335 debug!("Returned browser to idle pool");
336 return;
337 }
338 drop(guard);
339 }
340
341 // Unhealthy or pool full — dispose
342 #[cfg(feature = "metrics")]
343 if !instance.is_healthy_cached() {
344 crate::metrics::METRICS.record_crash();
345 }
346 let active_count = self.active_count.clone();
347 tokio::spawn(async move {
348 let _ = instance.shutdown().await;
349 active_count.fetch_sub(1, Ordering::Relaxed);
350 });
351
352 self.semaphore.add_permits(1);
353 }
354
355 // ─── Stats ────────────────────────────────────────────────────────────────
356
357 /// Snapshot of current pool metrics.
358 ///
359 /// # Example
360 ///
361 /// ```no_run
362 /// use stygian_browser::{BrowserPool, BrowserConfig};
363 ///
364 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
365 /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
366 /// let s = pool.stats();
367 /// println!("active={} idle={} max={}", s.active, s.idle, s.max);
368 /// # Ok(())
369 /// # }
370 /// ```
371 pub fn stats(&self) -> PoolStats {
372 PoolStats {
373 active: self.active_count.load(Ordering::Relaxed),
374 max: self.max_size,
375 available: self
376 .max_size
377 .saturating_sub(self.active_count.load(Ordering::Relaxed)),
378 idle: 0, // approximate — would need lock; kept lock-free for perf
379 }
380 }
381}
382
383// ─── BrowserHandle ────────────────────────────────────────────────────────────
384
385/// An acquired browser from the pool.
386///
387/// Call [`BrowserHandle::release`] after use to return the instance to the
388/// idle queue. If dropped without releasing, the browser is shut down and the
389/// pool slot freed.
390pub struct BrowserHandle {
391 instance: Option<BrowserInstance>,
392 pool: Arc<BrowserPool>,
393}
394
395impl BrowserHandle {
396 const fn new(instance: BrowserInstance, pool: Arc<BrowserPool>) -> Self {
397 Self {
398 instance: Some(instance),
399 pool,
400 }
401 }
402
403 /// Borrow the underlying [`BrowserInstance`].
404 ///
405 /// Returns `None` if the handle has already been released via [`release`](Self::release).
406 pub const fn browser(&self) -> Option<&BrowserInstance> {
407 self.instance.as_ref()
408 }
409
410 /// Mutable borrow of the underlying [`BrowserInstance`].
411 ///
412 /// Returns `None` if the handle has already been released via [`release`](Self::release).
413 pub const fn browser_mut(&mut self) -> Option<&mut BrowserInstance> {
414 self.instance.as_mut()
415 }
416
417 /// Return the browser to the pool.
418 ///
419 /// If the instance is unhealthy or the pool is full it will be disposed.
420 pub async fn release(mut self) {
421 if let Some(instance) = self.instance.take() {
422 self.pool.release(instance).await;
423 }
424 }
425}
426
427impl Drop for BrowserHandle {
428 fn drop(&mut self) {
429 if let Some(instance) = self.instance.take() {
430 let pool = Arc::clone(&self.pool);
431 tokio::spawn(async move {
432 pool.release(instance).await;
433 });
434 }
435 }
436}
437
438// ─── PoolStats ────────────────────────────────────────────────────────────────
439
440/// Point-in-time metrics for a [`BrowserPool`].
441///
442/// # Example
443///
444/// ```no_run
445/// use stygian_browser::{BrowserPool, BrowserConfig};
446///
447/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
448/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
449/// let stats = pool.stats();
450/// assert!(stats.max > 0);
451/// # Ok(())
452/// # }
453/// ```
454#[derive(Debug, Clone)]
455pub struct PoolStats {
456 /// Total browser instances currently managed by the pool (idle + in-use).
457 pub active: usize,
458 /// Maximum allowed concurrent instances.
459 pub max: usize,
460 /// Free slots (max - active).
461 pub available: usize,
462 /// Currently idle (warm) instances ready for immediate acquisition.
463 pub idle: usize,
464}
465
466// ─── Tests ────────────────────────────────────────────────────────────────────
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471 use crate::config::{PoolConfig, StealthLevel};
472 use std::time::Duration;
473
474 fn test_config() -> BrowserConfig {
475 BrowserConfig::builder()
476 .stealth_level(StealthLevel::None)
477 .pool(PoolConfig {
478 min_size: 0, // no warmup in unit tests
479 max_size: 5,
480 idle_timeout: Duration::from_secs(300),
481 acquire_timeout: Duration::from_millis(100),
482 })
483 .build()
484 }
485
486 #[test]
487 fn pool_stats_reflects_max() {
488 // This test is purely structural — pool construction needs a real browser
489 // so we only verify the config plumbing here.
490 let config = test_config();
491 assert_eq!(config.pool.max_size, 5);
492 assert_eq!(config.pool.min_size, 0);
493 }
494
495 #[test]
496 fn pool_stats_available_saturates() {
497 let stats = PoolStats {
498 active: 10,
499 max: 10,
500 available: 0,
501 idle: 0,
502 };
503 assert_eq!(stats.available, 0);
504 assert_eq!(stats.active, stats.max);
505 }
506
507 #[test]
508 fn pool_stats_partial_usage() {
509 let stats = PoolStats {
510 active: 3,
511 max: 10,
512 available: 7,
513 idle: 2,
514 };
515 assert_eq!(stats.available, 7);
516 }
517
518 #[tokio::test]
519 async fn pool_new_with_zero_min_size_ok() {
520 // With min_size=0 BrowserPool::new() should succeed without a real Chrome
521 // because no warmup launch is attempted.
522 // We skip this if no Chrome is present; this test is integration-only.
523 // Kept as a compile + config sanity check.
524 let config = test_config();
525 assert_eq!(config.pool.min_size, 0);
526 }
527
528 #[test]
529 fn pool_stats_available_is_max_minus_active() {
530 let stats = PoolStats {
531 active: 6,
532 max: 10,
533 available: 4,
534 idle: 3,
535 };
536 assert_eq!(stats.available, stats.max - stats.active);
537 }
538
539 #[test]
540 fn pool_stats_available_cannot_underflow() {
541 // active > max should not cause a panic — saturating_sub is used.
542 let stats = PoolStats {
543 active: 12,
544 max: 10,
545 available: 0_usize.saturating_sub(2),
546 idle: 0,
547 };
548 // available is computed with saturating_sub in BrowserPool::stats()
549 assert_eq!(stats.available, 0);
550 }
551
552 #[test]
553 fn pool_config_acquire_timeout_respected() {
554 let cfg = BrowserConfig::builder()
555 .pool(PoolConfig {
556 min_size: 0,
557 max_size: 1,
558 idle_timeout: Duration::from_secs(300),
559 acquire_timeout: Duration::from_millis(10),
560 })
561 .build();
562 assert_eq!(cfg.pool.acquire_timeout, Duration::from_millis(10));
563 }
564
565 #[test]
566 fn pool_config_idle_timeout_respected() {
567 let cfg = BrowserConfig::builder()
568 .pool(PoolConfig {
569 min_size: 1,
570 max_size: 5,
571 idle_timeout: Duration::from_secs(60),
572 acquire_timeout: Duration::from_secs(5),
573 })
574 .build();
575 assert_eq!(cfg.pool.idle_timeout, Duration::from_secs(60));
576 }
577
578 #[test]
579 fn browser_handle_drop_does_not_panic_without_runtime() {
580 // Verify BrowserHandle can be constructed/dropped without a real browser
581 // by ensuring the struct itself is Send + Sync (compile-time check).
582 fn assert_send<T: Send>() {}
583 fn assert_sync<T: Sync>() {}
584 assert_send::<BrowserPool>();
585 assert_send::<PoolStats>();
586 assert_sync::<BrowserPool>();
587 }
588
589 #[test]
590 fn pool_stats_zero_active_means_full_availability() {
591 let stats = PoolStats {
592 active: 0,
593 max: 8,
594 available: 8,
595 idle: 0,
596 };
597 assert_eq!(stats.available, stats.max);
598 }
599
600 #[test]
601 fn pool_entry_last_used_ordering() {
602 use std::time::Duration;
603 let now = std::time::Instant::now();
604 let older = now.checked_sub(Duration::from_secs(400)).unwrap_or(now);
605 let idle_timeout = Duration::from_secs(300);
606 // Simulate eviction check: entry older than idle_timeout should be evicted
607 assert!(now.duration_since(older) >= idle_timeout);
608 }
609
610 #[test]
611 fn pool_stats_debug_format() {
612 let stats = PoolStats {
613 active: 2,
614 max: 10,
615 available: 8,
616 idle: 1,
617 };
618 let dbg = format!("{stats:?}");
619 assert!(dbg.contains("active"));
620 assert!(dbg.contains("max"));
621 }
622}