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}