use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use tokio::task::JoinHandle as TokioJoinHandle;
use crate::config::BrowserPoolConfig;
use crate::error::{BrowserPoolError, Result};
use crate::factory::BrowserFactory;
use crate::handle::BrowserHandle;
use crate::stats::PoolStats;
use crate::tracked::TrackedBrowser;
pub(crate) struct BrowserPoolInner {
config: BrowserPoolConfig,
available: Mutex<Vec<Arc<TrackedBrowser>>>,
active: Mutex<HashMap<u64, Arc<TrackedBrowser>>>,
factory: Box<dyn BrowserFactory>,
shutting_down: AtomicBool,
replacement_tasks: Mutex<Vec<TokioJoinHandle<()>>>,
runtime_handle: tokio::runtime::Handle,
shutdown_signal: Arc<(Mutex<bool>, Condvar)>,
}
impl BrowserPoolInner {
pub(crate) fn new(config: BrowserPoolConfig, factory: Box<dyn BrowserFactory>) -> Arc<Self> {
log::info!(
"🚀 Initializing browser pool with capacity {}",
config.max_pool_size
);
log::debug!(
"📋 Pool config: warmup={}, TTL={}s, ping_interval={}s",
config.warmup_count,
config.browser_ttl.as_secs(),
config.ping_interval.as_secs()
);
let runtime_handle = tokio::runtime::Handle::current();
Arc::new(Self {
config,
available: Mutex::new(Vec::new()),
active: Mutex::new(HashMap::new()),
factory,
shutting_down: AtomicBool::new(false),
replacement_tasks: Mutex::new(Vec::new()),
runtime_handle,
shutdown_signal: Arc::new((Mutex::new(false), Condvar::new())),
})
}
#[cfg(test)]
pub(crate) fn new_for_test(
config: BrowserPoolConfig,
factory: Box<dyn BrowserFactory>,
runtime_handle: tokio::runtime::Handle,
) -> Self {
Self {
config,
available: Mutex::new(Vec::new()),
active: Mutex::new(HashMap::new()),
factory,
shutting_down: AtomicBool::new(false),
replacement_tasks: Mutex::new(Vec::new()),
runtime_handle,
shutdown_signal: Arc::new((Mutex::new(false), Condvar::new())),
}
}
pub(crate) fn create_browser_direct(&self) -> Result<Arc<TrackedBrowser>> {
if self.shutting_down.load(Ordering::Acquire) {
log::debug!("🛑 Skipping browser creation - pool is shutting down");
return Err(BrowserPoolError::ShuttingDown);
}
log::debug!("📦 Creating new browser directly via factory...");
let browser = self.factory.create()?;
let tracked = Arc::new(TrackedBrowser::new(browser)?);
let id = tracked.id();
if let Ok(mut active) = self.active.lock() {
active.insert(id, Arc::clone(&tracked));
log::debug!(
"📊 Browser {} added to active tracking (total active: {})",
id,
active.len()
);
} else {
log::warn!(
"⚠️ Failed to add browser {} to active tracking (poisoned lock)",
id
);
}
log::info!("✅ Created new browser with ID {}", id);
Ok(tracked)
}
pub(crate) fn get_or_create_browser(self: &Arc<Self>) -> Result<BrowserHandle> {
log::debug!("🔍 Attempting to get browser from pool...");
loop {
let tracked_opt = {
let mut available = self.available.lock().unwrap_or_else(|poisoned| {
log::warn!("Pool available lock poisoned, recovering");
poisoned.into_inner()
});
let popped = available.pop();
log::trace!("📊 Pool size after pop: {}", available.len());
popped
};
if let Some(tracked) = tracked_opt {
let age = tracked.created_at().elapsed();
let ttl = self.config.browser_ttl;
let safety_margin = Duration::from_secs(30);
if age + safety_margin > ttl {
log::debug!(
"⏳ Browser {} is near expiry (Age: {}s, Margin: 30s), skipping.",
tracked.id(),
age.as_secs()
);
continue;
}
let pool_size = {
let available = self.available.lock().unwrap_or_else(|poisoned| {
log::warn!("Pool available lock poisoned, recovering");
poisoned.into_inner()
});
available.len()
};
log::info!(
"♻️ Reusing healthy browser {} from pool (pool size: {})",
tracked.id(),
pool_size
);
return Ok(BrowserHandle::new(tracked, Arc::clone(self)));
} else {
log::debug!("📥 Pool is empty, will create new browser");
break;
}
}
log::info!("📦 Creating new browser (pool was empty or all browsers unhealthy)");
let tracked = self.create_browser_direct()?;
log::info!("✅ Returning newly created browser {}", tracked.id());
Ok(BrowserHandle::new(tracked, Arc::clone(self)))
}
pub(crate) fn return_browser(self_arc: &Arc<Self>, tracked: Arc<TrackedBrowser>) {
log::debug!("♻️ Returning browser {} to pool...", tracked.id());
if self_arc.shutting_down.load(Ordering::Acquire) {
log::debug!(
"🛑 Pool shutting down, not returning browser {}",
tracked.id()
);
return;
}
let mut active = self_arc.active.lock().unwrap_or_else(|poisoned| {
log::warn!("Pool active lock poisoned, recovering");
poisoned.into_inner()
});
let mut pool = self_arc.available.lock().unwrap_or_else(|poisoned| {
log::warn!("Pool available lock poisoned, recovering");
poisoned.into_inner()
});
if !active.contains_key(&tracked.id()) {
log::warn!(
"❌ Browser {} not in active tracking (probably already removed), skipping return",
tracked.id()
);
return;
}
if tracked.is_expired(self_arc.config.browser_ttl) {
log::info!(
"⏰ Browser {} expired (age: {}min, TTL: {}min), retiring instead of returning",
tracked.id(),
tracked.age_minutes(),
self_arc.config.browser_ttl.as_secs() / 60
);
active.remove(&tracked.id());
log::debug!("📊 Active browsers after TTL retirement: {}", active.len());
drop(active);
drop(pool);
log::debug!("🔍 Triggering replacement browser creation for expired browser");
Self::spawn_replacement_creation(Arc::clone(self_arc), 1);
return;
}
if !tracked.is_healthy() {
log::warn!(
"⚕️ Browser {} marked unhealthy, retiring instead of returning",
tracked.id()
);
active.remove(&tracked.id());
log::debug!(
"📊 Active browsers after health retirement: {}",
active.len()
);
drop(active);
drop(pool);
log::debug!("🔍 Triggering replacement browser creation for unhealthy browser");
Self::spawn_replacement_creation(Arc::clone(self_arc), 1);
return;
}
if pool.iter().any(|b| b.id() == tracked.id()) {
log::warn!(
"⚠️ Browser {} already in pool (duplicate return attempt), skipping",
tracked.id()
);
return;
}
if pool.len() < self_arc.config.max_pool_size {
pool.push(tracked.clone());
log::info!(
"♻️ Browser {} returned to pool (pool size: {}/{})",
tracked.id(),
pool.len(),
self_arc.config.max_pool_size
);
} else {
log::debug!(
"️ Pool full ({}/{}), removing browser {} from system",
pool.len(),
self_arc.config.max_pool_size,
tracked.id()
);
active.remove(&tracked.id());
log::debug!("📊 Active browsers after removal: {}", active.len());
}
}
async fn spawn_replacement_creation_async(inner: Arc<Self>, count: usize) {
log::info!(
"🔍 Starting async replacement creation for {} browsers",
count
);
let mut created_count = 0;
let mut failed_count = 0;
for i in 0..count {
if inner.shutting_down.load(Ordering::Acquire) {
log::info!(
"🛑 Shutdown detected during replacement creation, stopping at {}/{}",
i,
count
);
break;
}
let pool_has_space = {
let pool = inner.available.lock().unwrap_or_else(|poisoned| {
log::warn!("Pool available lock poisoned, recovering");
poisoned.into_inner()
});
let has_space = pool.len() < inner.config.max_pool_size;
log::trace!(
"📊 Pool space check: {}/{} (has space: {})",
pool.len(),
inner.config.max_pool_size,
has_space
);
has_space
};
if !pool_has_space {
log::warn!(
"⚠️ Pool is full, stopping replacement creation at {}/{}",
i,
count
);
break;
}
log::debug!("📦 Creating replacement browser {}/{}", i + 1, count);
let inner_clone = Arc::clone(&inner);
let result =
tokio::task::spawn_blocking(move || inner_clone.create_browser_direct()).await;
match result {
Ok(Ok(tracked)) => {
let id = tracked.id();
let mut pool = inner.available.lock().unwrap_or_else(|poisoned| {
log::warn!("Pool available lock poisoned, recovering");
poisoned.into_inner()
});
if pool.len() < inner.config.max_pool_size {
pool.push(tracked);
created_count += 1;
log::info!(
"✅ Created replacement browser {} and added to pool ({}/{})",
id,
i + 1,
count
);
} else {
log::warn!(
"⚠️ Pool became full during creation, replacement browser {} kept in active only",
id
);
created_count += 1; }
}
Ok(Err(e)) => {
failed_count += 1;
log::error!(
"❌ Failed to create replacement browser {}/{}: {}",
i + 1,
count,
e
);
}
Err(e) => {
failed_count += 1;
log::error!(
"❌ Replacement browser {}/{} task panicked: {:?}",
i + 1,
count,
e
);
}
}
}
let pool_size = inner
.available
.lock()
.unwrap_or_else(|poisoned| {
log::warn!("Pool available lock poisoned, recovering");
poisoned.into_inner()
})
.len();
let active_size = inner
.active
.lock()
.unwrap_or_else(|poisoned| {
log::warn!("Pool active lock poisoned, recovering");
poisoned.into_inner()
})
.len();
log::info!(
"🏁 Replacement creation completed: {}/{} created, {} failed. Pool: {}, Active: {}",
created_count,
count,
failed_count,
pool_size,
active_size
);
}
pub(crate) fn spawn_replacement_creation(inner: Arc<Self>, count: usize) {
log::info!(
"📥 Spawning async task to create {} replacement browsers",
count
);
let inner_for_task = Arc::clone(&inner);
let task_handle = inner.runtime_handle.spawn(async move {
Self::spawn_replacement_creation_async(inner_for_task, count).await;
});
if let Ok(mut tasks) = inner.replacement_tasks.lock() {
let original_count = tasks.len();
tasks.retain(|h| !h.is_finished());
let cleaned = original_count - tasks.len();
if cleaned > 0 {
log::trace!("🧹 Cleaned up {} finished replacement tasks", cleaned);
}
tasks.push(task_handle);
log::debug!("📋 Now tracking {} active replacement tasks", tasks.len());
} else {
log::warn!("⚠️ Failed to track replacement task (poisoned lock)");
}
}
#[inline]
pub(crate) fn config(&self) -> &BrowserPoolConfig {
&self.config
}
#[inline]
pub(crate) fn is_shutting_down(&self) -> bool {
self.shutting_down.load(Ordering::Acquire)
}
#[inline]
pub(crate) fn set_shutting_down(&self, value: bool) {
self.shutting_down.store(value, Ordering::Release);
}
#[inline]
pub(crate) fn shutdown_signal(&self) -> &Arc<(Mutex<bool>, Condvar)> {
&self.shutdown_signal
}
pub(crate) fn available_count(&self) -> usize {
self.available.lock().map(|g| g.len()).unwrap_or(0)
}
pub(crate) fn active_count(&self) -> usize {
self.active.lock().map(|g| g.len()).unwrap_or(0)
}
pub(crate) fn get_active_browsers_snapshot(&self) -> Vec<(u64, Arc<TrackedBrowser>)> {
let active = self.active.lock().unwrap_or_else(|poisoned| {
log::warn!("Pool active lock poisoned, recovering");
poisoned.into_inner()
});
active
.iter()
.map(|(id, tracked)| (*id, Arc::clone(tracked)))
.collect()
}
pub(crate) fn remove_from_active(&self, id: u64) -> Option<Arc<TrackedBrowser>> {
let mut active = self.active.lock().unwrap_or_else(|poisoned| {
log::warn!("Pool active lock poisoned, recovering");
poisoned.into_inner()
});
active.remove(&id)
}
pub(crate) fn remove_from_available(&self, ids: &[u64]) {
let mut pool = self.available.lock().unwrap_or_else(|poisoned| {
log::warn!("Pool available lock poisoned, recovering");
poisoned.into_inner()
});
let original_size = pool.len();
pool.retain(|b| !ids.contains(&b.id()));
let removed = original_size - pool.len();
if removed > 0 {
log::debug!("🗑️ Removed {} browsers from available pool", removed);
}
}
pub(crate) fn abort_replacement_tasks(&self) -> usize {
if let Ok(mut tasks) = self.replacement_tasks.lock() {
let count = tasks.len();
for handle in tasks.drain(..) {
handle.abort();
}
count
} else {
0
}
}
}
pub struct BrowserPool {
inner: Arc<BrowserPoolInner>,
keep_alive_handle: Option<JoinHandle<()>>,
}
impl BrowserPool {
pub fn into_shared(self) -> Arc<BrowserPool> {
log::debug!("🔍 Converting BrowserPool into shared Arc<BrowserPool>");
Arc::new(self)
}
pub fn builder() -> BrowserPoolBuilder {
BrowserPoolBuilder::new()
}
pub fn get(&self) -> Result<BrowserHandle> {
log::trace!("🎯 BrowserPool::get() called");
self.inner.get_or_create_browser()
}
pub fn stats(&self) -> PoolStats {
let available = self.inner.available_count();
let active = self.inner.active_count();
log::trace!("📊 Pool stats: available={}, active={}", available, active);
PoolStats {
available,
active,
total: active,
}
}
#[inline]
pub fn config(&self) -> &BrowserPoolConfig {
self.inner.config()
}
pub async fn warmup(&self) -> Result<()> {
let count = self.inner.config().warmup_count;
let warmup_timeout = self.inner.config().warmup_timeout;
log::info!(
"🔥 Starting browser pool warmup with {} instances (timeout: {}s)",
count,
warmup_timeout.as_secs()
);
let warmup_result = tokio::time::timeout(warmup_timeout, self.warmup_internal(count)).await;
match warmup_result {
Ok(Ok(())) => {
let stats = self.stats();
log::info!(
"✅ Warmup completed successfully - Available: {}, Active: {}",
stats.available,
stats.active
);
Ok(())
}
Ok(Err(e)) => {
log::error!("❌ Warmup failed with error: {}", e);
Err(e)
}
Err(_) => {
log::error!("❌ Warmup timed out after {}s", warmup_timeout.as_secs());
Err(BrowserPoolError::Configuration(format!(
"Warmup timed out after {}s",
warmup_timeout.as_secs()
)))
}
}
}
async fn warmup_internal(&self, count: usize) -> Result<()> {
log::debug!("🛠️ Starting internal warmup process for {} browsers", count);
let stagger_interval = self.config().warmup_stagger;
let mut handles = Vec::new();
let mut created_count = 0;
let mut failed_count = 0;
for i in 0..count {
log::debug!("🌐 Creating startup browser instance {}/{}", i + 1, count);
let browser_result = tokio::time::timeout(
Duration::from_secs(15),
tokio::task::spawn_blocking({
let inner = Arc::clone(&self.inner);
move || inner.create_browser_direct()
}),
)
.await;
match browser_result {
Ok(Ok(Ok(tracked))) => {
log::debug!(
"✅ Browser {} created, performing validation test...",
tracked.id()
);
match tracked.browser().new_tab() {
Ok(tab) => {
log::trace!("✅ Browser {} test: new_tab() successful", tracked.id());
let nav_result = tab.navigate_to(
"data:text/html,<html><body>Warmup test</body></html>",
);
if let Err(e) = nav_result {
log::warn!(
"⚠️ Browser {} test navigation failed: {}",
tracked.id(),
e
);
} else {
log::trace!(
"✅ Browser {} test: navigation successful",
tracked.id()
);
}
let _ = tab.close(true);
handles.push(BrowserHandle::new(tracked, Arc::clone(&self.inner)));
created_count += 1;
log::info!(
"✅ Browser instance {}/{} ready and validated",
i + 1,
count
);
}
Err(e) => {
failed_count += 1;
log::error!(
"❌ Browser {} validation test failed: {}",
tracked.id(),
e
);
self.inner.remove_from_active(tracked.id());
}
}
}
Ok(Ok(Err(e))) => {
failed_count += 1;
log::error!("❌ Failed to create browser {}/{}: {}", i + 1, count, e);
}
Ok(Err(e)) => {
failed_count += 1;
log::error!(
"❌ Browser {}/{} creation task panicked: {:?}",
i + 1,
count,
e
);
}
Err(_) => {
failed_count += 1;
log::error!(
"❌ Browser {}/{} creation timed out (15s limit)",
i + 1,
count
);
}
}
if i < count - 1 {
log::info!(
"⏳ Waiting {}s before creating next warmup browser to stagger TTLs...",
stagger_interval.as_secs()
);
tokio::time::sleep(stagger_interval).await;
}
}
log::info!(
"📊 Warmup creation phase: {} created, {} failed",
created_count,
failed_count
);
log::debug!("🔍 Returning {} warmup browsers to pool...", handles.len());
drop(handles);
let final_stats = self.stats();
log::info!(
"🏁 Warmup internal completed - Pool: {}, Active: {}",
final_stats.available,
final_stats.active
);
Ok(())
}
fn start_keep_alive(inner: Arc<BrowserPoolInner>) -> JoinHandle<()> {
let ping_interval = inner.config().ping_interval;
let max_failures = inner.config().max_ping_failures;
let browser_ttl = inner.config().browser_ttl;
let shutdown_signal = Arc::clone(inner.shutdown_signal());
log::info!(
"🚀 Starting keep-alive thread (interval: {}s, max failures: {}, TTL: {}min)",
ping_interval.as_secs(),
max_failures,
browser_ttl.as_secs() / 60
);
thread::spawn(move || {
log::info!("🏁 Keep-alive thread started successfully");
let mut failure_counts: HashMap<u64, u32> = HashMap::new();
loop {
let (lock, cvar) = &*shutdown_signal;
let wait_result = {
let shutdown = lock.lock().unwrap_or_else(|poisoned| {
log::warn!("Shutdown lock poisoned, recovering");
poisoned.into_inner()
});
cvar.wait_timeout(shutdown, ping_interval)
.unwrap_or_else(|poisoned| {
log::warn!("Condvar wait_timeout lock poisoned, recovering");
poisoned.into_inner()
})
};
let shutdown_flag = *wait_result.0;
let timed_out = wait_result.1.timed_out();
if shutdown_flag {
log::info!("🛑 Keep-alive received shutdown signal via condvar");
break;
}
if inner.is_shutting_down() {
log::info!("🛑 Keep-alive detected shutdown via atomic flag");
break;
}
if !timed_out {
log::trace!("⏰ Keep-alive spuriously woken, continuing wait...");
continue;
}
log::trace!("⚡ Keep-alive ping cycle starting...");
let browsers_to_ping = inner.get_active_browsers_snapshot();
log::trace!(
"Keep-alive checking {} active browsers",
browsers_to_ping.len()
);
let mut to_remove = Vec::new();
let mut expired_browsers = Vec::new();
for (id, tracked) in browsers_to_ping {
if inner.is_shutting_down() {
log::info!("Shutdown detected during ping loop, exiting immediately");
return;
}
if tracked.is_expired(browser_ttl) {
log::info!(
"Browser {} expired (age: {}min, TTL: {}min), marking for retirement",
id,
tracked.age_minutes(),
browser_ttl.as_secs() / 60
);
expired_browsers.push(id);
continue; }
use crate::traits::Healthcheck;
match tracked.ping() {
Ok(_) => {
if failure_counts.remove(&id).is_some() {
log::debug!("Browser {} ping successful, failure count reset", id);
}
}
Err(e) => {
if !inner.is_shutting_down() {
let failures = failure_counts.entry(id).or_insert(0);
*failures += 1;
log::warn!(
"Browser {} ping failed (attempt {}/{}): {}",
id,
failures,
max_failures,
e
);
if *failures >= max_failures {
log::error!(
"Browser {} exceeded max ping failures ({}), marking for removal",
id,
max_failures
);
to_remove.push(id);
}
}
}
}
}
if inner.is_shutting_down() {
log::info!("Shutdown detected before cleanup, skipping and exiting");
break;
}
if !expired_browsers.is_empty() {
log::info!("Processing {} TTL-expired browsers", expired_browsers.len());
Self::handle_browser_retirement(&inner, expired_browsers, &mut failure_counts);
}
if !to_remove.is_empty() {
log::warn!("Removing {} failed browsers from pool", to_remove.len());
let mut actual_removed_count = 0;
for id in &to_remove {
if inner.remove_from_active(*id).is_some() {
actual_removed_count += 1;
log::debug!("Removed failed browser {} from active tracking", id);
}
failure_counts.remove(id);
}
log::debug!(
"Active browsers after failure cleanup: {}",
inner.active_count()
);
inner.remove_from_available(&to_remove);
log::debug!("Pool size after cleanup: {}", inner.available_count());
if actual_removed_count > 0 {
log::info!(
"Spawning {} replacement browsers for failed ones",
actual_removed_count
);
BrowserPoolInner::spawn_replacement_creation(
Arc::clone(&inner),
actual_removed_count,
);
}
}
log::debug!(
"Keep-alive cycle complete - Active: {}, Pooled: {}, Tracking {} failure states",
inner.active_count(),
inner.available_count(),
failure_counts.len()
);
}
log::info!("Keep-alive thread exiting cleanly");
})
}
fn handle_browser_retirement(
inner: &Arc<BrowserPoolInner>,
expired_ids: Vec<u64>,
failure_counts: &mut HashMap<u64, u32>,
) {
log::info!(
"Retiring {} expired browsers (TTL enforcement)",
expired_ids.len()
);
let mut retired_count = 0;
for id in &expired_ids {
if inner.remove_from_active(*id).is_some() {
retired_count += 1;
log::debug!("Removed expired browser {} from active tracking", id);
}
failure_counts.remove(id);
}
inner.remove_from_available(&expired_ids);
log::debug!(
"After retirement - Active: {}, Pooled: {}",
inner.active_count(),
inner.available_count()
);
if retired_count > 0 {
log::info!(
"Spawning {} replacement browsers for retired ones",
retired_count
);
BrowserPoolInner::spawn_replacement_creation(Arc::clone(inner), retired_count);
} else {
log::debug!("No browsers were actually retired (already removed)");
}
}
pub async fn shutdown_async(&mut self) {
log::info!("Shutting down browser pool (async mode)...");
self.inner.set_shutting_down(true);
log::debug!("Shutdown flag set");
{
let (lock, cvar) = &**self.inner.shutdown_signal();
let mut shutdown = lock.lock().unwrap_or_else(|poisoned| {
log::warn!("Shutdown lock poisoned, recovering");
poisoned.into_inner()
});
*shutdown = true;
cvar.notify_all();
log::debug!("Shutdown signal sent to keep-alive thread");
}
if let Some(handle) = self.keep_alive_handle.take() {
log::debug!("Waiting for keep-alive thread to exit...");
let join_task = tokio::task::spawn_blocking(move || handle.join());
match tokio::time::timeout(Duration::from_secs(5), join_task).await {
Ok(Ok(Ok(_))) => {
log::info!("Keep-alive thread stopped cleanly");
}
Ok(Ok(Err(_))) => {
log::error!("Keep-alive thread panicked during shutdown");
}
Ok(Err(_)) => {
log::error!("Keep-alive join task panicked");
}
Err(_) => {
log::error!("Keep-alive thread didn't exit within 5s timeout");
}
}
} else {
log::debug!("No keep-alive thread to stop (was disabled or already stopped)");
}
log::info!("Aborting replacement creation tasks...");
let aborted_count = self.inner.abort_replacement_tasks();
if aborted_count > 0 {
log::info!("Aborted {} replacement tasks", aborted_count);
} else {
log::debug!("No replacement tasks to abort");
}
tokio::time::sleep(Duration::from_millis(100)).await;
let stats = self.stats();
log::info!(
"Async shutdown complete - Available: {}, Active: {}, Total: {}",
stats.available,
stats.active,
stats.total
);
}
pub fn shutdown(&mut self) {
log::debug!("Calling synchronous shutdown...");
self.shutdown_sync();
}
fn shutdown_sync(&mut self) {
log::info!("Shutting down browser pool (sync mode)...");
self.inner.set_shutting_down(true);
log::debug!("Shutdown flag set");
{
let (lock, cvar) = &**self.inner.shutdown_signal();
let mut shutdown = lock.lock().unwrap_or_else(|poisoned| {
log::warn!("Shutdown lock poisoned, recovering");
poisoned.into_inner()
});
*shutdown = true;
cvar.notify_all();
log::debug!("Shutdown signal sent");
}
if let Some(handle) = self.keep_alive_handle.take() {
log::debug!("Joining keep-alive thread (sync)...");
match handle.join() {
Ok(_) => log::info!("Keep-alive thread stopped"),
Err(_) => log::error!("Keep-alive thread panicked"),
}
}
let aborted_count = self.inner.abort_replacement_tasks();
if aborted_count > 0 {
log::debug!("Aborted {} replacement tasks (sync mode)", aborted_count);
}
let stats = self.stats();
log::info!(
"Sync shutdown complete - Available: {}, Active: {}",
stats.available,
stats.active
);
}
#[doc(hidden)]
#[allow(dead_code)]
pub(crate) fn inner(&self) -> &Arc<BrowserPoolInner> {
&self.inner
}
}
impl Drop for BrowserPool {
fn drop(&mut self) {
log::debug!("🛑 BrowserPool Drop triggered - running cleanup");
if !self.inner.is_shutting_down() {
log::warn!("⚠ BrowserPool dropped without explicit shutdown - cleaning up");
self.shutdown();
} else {
log::debug!(" Pool already shutdown, Drop is no-op");
}
}
}
pub struct BrowserPoolBuilder {
config: Option<BrowserPoolConfig>,
factory: Option<Box<dyn BrowserFactory>>,
enable_keep_alive: bool,
}
impl BrowserPoolBuilder {
pub fn new() -> Self {
Self {
config: None,
factory: None,
enable_keep_alive: true,
}
}
pub fn config(mut self, config: BrowserPoolConfig) -> Self {
self.config = Some(config);
self
}
pub fn factory(mut self, factory: Box<dyn BrowserFactory>) -> Self {
self.factory = Some(factory);
self
}
pub fn enable_keep_alive(mut self, enable: bool) -> Self {
self.enable_keep_alive = enable;
self
}
pub fn build(self) -> Result<BrowserPool> {
let config = self.config.unwrap_or_default();
let factory = self.factory.ok_or_else(|| {
BrowserPoolError::Configuration("No browser factory provided".to_string())
})?;
log::info!("📦 Building browser pool with config: {:?}", config);
let inner = BrowserPoolInner::new(config, factory);
let keep_alive_handle = if self.enable_keep_alive {
log::info!("🚀 Starting keep-alive monitoring thread");
Some(BrowserPool::start_keep_alive(Arc::clone(&inner)))
} else {
log::warn!("⚠️ Keep-alive thread disabled (should only be used for testing)");
None
};
log::info!("✅ Browser pool built successfully");
Ok(BrowserPool {
inner,
keep_alive_handle,
})
}
}
impl Default for BrowserPoolBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "env-config")]
pub async fn init_browser_pool() -> Result<Arc<BrowserPool>> {
use crate::config::env::{chrome_path_from_env, from_env};
use crate::factory::ChromeBrowserFactory;
log::info!("Initializing browser pool from environment...");
let config = from_env()?;
let chrome_path = chrome_path_from_env();
log::info!("Pool configuration from environment:");
log::info!(" - Max pool size: {}", config.max_pool_size);
log::info!(" - Warmup count: {}", config.warmup_count);
log::info!(
" - Browser TTL: {}s ({}min)",
config.browser_ttl.as_secs(),
config.browser_ttl.as_secs() / 60
);
log::info!(" - Warmup timeout: {}s", config.warmup_timeout.as_secs());
log::info!(
" - Chrome path: {}",
chrome_path.as_deref().unwrap_or("auto-detect")
);
let factory: Box<dyn BrowserFactory> = match chrome_path {
Some(path) => {
log::info!("Using custom Chrome path: {}", path);
Box::new(ChromeBrowserFactory::with_path(path))
}
None => {
log::info!("Using auto-detected Chrome browser");
Box::new(ChromeBrowserFactory::with_defaults())
}
};
log::debug!("Building browser pool...");
let pool = BrowserPool::builder()
.config(config.clone())
.factory(factory)
.enable_keep_alive(true)
.build()
.map_err(|e| {
log::error!("❌ Failed to create browser pool: {}", e);
e
})?;
log::info!("✅ Browser pool created successfully");
log::info!(
"Warming up browser pool with {} instances...",
config.warmup_count
);
pool.warmup().await.map_err(|e| {
log::error!("❌ Failed to warmup pool: {}", e);
e
})?;
let stats = pool.stats();
log::info!(
"✅ Browser pool ready - Available: {}, Active: {}, Total: {}",
stats.available,
stats.active,
stats.total
);
Ok(pool.into_shared())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pool_builder_missing_factory() {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
let config = crate::config::BrowserPoolConfigBuilder::new()
.max_pool_size(3)
.build()
.unwrap();
let result = BrowserPool::builder()
.config(config)
.build();
assert!(result.is_err(), "Build should fail without factory");
match result {
Err(BrowserPoolError::Configuration(msg)) => {
assert!(
msg.contains("No browser factory provided"),
"Expected factory error, got: {}",
msg
);
}
_ => panic!("Expected Configuration error for missing factory"),
}
});
}
#[test]
fn test_builder_default() {
let builder: BrowserPoolBuilder = Default::default();
assert!(builder.config.is_none());
assert!(builder.factory.is_none());
assert!(builder.enable_keep_alive);
}
#[test]
fn test_builder_disable_keep_alive() {
let builder = BrowserPoolBuilder::new().enable_keep_alive(false);
assert!(!builder.enable_keep_alive);
}
}