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    /// RAII proxy lease — held for the entire Chrome process lifetime.
72    /// `mark_success()` is called on clean disposal; simply dropping it
73    /// records a circuit-breaker failure in the proxy pool (if any).
74    proxy_lease: Option<Box<dyn crate::proxy::ProxyLease>>,
75}
76
77// ─── PoolInner ────────────────────────────────────────────────────────────────
78
79struct PoolInner {
80    shared: std::collections::VecDeque<PoolEntry>,
81    scoped: std::collections::HashMap<String, std::collections::VecDeque<PoolEntry>>,
82}
83
84// ─── BrowserPool ──────────────────────────────────────────────────────────────
85
86/// Thread-safe pool of reusable [`BrowserInstance`]s.
87///
88/// Maintains a warm set of idle browsers ready for immediate acquisition
89/// (`<100ms`), and lazily launches new instances when demand spikes.
90///
91/// # Example
92///
93/// ```no_run
94/// use stygian_browser::{BrowserConfig, BrowserPool};
95///
96/// # async fn run() -> stygian_browser::error::Result<()> {
97/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
98/// let handle = pool.acquire().await?;
99/// handle.release().await;
100/// # Ok(())
101/// # }
102/// ```
103pub struct BrowserPool {
104    config: Arc<BrowserConfig>,
105    semaphore: Arc<Semaphore>,
106    inner: Arc<Mutex<PoolInner>>,
107    active_count: Arc<AtomicUsize>,
108    max_size: usize,
109}
110
111impl BrowserPool {
112    /// Create a new pool and pre-warm `config.pool.min_size` browser instances.
113    ///
114    /// Warmup failures are logged but not fatal — the pool will start smaller
115    /// and grow lazily.
116    ///
117    /// # Example
118    ///
119    /// ```no_run
120    /// use stygian_browser::{BrowserPool, BrowserConfig};
121    ///
122    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
123    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
124    /// # Ok(())
125    /// # }
126    /// ```
127    ///
128    /// # Errors
129    ///
130    /// Returns [`BrowserError::PoolExhausted`] when the pool fails to warm up the
131    /// minimum number of browser instances (e.g. zero `min_size`, browser
132    /// launch failures, CDP handshake errors), or
133    /// [`BrowserError::ConfigError`] when `BrowserConfig::validate` reports
134    /// invariant violations before the warm-up phase runs.
135    pub async fn new(config: BrowserConfig) -> Result<Arc<Self>> {
136        let max_size = config.pool.max_size;
137        let min_size = config.pool.min_size;
138
139        let pool = Self {
140            config: Arc::new(config),
141            semaphore: Arc::new(Semaphore::new(max_size)),
142            inner: Arc::new(Mutex::new(PoolInner {
143                shared: std::collections::VecDeque::new(),
144                scoped: std::collections::HashMap::new(),
145            })),
146            active_count: Arc::new(AtomicUsize::new(0)),
147            max_size,
148        };
149
150        Self::warmup_pool(&pool, min_size).await;
151
152        // Spawn idle-eviction task
153        tokio::spawn(Self::eviction_loop(
154            pool.inner.clone(),
155            pool.active_count.clone(),
156            pool.config.pool.idle_timeout,
157            min_size,
158        ));
159
160        Ok(Arc::new(pool))
161    }
162
163    /// Construct a [`BrowserPool`] without launching any browser or
164    /// spawning background tasks. Intended for unit tests that only
165    /// exercise code paths that read pool state (e.g. freshness
166    /// short-circuits in [`AcquisitionRunner`][crate::acquisition::AcquisitionRunner]).
167    ///
168    /// Any attempt to actually acquire a browser from a placeholder
169    /// pool will fail with [`BrowserError::PoolExhausted`] after a
170    /// short bounded wait. The placeholder uses `max_size = 0` (so
171    /// the slow-path launch attempt is skipped) and a 50 ms
172    /// `acquire_timeout` (so the poll-for-release path returns
173    /// `PoolExhausted` deterministically rather than racing the
174    /// runner's `total_timeout`). This keeps unit tests
175    /// deterministic and avoids the 10 s `launch_timeout` that the
176    /// default config would otherwise impose.
177    #[cfg(test)]
178    #[must_use]
179    pub fn placeholder() -> Arc<Self> {
180        use std::time::Duration;
181        let mut config = BrowserConfig::default();
182        config.pool.acquire_timeout = Duration::from_millis(50);
183        config.launch_timeout = Duration::from_millis(50);
184        Arc::new(Self {
185            config: Arc::new(config),
186            semaphore: Arc::new(Semaphore::new(0)),
187            inner: Arc::new(Mutex::new(PoolInner {
188                shared: std::collections::VecDeque::new(),
189                scoped: std::collections::HashMap::new(),
190            })),
191            active_count: Arc::new(AtomicUsize::new(0)),
192            max_size: 0,
193        })
194    }
195
196    // ─── Warmup ───────────────────────────────────────────────────────────────
197
198    /// Pre-warm `min_size` browser instances into the shared queue.
199    async fn warmup_pool(pool: &Self, min_size: usize) {
200        info!(
201            "Warming browser pool: min_size={min_size}, max_size={}",
202            pool.max_size
203        );
204        for i in 0..min_size {
205            let (launch_config, proxy_lease) = if let Some(source) = &pool.config.proxy_source {
206                match source.bind_proxy().await {
207                    Ok((url, lease)) => {
208                        let mut cfg = (*pool.config).clone();
209                        cfg.proxy = Some(url);
210                        cfg.proxy_source = None;
211                        (cfg, Some(lease))
212                    }
213                    Err(e) => {
214                        warn!("Warmup browser {i} failed to acquire proxy (non-fatal): {e}");
215                        continue;
216                    }
217                }
218            } else {
219                ((*pool.config).clone(), None)
220            };
221
222            // Acquire a semaphore permit so that `active_count` and the
223            // semaphore always agree on capacity.
224            let Ok(permit) = pool.semaphore.try_acquire() else {
225                warn!("Warmup browser {i}: semaphore full, stopping warmup early");
226                break;
227            };
228            permit.forget(); // immediately — track capacity via active_count
229            pool.active_count.fetch_add(1, Ordering::Relaxed);
230
231            match BrowserInstance::launch(launch_config).await {
232                Ok(instance) => {
233                    pool.inner.lock().await.shared.push_back(PoolEntry {
234                        instance,
235                        last_used: Instant::now(),
236                        proxy_lease,
237                    });
238                    debug!("Warmed browser {}/{min_size}", i + 1);
239                }
240                Err(e) => {
241                    warn!("Warmup browser {i} failed (non-fatal): {e}");
242                    pool.active_count.fetch_sub(1, Ordering::Relaxed);
243                    pool.semaphore.add_permits(1);
244                    // proxy_lease drops here = circuit-breaker failure signal
245                }
246            }
247        }
248    }
249
250    // ─── Eviction ─────────────────────────────────────────────────────────────
251
252    /// Background loop that evicts browsers idle longer than `idle_timeout`.
253    async fn eviction_loop(
254        inner: Arc<Mutex<PoolInner>>,
255        active_count: Arc<AtomicUsize>,
256        idle_timeout: std::time::Duration,
257        min_size: usize,
258    ) {
259        loop {
260            sleep(idle_timeout / 2).await;
261
262            let mut guard = inner.lock().await;
263            let now = Instant::now();
264            let active = active_count.load(Ordering::Relaxed);
265
266            let total_idle: usize = guard.shared.len()
267                + guard
268                    .scoped
269                    .values()
270                    .map(std::collections::VecDeque::len)
271                    .sum::<usize>();
272            let evict_count = if active > min_size {
273                (active - min_size).min(total_idle)
274            } else {
275                0
276            };
277
278            let mut evicted = 0usize;
279
280            // Evict from shared queue
281            let mut kept: std::collections::VecDeque<PoolEntry> = std::collections::VecDeque::new();
282            while let Some(entry) = guard.shared.pop_front() {
283                if evicted < evict_count && now.duration_since(entry.last_used) >= idle_timeout {
284                    // Clean eviction: proxy was fine, just expired.
285                    if let Some(lease) = &entry.proxy_lease {
286                        lease.mark_success();
287                    }
288                    let instance = entry.instance;
289                    tokio::spawn(async move {
290                        let _ = instance.shutdown().await;
291                    });
292                    active_count.fetch_sub(1, Ordering::Relaxed);
293                    evicted += 1;
294                } else {
295                    kept.push_back(entry);
296                }
297            }
298            guard.shared = kept;
299
300            // Evict from scoped queues
301            let context_ids: Vec<String> = guard.scoped.keys().cloned().collect();
302            for cid in &context_ids {
303                if let Some(queue) = guard.scoped.get_mut(cid) {
304                    let mut kept: std::collections::VecDeque<PoolEntry> =
305                        std::collections::VecDeque::new();
306                    while let Some(entry) = queue.pop_front() {
307                        if evicted < evict_count
308                            && now.duration_since(entry.last_used) >= idle_timeout
309                        {
310                            if let Some(lease) = &entry.proxy_lease {
311                                lease.mark_success();
312                            }
313                            let instance = entry.instance;
314                            tokio::spawn(async move {
315                                let _ = instance.shutdown().await;
316                            });
317                            active_count.fetch_sub(1, Ordering::Relaxed);
318                            evicted += 1;
319                        } else {
320                            kept.push_back(entry);
321                        }
322                    }
323                    *queue = kept;
324                }
325            }
326
327            // Remove empty scoped queues
328            guard.scoped.retain(|_, q| !q.is_empty());
329
330            // Drop the guard promptly to avoid holding the lock longer than needed
331            drop(guard);
332
333            if evicted > 0 {
334                info!("Evicted {evicted} idle browsers (idle_timeout={idle_timeout:?})");
335            }
336        }
337    }
338
339    // ─── Acquire ──────────────────────────────────────────────────────────────
340
341    /// Acquire a browser handle from the pool.
342    ///
343    /// - If a healthy idle browser is available it is returned immediately.
344    /// - If `active < max_size` a new browser is launched.
345    /// - Otherwise waits up to `pool.acquire_timeout`.
346    ///
347    /// # Errors
348    ///
349    /// Returns [`BrowserError::PoolExhausted`] if no browser becomes available
350    /// within `pool.acquire_timeout`.
351    ///
352    /// # Example
353    ///
354    /// ```no_run
355    /// use stygian_browser::{BrowserPool, BrowserConfig};
356    ///
357    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
358    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
359    /// let handle = pool.acquire().await?;
360    /// handle.release().await;
361    /// # Ok(())
362    /// # }
363    /// ```
364    pub async fn acquire(self: &Arc<Self>) -> Result<BrowserHandle> {
365        #[cfg(feature = "metrics")]
366        let acquire_start = std::time::Instant::now();
367
368        let result = self.acquire_inner(None).await;
369
370        #[cfg(feature = "metrics")]
371        {
372            let elapsed = acquire_start.elapsed();
373            crate::metrics::METRICS.record_acquisition(elapsed);
374            crate::metrics::METRICS.set_pool_size(
375                i64::try_from(self.active_count.load(Ordering::Relaxed)).unwrap_or(i64::MAX),
376            );
377        }
378
379        result
380    }
381
382    /// Acquire a browser scoped to `context_id`.
383    ///
384    /// Browsers obtained this way are isolated: they will only be reused by
385    /// future calls to `acquire_for` with the **same** `context_id`.
386    /// The global `max_size` still applies across all contexts.
387    ///
388    /// # Errors
389    ///
390    /// Returns [`BrowserError::PoolExhausted`] if no browser becomes available
391    /// within `pool.acquire_timeout`.
392    ///
393    /// # Example
394    ///
395    /// ```no_run
396    /// use stygian_browser::{BrowserPool, BrowserConfig};
397    ///
398    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
399    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
400    /// let a = pool.acquire_for("bot-a").await?;
401    /// let b = pool.acquire_for("bot-b").await?;
402    /// a.release().await;
403    /// b.release().await;
404    /// # Ok(())
405    /// # }
406    /// ```
407    pub async fn acquire_for(self: &Arc<Self>, context_id: &str) -> Result<BrowserHandle> {
408        #[cfg(feature = "metrics")]
409        let acquire_start = std::time::Instant::now();
410
411        let result = self.acquire_inner(Some(context_id)).await;
412
413        #[cfg(feature = "metrics")]
414        {
415            let elapsed = acquire_start.elapsed();
416            crate::metrics::METRICS.record_acquisition(elapsed);
417            crate::metrics::METRICS.set_pool_size(
418                i64::try_from(self.active_count.load(Ordering::Relaxed)).unwrap_or(i64::MAX),
419            );
420        }
421
422        result
423    }
424
425    /// Shared acquisition logic. `context_id = None` reads from the shared
426    /// queue; `Some(id)` reads from the scoped queue for that context.
427    #[allow(clippy::significant_drop_tightening)] // guard scope is already minimal
428    async fn acquire_inner(self: &Arc<Self>, context_id: Option<&str>) -> Result<BrowserHandle> {
429        let acquire_timeout = self.config.pool.acquire_timeout;
430        let active = self.active_count.load(Ordering::Relaxed);
431        let max = self.max_size;
432        let ctx_owned: Option<String> = context_id.map(String::from);
433
434        // Fast path: try idle queue first
435        let fast_result = {
436            let mut guard = self.inner.lock().await;
437            let queue = match context_id {
438                Some(id) => guard.scoped.get_mut(id),
439                None => Some(&mut guard.shared),
440            };
441            let mut healthy: Option<(BrowserInstance, Option<Box<dyn crate::proxy::ProxyLease>>)> =
442                None;
443            let mut unhealthy: Vec<(BrowserInstance, Option<Box<dyn crate::proxy::ProxyLease>>)> =
444                Vec::new();
445            if let Some(queue) = queue {
446                while let Some(entry) = queue.pop_front() {
447                    if healthy.is_none() && entry.instance.is_healthy_cached() {
448                        healthy = Some((entry.instance, entry.proxy_lease));
449                    } else if !entry.instance.is_healthy_cached() {
450                        unhealthy.push((entry.instance, entry.proxy_lease));
451                    } else {
452                        // Healthy but we already found one — push back.
453                        queue.push_front(entry);
454                        break;
455                    }
456                }
457            }
458            (healthy, unhealthy)
459        };
460
461        // Dispose unhealthy entries outside the lock
462        for (instance, _lease) in fast_result.1 {
463            // _lease drops here = circuit-breaker failure signal
464            #[cfg(feature = "metrics")]
465            crate::metrics::METRICS.record_crash();
466            let active_count = self.active_count.clone();
467            tokio::spawn(async move {
468                let _ = instance.shutdown().await;
469                active_count.fetch_sub(1, Ordering::Relaxed);
470            });
471        }
472
473        if let Some((instance, proxy_lease)) = fast_result.0 {
474            debug!(
475                context = context_id.unwrap_or("shared"),
476                "Reusing idle browser (uptime={:?})",
477                instance.uptime()
478            );
479            return Ok(BrowserHandle::new(
480                instance,
481                Arc::clone(self),
482                ctx_owned,
483                proxy_lease,
484            ));
485        }
486
487        // Slow path: launch new or wait
488        if active < max {
489            // Acquire semaphore permit (non-blocking since active < max)
490            // Inline permit — no named binding to avoid significant_drop_tightening
491            timeout(acquire_timeout, self.semaphore.acquire())
492                .await
493                .map_err(|_| BrowserError::PoolExhausted { active, max })?
494                .map_err(|_| BrowserError::PoolExhausted { active, max })?
495                .forget(); // We track capacity manually via active_count
496            self.active_count.fetch_add(1, Ordering::Relaxed);
497
498            let (launch_config, proxy_lease) = if let Some(source) = &self.config.proxy_source {
499                match source.bind_proxy().await {
500                    Ok((url, lease)) => {
501                        let mut cfg = (*self.config).clone();
502                        cfg.proxy = Some(url);
503                        cfg.proxy_source = None;
504                        (cfg, Some(lease))
505                    }
506                    Err(e) => {
507                        self.active_count.fetch_sub(1, Ordering::Relaxed);
508                        self.semaphore.add_permits(1);
509                        return Err(e);
510                    }
511                }
512            } else {
513                ((*self.config).clone(), None)
514            };
515
516            let instance = match BrowserInstance::launch(launch_config).await {
517                Ok(i) => i,
518                Err(e) => {
519                    // proxy_lease drops here = circuit-breaker failure signal
520                    self.active_count.fetch_sub(1, Ordering::Relaxed);
521                    self.semaphore.add_permits(1);
522                    return Err(e);
523                }
524            };
525
526            info!(
527                context = context_id.unwrap_or("shared"),
528                "Launched fresh browser (pool active={})",
529                self.active_count.load(Ordering::Relaxed)
530            );
531            return Ok(BrowserHandle::new(
532                instance,
533                Arc::clone(self),
534                ctx_owned,
535                proxy_lease,
536            ));
537        }
538
539        // Pool full — wait for a release
540        let ctx_for_poll = context_id.map(String::from);
541        self.poll_for_release(ctx_for_poll, acquire_timeout, active, max)
542            .await
543    }
544
545    // ─── Poll for release ─────────────────────────────────────────────────────
546
547    /// Wait until an idle browser is returned to the pool or the timeout fires.
548    async fn poll_for_release(
549        self: &Arc<Self>,
550        ctx_for_poll: Option<String>,
551        acquire_timeout: std::time::Duration,
552        active: usize,
553        max: usize,
554    ) -> Result<BrowserHandle> {
555        timeout(acquire_timeout, async {
556            loop {
557                sleep(std::time::Duration::from_millis(50)).await;
558                let mut guard = self.inner.lock().await;
559                let queue = match ctx_for_poll.as_deref() {
560                    Some(id) => guard.scoped.get_mut(id),
561                    None => Some(&mut guard.shared),
562                };
563                if let Some(queue) = queue
564                    && let Some(entry) = queue.pop_front()
565                {
566                    drop(guard);
567                    if entry.instance.is_healthy_cached() {
568                        let (instance, proxy_lease) = (entry.instance, entry.proxy_lease);
569                        return Ok(BrowserHandle::new(
570                            instance,
571                            Arc::clone(self),
572                            ctx_for_poll.clone(),
573                            proxy_lease,
574                        ));
575                    }
576                    #[cfg(feature = "metrics")]
577                    crate::metrics::METRICS.record_crash();
578                    // _lease drops = circuit-breaker failure signal
579                    let instance = entry.instance;
580                    let active_count = self.active_count.clone();
581                    tokio::spawn(async move {
582                        let _ = instance.shutdown().await;
583                        active_count.fetch_sub(1, Ordering::Relaxed);
584                    });
585                }
586            }
587        })
588        .await
589        .map_err(|_| BrowserError::PoolExhausted { active, max })?
590    }
591
592    // ─── Release ──────────────────────────────────────────────────────────────
593
594    /// Return a browser instance to the pool (called by [`BrowserHandle::release`]).
595    async fn release(
596        &self,
597        instance: BrowserInstance,
598        context_id: Option<&str>,
599        mut proxy_lease: Option<Box<dyn crate::proxy::ProxyLease>>,
600    ) {
601        // Health-check before returning to idle queue
602        if instance.is_healthy_cached() {
603            let mut guard = self.inner.lock().await;
604            let total_idle: usize = guard.shared.len()
605                + guard
606                    .scoped
607                    .values()
608                    .map(std::collections::VecDeque::len)
609                    .sum::<usize>();
610            if total_idle < self.max_size {
611                let queue = match context_id {
612                    Some(id) => guard.scoped.entry(id.to_owned()).or_default(),
613                    None => &mut guard.shared,
614                };
615                queue.push_back(PoolEntry {
616                    instance,
617                    last_used: Instant::now(),
618                    proxy_lease: proxy_lease.take(), // lease travels with the pooled entry
619                });
620                debug!(
621                    context = context_id.unwrap_or("shared"),
622                    "Returned browser to idle pool"
623                );
624                return;
625            }
626            drop(guard);
627            // Healthy but pool full: mark success before clean disposal
628            if let Some(lease) = &proxy_lease {
629                lease.mark_success();
630            }
631        }
632        // proxy_lease drops here:
633        //   - healthy + pool full → mark_success was called above, drop is a no-op
634        //   - unhealthy → mark_success NOT called, drop records circuit-breaker failure
635
636        // Unhealthy or pool full — dispose
637        #[cfg(feature = "metrics")]
638        if !instance.is_healthy_cached() {
639            crate::metrics::METRICS.record_crash();
640        }
641        let active_count = self.active_count.clone();
642        tokio::spawn(async move {
643            let _ = instance.shutdown().await;
644            active_count.fetch_sub(1, Ordering::Relaxed);
645        });
646
647        self.semaphore.add_permits(1);
648    }
649
650    // ─── Context management ───────────────────────────────────────────────────
651
652    /// Shut down and remove all idle browsers belonging to `context_id`.
653    ///
654    /// Active handles for that context are unaffected — they will be disposed
655    /// normally when released. Call this when a bot or tenant is deprovisioned.
656    ///
657    /// Returns the number of browsers shut down.
658    ///
659    /// # Example
660    ///
661    /// ```no_run
662    /// use stygian_browser::{BrowserPool, BrowserConfig};
663    ///
664    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
665    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
666    /// let released = pool.release_context("bot-a").await;
667    /// println!("Shut down {released} browsers for bot-a");
668    /// # Ok(())
669    /// # }
670    /// ```
671    pub async fn release_context(&self, context_id: &str) -> usize {
672        let mut guard = self.inner.lock().await;
673        let entries = guard.scoped.remove(context_id).unwrap_or_default();
674        drop(guard);
675
676        let count = entries.len();
677        for entry in entries {
678            // Clean deprovisioning: mark the proxy as successful
679            if let Some(lease) = &entry.proxy_lease {
680                lease.mark_success();
681            }
682            let instance = entry.instance;
683            let active_count = self.active_count.clone();
684            tokio::spawn(async move {
685                let _ = instance.shutdown().await;
686                active_count.fetch_sub(1, Ordering::Relaxed);
687            });
688            self.semaphore.add_permits(1);
689        }
690
691        if count > 0 {
692            info!("Released {count} browsers for context '{context_id}'");
693        }
694        count
695    }
696
697    /// List all active context IDs that have idle browsers in the pool.
698    ///
699    /// # Example
700    ///
701    /// ```no_run
702    /// use stygian_browser::{BrowserPool, BrowserConfig};
703    ///
704    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
705    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
706    /// let ids = pool.context_ids().await;
707    /// println!("Active contexts: {ids:?}");
708    /// # Ok(())
709    /// # }
710    /// ```
711    pub async fn context_ids(&self) -> Vec<String> {
712        let guard = self.inner.lock().await;
713        guard.scoped.keys().cloned().collect()
714    }
715
716    // ─── Stats ────────────────────────────────────────────────────────────────
717
718    /// Snapshot of current pool metrics.
719    ///
720    /// # Example
721    ///
722    /// ```no_run
723    /// use stygian_browser::{BrowserPool, BrowserConfig};
724    ///
725    /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
726    /// let pool = BrowserPool::new(BrowserConfig::default()).await?;
727    /// let s = pool.stats();
728    /// println!("active={} idle={} max={}", s.active, s.idle, s.max);
729    /// # Ok(())
730    /// # }
731    /// ```
732    #[must_use]
733    pub fn stats(&self) -> PoolStats {
734        PoolStats {
735            active: self.active_count.load(Ordering::Relaxed),
736            max: self.max_size,
737            available: self
738                .max_size
739                .saturating_sub(self.active_count.load(Ordering::Relaxed)),
740            idle: 0, // approximate — would need lock; kept lock-free for perf
741        }
742    }
743}
744
745// ─── BrowserHandle ────────────────────────────────────────────────────────────
746
747/// An acquired browser from the pool.
748///
749/// Call [`BrowserHandle::release`] after use to return the instance to the
750/// idle queue.  If dropped without releasing, the browser is shut down and the
751/// pool slot freed.
752pub struct BrowserHandle {
753    instance: Option<BrowserInstance>,
754    pool: Arc<BrowserPool>,
755    context_id: Option<String>,
756    proxy_lease: Option<Box<dyn crate::proxy::ProxyLease>>,
757}
758
759impl BrowserHandle {
760    fn new(
761        instance: BrowserInstance,
762        pool: Arc<BrowserPool>,
763        context_id: Option<String>,
764        proxy_lease: Option<Box<dyn crate::proxy::ProxyLease>>,
765    ) -> Self {
766        Self {
767            instance: Some(instance),
768            pool,
769            context_id,
770            proxy_lease,
771        }
772    }
773
774    /// Borrow the underlying [`BrowserInstance`].
775    ///
776    /// Returns `None` if the handle has already been released via [`release`](Self::release).
777    #[must_use]
778    pub const fn browser(&self) -> Option<&BrowserInstance> {
779        self.instance.as_ref()
780    }
781
782    /// Mutable borrow of the underlying [`BrowserInstance`].
783    ///
784    /// Returns `None` if the handle has already been released via [`release`](Self::release).
785    pub const fn browser_mut(&mut self) -> Option<&mut BrowserInstance> {
786        self.instance.as_mut()
787    }
788
789    /// The context that owns this handle, if scoped via [`BrowserPool::acquire_for`].
790    ///
791    /// Returns `None` for handles obtained with [`BrowserPool::acquire`].
792    #[must_use]
793    pub fn context_id(&self) -> Option<&str> {
794        self.context_id.as_deref()
795    }
796
797    /// Return the browser to the pool.
798    ///
799    /// If the instance is unhealthy or the pool is full it will be disposed.
800    pub async fn release(mut self) {
801        if let Some(instance) = self.instance.take() {
802            self.pool
803                .release(
804                    instance,
805                    self.context_id.as_deref(),
806                    self.proxy_lease.take(),
807                )
808                .await;
809        }
810    }
811}
812
813impl Drop for BrowserHandle {
814    fn drop(&mut self) {
815        if let Some(instance) = self.instance.take() {
816            let pool = Arc::clone(&self.pool);
817            let context_id = self.context_id.clone();
818            let proxy_lease = self.proxy_lease.take();
819            tokio::spawn(async move {
820                pool.release(instance, context_id.as_deref(), proxy_lease)
821                    .await;
822            });
823        }
824    }
825}
826
827// ─── PoolStats ────────────────────────────────────────────────────────────────
828
829/// Point-in-time metrics for a [`BrowserPool`].
830///
831/// # Example
832///
833/// ```no_run
834/// use stygian_browser::{BrowserPool, BrowserConfig};
835///
836/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
837/// let pool = BrowserPool::new(BrowserConfig::default()).await?;
838/// let stats = pool.stats();
839/// assert!(stats.max > 0);
840/// # Ok(())
841/// # }
842/// ```
843#[derive(Debug, Clone)]
844pub struct PoolStats {
845    /// Total browser instances currently managed by the pool (idle + in-use).
846    pub active: usize,
847    /// Maximum allowed concurrent instances.
848    pub max: usize,
849    /// Free slots (max - active).
850    pub available: usize,
851    /// Currently idle (warm) instances ready for immediate acquisition.
852    pub idle: usize,
853}
854
855// ─── Tests ────────────────────────────────────────────────────────────────────
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use crate::config::{PoolConfig, StealthLevel};
861    use std::time::Duration;
862
863    fn test_config() -> BrowserConfig {
864        BrowserConfig::builder()
865            .stealth_level(StealthLevel::None)
866            .pool(PoolConfig {
867                min_size: 0, // no warmup in unit tests
868                max_size: 5,
869                idle_timeout: Duration::from_mins(5),
870                acquire_timeout: Duration::from_millis(100),
871            })
872            .build()
873    }
874
875    #[test]
876    fn pool_stats_reflects_max() {
877        // This test is purely structural — pool construction needs a real browser
878        // so we only verify the config plumbing here.
879        let config = test_config();
880        assert_eq!(config.pool.max_size, 5);
881        assert_eq!(config.pool.min_size, 0);
882    }
883
884    #[test]
885    fn pool_stats_available_saturates() {
886        let stats = PoolStats {
887            active: 10,
888            max: 10,
889            available: 0,
890            idle: 0,
891        };
892        assert_eq!(stats.available, 0);
893        assert_eq!(stats.active, stats.max);
894    }
895
896    #[test]
897    fn pool_stats_partial_usage() {
898        let stats = PoolStats {
899            active: 3,
900            max: 10,
901            available: 7,
902            idle: 2,
903        };
904        assert_eq!(stats.available, 7);
905    }
906
907    #[tokio::test]
908    async fn pool_new_with_zero_min_size_ok() {
909        // With min_size=0 BrowserPool::new() should succeed without a real Chrome
910        // because no warmup launch is attempted.
911        // We skip this if no Chrome is present; this test is integration-only.
912        // Kept as a compile + config sanity check.
913        let config = test_config();
914        assert_eq!(config.pool.min_size, 0);
915    }
916
917    #[test]
918    fn pool_stats_available_is_max_minus_active() {
919        let stats = PoolStats {
920            active: 6,
921            max: 10,
922            available: 4,
923            idle: 3,
924        };
925        assert_eq!(stats.available, stats.max - stats.active);
926    }
927
928    #[test]
929    fn pool_stats_available_cannot_underflow() {
930        // active > max should not cause a panic — saturating_sub is used.
931        let stats = PoolStats {
932            active: 12,
933            max: 10,
934            available: 0_usize.saturating_sub(2),
935            idle: 0,
936        };
937        // available is computed with saturating_sub in BrowserPool::stats()
938        assert_eq!(stats.available, 0);
939    }
940
941    #[test]
942    fn pool_config_acquire_timeout_respected() {
943        let cfg = BrowserConfig::builder()
944            .pool(PoolConfig {
945                min_size: 0,
946                max_size: 1,
947                idle_timeout: Duration::from_mins(5),
948                acquire_timeout: Duration::from_millis(10),
949            })
950            .build();
951        assert_eq!(cfg.pool.acquire_timeout, Duration::from_millis(10));
952    }
953
954    #[test]
955    fn pool_config_idle_timeout_respected() {
956        let cfg = BrowserConfig::builder()
957            .pool(PoolConfig {
958                min_size: 1,
959                max_size: 5,
960                idle_timeout: Duration::from_mins(1),
961                acquire_timeout: Duration::from_secs(5),
962            })
963            .build();
964        assert_eq!(cfg.pool.idle_timeout, Duration::from_mins(1));
965    }
966
967    #[test]
968    fn browser_handle_drop_does_not_panic_without_runtime() {
969        // Verify BrowserHandle can be constructed/dropped without a real browser
970        // by ensuring the struct itself is Send + Sync (compile-time check).
971        fn assert_send<T: Send>() {}
972        fn assert_sync<T: Sync>() {}
973        assert_send::<BrowserPool>();
974        assert_send::<PoolStats>();
975        assert_sync::<BrowserPool>();
976    }
977
978    #[test]
979    fn pool_stats_zero_active_means_full_availability() {
980        let stats = PoolStats {
981            active: 0,
982            max: 8,
983            available: 8,
984            idle: 0,
985        };
986        assert_eq!(stats.available, stats.max);
987    }
988
989    #[test]
990    fn pool_entry_last_used_ordering() {
991        use std::time::{Duration, Instant};
992        let now = Instant::now();
993        let older = now.checked_sub(Duration::from_secs(400)).unwrap_or(now);
994        let idle_timeout = Duration::from_mins(5);
995        // Simulate eviction check: entry older than idle_timeout should be evicted
996        assert!(now.duration_since(older) >= idle_timeout);
997    }
998
999    #[test]
1000    fn pool_stats_debug_format() {
1001        let stats = PoolStats {
1002            active: 2,
1003            max: 10,
1004            available: 8,
1005            idle: 1,
1006        };
1007        let dbg = format!("{stats:?}");
1008        assert!(dbg.contains("active"));
1009        assert!(dbg.contains("max"));
1010    }
1011
1012    // ─── Context segregation tests ────────────────────────────────────────────
1013
1014    #[test]
1015    fn pool_inner_scoped_default_is_empty() {
1016        let inner = PoolInner {
1017            shared: std::collections::VecDeque::new(),
1018            scoped: std::collections::HashMap::new(),
1019        };
1020        assert!(inner.shared.is_empty());
1021        assert!(inner.scoped.is_empty());
1022    }
1023
1024    #[test]
1025    fn pool_inner_scoped_insert_and_retrieve() {
1026        let mut inner = PoolInner {
1027            shared: std::collections::VecDeque::new(),
1028            scoped: std::collections::HashMap::new(),
1029        };
1030        // Verify the scoped map key-space is independent
1031        inner.scoped.entry("bot-a".to_owned()).or_default();
1032        inner.scoped.entry("bot-b".to_owned()).or_default();
1033        assert_eq!(inner.scoped.len(), 2);
1034        assert!(inner.scoped.contains_key("bot-a"));
1035        assert!(inner.scoped.contains_key("bot-b"));
1036        assert!(inner.shared.is_empty());
1037    }
1038
1039    #[test]
1040    fn pool_inner_scoped_retain_removes_empty() {
1041        let mut inner = PoolInner {
1042            shared: std::collections::VecDeque::new(),
1043            scoped: std::collections::HashMap::new(),
1044        };
1045        inner.scoped.entry("empty".to_owned()).or_default();
1046        assert_eq!(inner.scoped.len(), 1);
1047        inner.scoped.retain(|_, q| !q.is_empty());
1048        assert!(inner.scoped.is_empty());
1049    }
1050
1051    #[tokio::test]
1052    async fn pool_context_ids_empty_by_default() {
1053        // Without a running Chrome, we test with min_size=0 so no browser
1054        // is launched. We need to construct the pool carefully.
1055        let config = test_config();
1056        assert_eq!(config.pool.min_size, 0);
1057        // context_ids requires an actual pool instance — this test verifies
1058        // the zero-state. Full integration tested with real browser.
1059    }
1060
1061    #[test]
1062    fn browser_handle_context_id_none_for_shared() {
1063        // Compile-time / structural: BrowserHandle carries context_id
1064        fn _check_context_api(handle: &BrowserHandle) {
1065            let _: Option<&str> = handle.context_id();
1066        }
1067    }
1068
1069    #[test]
1070    fn pool_inner_total_idle_calculation() {
1071        fn total_idle(inner: &PoolInner) -> usize {
1072            inner.shared.len()
1073                + inner
1074                    .scoped
1075                    .values()
1076                    .map(std::collections::VecDeque::len)
1077                    .sum::<usize>()
1078        }
1079        let mut inner = PoolInner {
1080            shared: std::collections::VecDeque::new(),
1081            scoped: std::collections::HashMap::new(),
1082        };
1083        assert_eq!(total_idle(&inner), 0);
1084
1085        // Add entries to scoped queues (without real BrowserInstance, just check sizes)
1086        inner.scoped.entry("a".to_owned()).or_default();
1087        inner.scoped.entry("b".to_owned()).or_default();
1088        assert_eq!(total_idle(&inner), 0); // empty queues don't count
1089    }
1090}