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 — global backpressure)        │
10//! │  ┌───────────────────────────────────────────────────┐   │
11//! │  │         shared: VecDeque<PoolEntry>               │   │
12//! │  │  (unscoped browsers — used by acquire())         │   │
13//! │  └───────────────────────────────────────────────────┘   │
14//! │  ┌───────────────────────────────────────────────────┐   │
15//! │  │    scoped: HashMap<String, VecDeque<PoolEntry>>   │   │
16//! │  │  (per-context queues — used by acquire_for())    │   │
17//! │  └───────────────────────────────────────────────────┘   │
18//! │  active_count: Arc<AtomicUsize>                          │
19//! └───────────────────────────────────────────────────────────┘
20//! ```
21//!
22//! **Acquisition flow**
23//! 1. Try to pop a healthy idle entry.
24//! 2. If none idle and `active < max_size`, launch a fresh `BrowserInstance`.
25//! 3. Otherwise wait up to `acquire_timeout` for an idle slot.
26//!
27//! **Release flow**
28//! 1. Run a health-check on the returned instance.
29//! 2. If healthy and `idle < max_size`, push it back to the idle queue.
30//! 3. Otherwise shut it down and decrement the active counter.
31//!
32//! # Example
33//!
34//! ```no_run
35//! use stygian_browser::{BrowserConfig, BrowserPool};
36//!
37//! # async fn run() -> stygian_browser::error::Result<()> {
38//! let config = BrowserConfig::default();
39//! let pool = BrowserPool::new(config).await?;
40//!
41//! let stats = pool.stats();
42//! println!("Pool ready — idle: {}", stats.idle);
43//!
44//! let handle = pool.acquire().await?;
45//! handle.release().await;
46//! # Ok(())
47//! # }
48//! ```
49
50use std::sync::{
51    Arc,
52    atomic::{AtomicUsize, Ordering},
53};
54use std::time::Instant;
55
56use tokio::sync::{Mutex, Semaphore};
57use tokio::time::{sleep, timeout};
58use tracing::{debug, info, warn};
59
60use crate::{
61    BrowserConfig,
62    browser::BrowserInstance,
63    error::{BrowserError, Result},
64};
65
66// ─── PoolEntry ────────────────────────────────────────────────────────────────
67
68struct PoolEntry {
69    instance: BrowserInstance,
70    last_used: Instant,
71}
72
73// ─── PoolInner ────────────────────────────────────────────────────────────────
74
75struct PoolInner {
76    shared: std::collections::VecDeque<PoolEntry>,
77    scoped: std::collections::HashMap<String, std::collections::VecDeque<PoolEntry>>,
78}
79
80// ─── BrowserPool ──────────────────────────────────────────────────────────────
81
82/// Thread-safe pool of reusable [`BrowserInstance`]s.
83///
84/// Maintains a warm set of idle browsers ready for immediate acquisition
85/// (`<100ms`), and lazily launches new instances when demand spikes.
86///
87/// # Example
88///
89/// ```no_run
90/// use stygian_browser::{BrowserConfig, BrowserPool};
91///
92/// # async fn run() -> stygian_browser::error::Result<()> {
93/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
94/// let handle = pool.acquire().await?;
95/// handle.release().await;
96/// # Ok(())
97/// # }
98/// ```
99pub struct BrowserPool {
100    config: Arc<BrowserConfig>,
101    semaphore: Arc<Semaphore>,
102    inner: Arc<Mutex<PoolInner>>,
103    active_count: Arc<AtomicUsize>,
104    max_size: usize,
105}
106
107impl BrowserPool {
108    /// Create a new pool and pre-warm `config.pool.min_size` browser instances.
109    ///
110    /// Warmup failures are logged but not fatal — the pool will start smaller
111    /// and grow lazily.
112    ///
113    /// # Example
114    ///
115    /// ```no_run
116    /// use stygian_browser::{BrowserPool, BrowserConfig};
117    ///
118    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
119    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
120    /// # Ok(())
121    /// # }
122    /// ```
123    pub async fn new(config: BrowserConfig) -> Result<Arc<Self>> {
124        let max_size = config.pool.max_size;
125        let min_size = config.pool.min_size;
126
127        let pool = Self {
128            config: Arc::new(config),
129            semaphore: Arc::new(Semaphore::new(max_size)),
130            inner: Arc::new(Mutex::new(PoolInner {
131                shared: std::collections::VecDeque::new(),
132                scoped: std::collections::HashMap::new(),
133            })),
134            active_count: Arc::new(AtomicUsize::new(0)),
135            max_size,
136        };
137
138        // Warmup: pre-launch min_size instances
139        info!("Warming browser pool: min_size={min_size}, max_size={max_size}");
140        for i in 0..min_size {
141            match BrowserInstance::launch((*pool.config).clone()).await {
142                Ok(instance) => {
143                    pool.active_count.fetch_add(1, Ordering::Relaxed);
144                    pool.inner.lock().await.shared.push_back(PoolEntry {
145                        instance,
146                        last_used: Instant::now(),
147                    });
148                    debug!("Warmed browser {}/{min_size}", i + 1);
149                }
150                Err(e) => {
151                    warn!("Warmup browser {i} failed (non-fatal): {e}");
152                }
153            }
154        }
155
156        // Spawn idle-eviction task
157        let eviction_inner = pool.inner.clone();
158        let eviction_active = pool.active_count.clone();
159        let idle_timeout = pool.config.pool.idle_timeout;
160        let eviction_min = min_size;
161
162        tokio::spawn(async move {
163            loop {
164                sleep(idle_timeout / 2).await;
165
166                let mut guard = eviction_inner.lock().await;
167                let now = Instant::now();
168                let active = eviction_active.load(Ordering::Relaxed);
169
170                let total_idle: usize =
171                    guard.shared.len() + guard.scoped.values().map(|q| q.len()).sum::<usize>();
172                let evict_count = if active > eviction_min {
173                    (active - eviction_min).min(total_idle)
174                } else {
175                    0
176                };
177
178                let mut evicted = 0usize;
179
180                // Evict from shared queue
181                let mut kept: std::collections::VecDeque<PoolEntry> =
182                    std::collections::VecDeque::new();
183                while let Some(entry) = guard.shared.pop_front() {
184                    if evicted < evict_count && now.duration_since(entry.last_used) >= idle_timeout
185                    {
186                        tokio::spawn(async move {
187                            let _ = entry.instance.shutdown().await;
188                        });
189                        eviction_active.fetch_sub(1, Ordering::Relaxed);
190                        evicted += 1;
191                    } else {
192                        kept.push_back(entry);
193                    }
194                }
195                guard.shared = kept;
196
197                // Evict from scoped queues
198                let context_ids: Vec<String> = guard.scoped.keys().cloned().collect();
199                for cid in &context_ids {
200                    if let Some(queue) = guard.scoped.get_mut(cid) {
201                        let mut kept: std::collections::VecDeque<PoolEntry> =
202                            std::collections::VecDeque::new();
203                        while let Some(entry) = queue.pop_front() {
204                            if evicted < evict_count
205                                && now.duration_since(entry.last_used) >= idle_timeout
206                            {
207                                tokio::spawn(async move {
208                                    let _ = entry.instance.shutdown().await;
209                                });
210                                eviction_active.fetch_sub(1, Ordering::Relaxed);
211                                evicted += 1;
212                            } else {
213                                kept.push_back(entry);
214                            }
215                        }
216                        *queue = kept;
217                    }
218                }
219
220                // Remove empty scoped queues
221                guard.scoped.retain(|_, q| !q.is_empty());
222
223                drop(guard);
224
225                if evicted > 0 {
226                    info!("Evicted {evicted} idle browsers (idle_timeout={idle_timeout:?})");
227                }
228            }
229        });
230
231        Ok(Arc::new(pool))
232    }
233
234    // ─── Acquire ──────────────────────────────────────────────────────────────
235
236    /// Acquire a browser handle from the pool.
237    ///
238    /// - If a healthy idle browser is available it is returned immediately.
239    /// - If `active < max_size` a new browser is launched.
240    /// - Otherwise waits up to `pool.acquire_timeout`.
241    ///
242    /// # Errors
243    ///
244    /// Returns [`BrowserError::PoolExhausted`] if no browser becomes available
245    /// within `pool.acquire_timeout`.
246    ///
247    /// # Example
248    ///
249    /// ```no_run
250    /// use stygian_browser::{BrowserPool, BrowserConfig};
251    ///
252    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
253    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
254    /// let handle = pool.acquire().await?;
255    /// handle.release().await;
256    /// # Ok(())
257    /// # }
258    /// ```
259    pub async fn acquire(self: &Arc<Self>) -> Result<BrowserHandle> {
260        #[cfg(feature = "metrics")]
261        let acquire_start = std::time::Instant::now();
262
263        let result = self.acquire_inner(None).await;
264
265        #[cfg(feature = "metrics")]
266        {
267            let elapsed = acquire_start.elapsed();
268            crate::metrics::METRICS.record_acquisition(elapsed);
269            crate::metrics::METRICS.set_pool_size(
270                i64::try_from(self.active_count.load(Ordering::Relaxed)).unwrap_or(i64::MAX),
271            );
272        }
273
274        result
275    }
276
277    /// Acquire a browser scoped to `context_id`.
278    ///
279    /// Browsers obtained this way are isolated: they will only be reused by
280    /// future calls to `acquire_for` with the **same** `context_id`.
281    /// The global `max_size` still applies across all contexts.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`BrowserError::PoolExhausted`] if no browser becomes available
286    /// within `pool.acquire_timeout`.
287    ///
288    /// # Example
289    ///
290    /// ```no_run
291    /// use stygian_browser::{BrowserPool, BrowserConfig};
292    ///
293    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
294    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
295    /// let a = pool.acquire_for("bot-a").await?;
296    /// let b = pool.acquire_for("bot-b").await?;
297    /// a.release().await;
298    /// b.release().await;
299    /// # Ok(())
300    /// # }
301    /// ```
302    pub async fn acquire_for(self: &Arc<Self>, context_id: &str) -> Result<BrowserHandle> {
303        #[cfg(feature = "metrics")]
304        let acquire_start = std::time::Instant::now();
305
306        let result = self.acquire_inner(Some(context_id)).await;
307
308        #[cfg(feature = "metrics")]
309        {
310            let elapsed = acquire_start.elapsed();
311            crate::metrics::METRICS.record_acquisition(elapsed);
312            crate::metrics::METRICS.set_pool_size(
313                i64::try_from(self.active_count.load(Ordering::Relaxed)).unwrap_or(i64::MAX),
314            );
315        }
316
317        result
318    }
319
320    /// Shared acquisition logic. `context_id = None` reads from the shared
321    /// queue; `Some(id)` reads from the scoped queue for that context.
322    async fn acquire_inner(self: &Arc<Self>, context_id: Option<&str>) -> Result<BrowserHandle> {
323        let acquire_timeout = self.config.pool.acquire_timeout;
324        let active = self.active_count.load(Ordering::Relaxed);
325        let max = self.max_size;
326        let ctx_owned: Option<String> = context_id.map(String::from);
327
328        // Fast path: try idle queue first
329        {
330            let mut guard = self.inner.lock().await;
331            let queue = match context_id {
332                Some(id) => guard.scoped.get_mut(id),
333                None => Some(&mut guard.shared),
334            };
335            if let Some(queue) = queue {
336                while let Some(entry) = queue.pop_front() {
337                    if entry.instance.is_healthy_cached() {
338                        self.active_count.fetch_add(0, Ordering::Relaxed); // already counted
339                        debug!(
340                            context = context_id.unwrap_or("shared"),
341                            "Reusing idle browser (uptime={:?})",
342                            entry.instance.uptime()
343                        );
344                        return Ok(BrowserHandle::new(
345                            entry.instance,
346                            Arc::clone(self),
347                            ctx_owned,
348                        ));
349                    }
350                    // Unhealthy idle entry — dispose in background
351                    #[cfg(feature = "metrics")]
352                    crate::metrics::METRICS.record_crash();
353                    let active_count = self.active_count.clone();
354                    tokio::spawn(async move {
355                        let _ = entry.instance.shutdown().await;
356                        active_count.fetch_sub(1, Ordering::Relaxed);
357                    });
358                }
359            }
360        }
361
362        // Slow path: launch new or wait
363        if active < max {
364            // Acquire semaphore permit (non-blocking since active < max)
365            // Inline permit — no named binding to avoid significant_drop_tightening
366            timeout(acquire_timeout, self.semaphore.acquire())
367                .await
368                .map_err(|_| BrowserError::PoolExhausted { active, max })?
369                .map_err(|_| BrowserError::PoolExhausted { active, max })?
370                .forget(); // We track capacity manually via active_count
371            self.active_count.fetch_add(1, Ordering::Relaxed);
372
373            let instance = match BrowserInstance::launch((*self.config).clone()).await {
374                Ok(i) => i,
375                Err(e) => {
376                    self.active_count.fetch_sub(1, Ordering::Relaxed);
377                    self.semaphore.add_permits(1);
378                    return Err(e);
379                }
380            };
381
382            info!(
383                context = context_id.unwrap_or("shared"),
384                "Launched fresh browser (pool active={})",
385                self.active_count.load(Ordering::Relaxed)
386            );
387            return Ok(BrowserHandle::new(instance, Arc::clone(self), ctx_owned));
388        }
389
390        // Pool full — wait for a release
391        let ctx_for_poll = context_id.map(String::from);
392        timeout(acquire_timeout, async {
393            loop {
394                sleep(std::time::Duration::from_millis(50)).await;
395                let mut guard = self.inner.lock().await;
396                let queue = match ctx_for_poll.as_deref() {
397                    Some(id) => guard.scoped.get_mut(id),
398                    None => Some(&mut guard.shared),
399                };
400                if let Some(queue) = queue
401                    && let Some(entry) = queue.pop_front()
402                {
403                    drop(guard);
404                    if entry.instance.is_healthy_cached() {
405                        return Ok(BrowserHandle::new(
406                            entry.instance,
407                            Arc::clone(self),
408                            ctx_for_poll.clone(),
409                        ));
410                    }
411                    #[cfg(feature = "metrics")]
412                    crate::metrics::METRICS.record_crash();
413                    let active_count = self.active_count.clone();
414                    tokio::spawn(async move {
415                        let _ = entry.instance.shutdown().await;
416                        active_count.fetch_sub(1, Ordering::Relaxed);
417                    });
418                }
419            }
420        })
421        .await
422        .map_err(|_| BrowserError::PoolExhausted { active, max })?
423    }
424
425    // ─── Release ──────────────────────────────────────────────────────────────
426
427    /// Return a browser instance to the pool (called by [`BrowserHandle::release`]).
428    async fn release(&self, instance: BrowserInstance, context_id: Option<&str>) {
429        // Health-check before returning to idle queue
430        if instance.is_healthy_cached() {
431            let mut guard = self.inner.lock().await;
432            let total_idle: usize =
433                guard.shared.len() + guard.scoped.values().map(|q| q.len()).sum::<usize>();
434            if total_idle < self.max_size {
435                let queue = match context_id {
436                    Some(id) => guard.scoped.entry(id.to_owned()).or_default(),
437                    None => &mut guard.shared,
438                };
439                queue.push_back(PoolEntry {
440                    instance,
441                    last_used: Instant::now(),
442                });
443                debug!(
444                    context = context_id.unwrap_or("shared"),
445                    "Returned browser to idle pool"
446                );
447                return;
448            }
449            drop(guard);
450        }
451
452        // Unhealthy or pool full — dispose
453        #[cfg(feature = "metrics")]
454        if !instance.is_healthy_cached() {
455            crate::metrics::METRICS.record_crash();
456        }
457        let active_count = self.active_count.clone();
458        tokio::spawn(async move {
459            let _ = instance.shutdown().await;
460            active_count.fetch_sub(1, Ordering::Relaxed);
461        });
462
463        self.semaphore.add_permits(1);
464    }
465
466    // ─── Context management ───────────────────────────────────────────────────
467
468    /// Shut down and remove all idle browsers belonging to `context_id`.
469    ///
470    /// Active handles for that context are unaffected — they will be disposed
471    /// normally when released. Call this when a bot or tenant is deprovisioned.
472    ///
473    /// Returns the number of browsers shut down.
474    ///
475    /// # Example
476    ///
477    /// ```no_run
478    /// use stygian_browser::{BrowserPool, BrowserConfig};
479    ///
480    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
481    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
482    /// let released = pool.release_context("bot-a").await;
483    /// println!("Shut down {released} browsers for bot-a");
484    /// # Ok(())
485    /// # }
486    /// ```
487    pub async fn release_context(&self, context_id: &str) -> usize {
488        let mut guard = self.inner.lock().await;
489        let entries = guard.scoped.remove(context_id).unwrap_or_default();
490        drop(guard);
491
492        let count = entries.len();
493        for entry in entries {
494            let active_count = self.active_count.clone();
495            tokio::spawn(async move {
496                let _ = entry.instance.shutdown().await;
497                active_count.fetch_sub(1, Ordering::Relaxed);
498            });
499            self.semaphore.add_permits(1);
500        }
501
502        if count > 0 {
503            info!("Released {count} browsers for context '{context_id}'");
504        }
505        count
506    }
507
508    /// List all active context IDs that have idle browsers in the pool.
509    ///
510    /// # Example
511    ///
512    /// ```no_run
513    /// use stygian_browser::{BrowserPool, BrowserConfig};
514    ///
515    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
516    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
517    /// let ids = pool.context_ids().await;
518    /// println!("Active contexts: {ids:?}");
519    /// # Ok(())
520    /// # }
521    /// ```
522    pub async fn context_ids(&self) -> Vec<String> {
523        let guard = self.inner.lock().await;
524        guard.scoped.keys().cloned().collect()
525    }
526
527    // ─── Stats ────────────────────────────────────────────────────────────────
528
529    /// Snapshot of current pool metrics.
530    ///
531    /// # Example
532    ///
533    /// ```no_run
534    /// use stygian_browser::{BrowserPool, BrowserConfig};
535    ///
536    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
537    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
538    /// let s = pool.stats();
539    /// println!("active={} idle={} max={}", s.active, s.idle, s.max);
540    /// # Ok(())
541    /// # }
542    /// ```
543    pub fn stats(&self) -> PoolStats {
544        PoolStats {
545            active: self.active_count.load(Ordering::Relaxed),
546            max: self.max_size,
547            available: self
548                .max_size
549                .saturating_sub(self.active_count.load(Ordering::Relaxed)),
550            idle: 0, // approximate — would need lock; kept lock-free for perf
551        }
552    }
553}
554
555// ─── BrowserHandle ────────────────────────────────────────────────────────────
556
557/// An acquired browser from the pool.
558///
559/// Call [`BrowserHandle::release`] after use to return the instance to the
560/// idle queue.  If dropped without releasing, the browser is shut down and the
561/// pool slot freed.
562pub struct BrowserHandle {
563    instance: Option<BrowserInstance>,
564    pool: Arc<BrowserPool>,
565    context_id: Option<String>,
566}
567
568impl BrowserHandle {
569    const fn new(
570        instance: BrowserInstance,
571        pool: Arc<BrowserPool>,
572        context_id: Option<String>,
573    ) -> Self {
574        Self {
575            instance: Some(instance),
576            pool,
577            context_id,
578        }
579    }
580
581    /// Borrow the underlying [`BrowserInstance`].
582    ///
583    /// Returns `None` if the handle has already been released via [`release`](Self::release).
584    pub const fn browser(&self) -> Option<&BrowserInstance> {
585        self.instance.as_ref()
586    }
587
588    /// Mutable borrow of the underlying [`BrowserInstance`].
589    ///
590    /// Returns `None` if the handle has already been released via [`release`](Self::release).
591    pub const fn browser_mut(&mut self) -> Option<&mut BrowserInstance> {
592        self.instance.as_mut()
593    }
594
595    /// The context that owns this handle, if scoped via [`BrowserPool::acquire_for`].
596    ///
597    /// Returns `None` for handles obtained with [`BrowserPool::acquire`].
598    pub fn context_id(&self) -> Option<&str> {
599        self.context_id.as_deref()
600    }
601
602    /// Return the browser to the pool.
603    ///
604    /// If the instance is unhealthy or the pool is full it will be disposed.
605    pub async fn release(mut self) {
606        if let Some(instance) = self.instance.take() {
607            self.pool
608                .release(instance, self.context_id.as_deref())
609                .await;
610        }
611    }
612}
613
614impl Drop for BrowserHandle {
615    fn drop(&mut self) {
616        if let Some(instance) = self.instance.take() {
617            let pool = Arc::clone(&self.pool);
618            let context_id = self.context_id.clone();
619            tokio::spawn(async move {
620                pool.release(instance, context_id.as_deref()).await;
621            });
622        }
623    }
624}
625
626// ─── PoolStats ────────────────────────────────────────────────────────────────
627
628/// Point-in-time metrics for a [`BrowserPool`].
629///
630/// # Example
631///
632/// ```no_run
633/// use stygian_browser::{BrowserPool, BrowserConfig};
634///
635/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
636/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
637/// let stats = pool.stats();
638/// assert!(stats.max > 0);
639/// # Ok(())
640/// # }
641/// ```
642#[derive(Debug, Clone)]
643pub struct PoolStats {
644    /// Total browser instances currently managed by the pool (idle + in-use).
645    pub active: usize,
646    /// Maximum allowed concurrent instances.
647    pub max: usize,
648    /// Free slots (max - active).
649    pub available: usize,
650    /// Currently idle (warm) instances ready for immediate acquisition.
651    pub idle: usize,
652}
653
654// ─── Tests ────────────────────────────────────────────────────────────────────
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use crate::config::{PoolConfig, StealthLevel};
660    use std::time::Duration;
661
662    fn test_config() -> BrowserConfig {
663        BrowserConfig::builder()
664            .stealth_level(StealthLevel::None)
665            .pool(PoolConfig {
666                min_size: 0, // no warmup in unit tests
667                max_size: 5,
668                idle_timeout: Duration::from_secs(300),
669                acquire_timeout: Duration::from_millis(100),
670            })
671            .build()
672    }
673
674    #[test]
675    fn pool_stats_reflects_max() {
676        // This test is purely structural — pool construction needs a real browser
677        // so we only verify the config plumbing here.
678        let config = test_config();
679        assert_eq!(config.pool.max_size, 5);
680        assert_eq!(config.pool.min_size, 0);
681    }
682
683    #[test]
684    fn pool_stats_available_saturates() {
685        let stats = PoolStats {
686            active: 10,
687            max: 10,
688            available: 0,
689            idle: 0,
690        };
691        assert_eq!(stats.available, 0);
692        assert_eq!(stats.active, stats.max);
693    }
694
695    #[test]
696    fn pool_stats_partial_usage() {
697        let stats = PoolStats {
698            active: 3,
699            max: 10,
700            available: 7,
701            idle: 2,
702        };
703        assert_eq!(stats.available, 7);
704    }
705
706    #[tokio::test]
707    async fn pool_new_with_zero_min_size_ok() {
708        // With min_size=0 BrowserPool::new() should succeed without a real Chrome
709        // because no warmup launch is attempted.
710        // We skip this if no Chrome is present; this test is integration-only.
711        // Kept as a compile + config sanity check.
712        let config = test_config();
713        assert_eq!(config.pool.min_size, 0);
714    }
715
716    #[test]
717    fn pool_stats_available_is_max_minus_active() {
718        let stats = PoolStats {
719            active: 6,
720            max: 10,
721            available: 4,
722            idle: 3,
723        };
724        assert_eq!(stats.available, stats.max - stats.active);
725    }
726
727    #[test]
728    fn pool_stats_available_cannot_underflow() {
729        // active > max should not cause a panic — saturating_sub is used.
730        let stats = PoolStats {
731            active: 12,
732            max: 10,
733            available: 0_usize.saturating_sub(2),
734            idle: 0,
735        };
736        // available is computed with saturating_sub in BrowserPool::stats()
737        assert_eq!(stats.available, 0);
738    }
739
740    #[test]
741    fn pool_config_acquire_timeout_respected() {
742        let cfg = BrowserConfig::builder()
743            .pool(PoolConfig {
744                min_size: 0,
745                max_size: 1,
746                idle_timeout: Duration::from_secs(300),
747                acquire_timeout: Duration::from_millis(10),
748            })
749            .build();
750        assert_eq!(cfg.pool.acquire_timeout, Duration::from_millis(10));
751    }
752
753    #[test]
754    fn pool_config_idle_timeout_respected() {
755        let cfg = BrowserConfig::builder()
756            .pool(PoolConfig {
757                min_size: 1,
758                max_size: 5,
759                idle_timeout: Duration::from_secs(60),
760                acquire_timeout: Duration::from_secs(5),
761            })
762            .build();
763        assert_eq!(cfg.pool.idle_timeout, Duration::from_secs(60));
764    }
765
766    #[test]
767    fn browser_handle_drop_does_not_panic_without_runtime() {
768        // Verify BrowserHandle can be constructed/dropped without a real browser
769        // by ensuring the struct itself is Send + Sync (compile-time check).
770        fn assert_send<T: Send>() {}
771        fn assert_sync<T: Sync>() {}
772        assert_send::<BrowserPool>();
773        assert_send::<PoolStats>();
774        assert_sync::<BrowserPool>();
775    }
776
777    #[test]
778    fn pool_stats_zero_active_means_full_availability() {
779        let stats = PoolStats {
780            active: 0,
781            max: 8,
782            available: 8,
783            idle: 0,
784        };
785        assert_eq!(stats.available, stats.max);
786    }
787
788    #[test]
789    fn pool_entry_last_used_ordering() {
790        use std::time::Duration;
791        let now = std::time::Instant::now();
792        let older = now.checked_sub(Duration::from_secs(400)).unwrap_or(now);
793        let idle_timeout = Duration::from_secs(300);
794        // Simulate eviction check: entry older than idle_timeout should be evicted
795        assert!(now.duration_since(older) >= idle_timeout);
796    }
797
798    #[test]
799    fn pool_stats_debug_format() {
800        let stats = PoolStats {
801            active: 2,
802            max: 10,
803            available: 8,
804            idle: 1,
805        };
806        let dbg = format!("{stats:?}");
807        assert!(dbg.contains("active"));
808        assert!(dbg.contains("max"));
809    }
810
811    // ─── Context segregation tests ────────────────────────────────────────────
812
813    #[test]
814    fn pool_inner_scoped_default_is_empty() {
815        let inner = PoolInner {
816            shared: std::collections::VecDeque::new(),
817            scoped: std::collections::HashMap::new(),
818        };
819        assert!(inner.shared.is_empty());
820        assert!(inner.scoped.is_empty());
821    }
822
823    #[test]
824    fn pool_inner_scoped_insert_and_retrieve() {
825        let mut inner = PoolInner {
826            shared: std::collections::VecDeque::new(),
827            scoped: std::collections::HashMap::new(),
828        };
829        // Verify the scoped map key-space is independent
830        inner.scoped.entry("bot-a".to_owned()).or_default();
831        inner.scoped.entry("bot-b".to_owned()).or_default();
832        assert_eq!(inner.scoped.len(), 2);
833        assert!(inner.scoped.contains_key("bot-a"));
834        assert!(inner.scoped.contains_key("bot-b"));
835        assert!(inner.shared.is_empty());
836    }
837
838    #[test]
839    fn pool_inner_scoped_retain_removes_empty() {
840        let mut inner = PoolInner {
841            shared: std::collections::VecDeque::new(),
842            scoped: std::collections::HashMap::new(),
843        };
844        inner.scoped.entry("empty".to_owned()).or_default();
845        assert_eq!(inner.scoped.len(), 1);
846        inner.scoped.retain(|_, q| !q.is_empty());
847        assert!(inner.scoped.is_empty());
848    }
849
850    #[tokio::test]
851    async fn pool_context_ids_empty_by_default() {
852        // Without a running Chrome, we test with min_size=0 so no browser
853        // is launched. We need to construct the pool carefully.
854        let config = test_config();
855        assert_eq!(config.pool.min_size, 0);
856        // context_ids requires an actual pool instance — this test verifies
857        // the zero-state. Full integration tested with real browser.
858    }
859
860    #[test]
861    fn browser_handle_context_id_none_for_shared() {
862        // Compile-time / structural: BrowserHandle carries context_id
863        fn _check_context_api(handle: &BrowserHandle) {
864            let _: Option<&str> = handle.context_id();
865        }
866    }
867
868    #[test]
869    fn pool_inner_total_idle_calculation() {
870        let mut inner = PoolInner {
871            shared: std::collections::VecDeque::new(),
872            scoped: std::collections::HashMap::new(),
873        };
874        // Total idle across shared + scoped
875        fn total_idle(inner: &PoolInner) -> usize {
876            inner.shared.len() + inner.scoped.values().map(|q| q.len()).sum::<usize>()
877        }
878        assert_eq!(total_idle(&inner), 0);
879
880        // Add entries to scoped queues (without real BrowserInstance, just check sizes)
881        inner.scoped.entry("a".to_owned()).or_default();
882        inner.scoped.entry("b".to_owned()).or_default();
883        assert_eq!(total_idle(&inner), 0); // empty queues don't count
884    }
885}