Skip to main content

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}