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}