use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use chromiumoxide::Page;
use tokio::sync::{Mutex, Semaphore};
use tracing::{debug, info, warn};
use super::manager::{BrowserManager, BrowserOptions};
use crate::error::{AuditError, Result};
const PAGE_RESET_TIMEOUT: Duration = Duration::from_secs(10);
const PAGE_CLOSE_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) const PAGE_RETURN_BUDGET_SECS: u64 =
PAGE_RESET_TIMEOUT.as_secs() + PAGE_CLOSE_TIMEOUT.as_secs();
#[derive(Debug, Clone)]
pub struct PoolConfig {
pub max_pages: usize,
pub browser_options: BrowserOptions,
pub acquire_timeout_secs: u64,
}
impl Default for PoolConfig {
fn default() -> Self {
Self {
max_pages: 4,
browser_options: BrowserOptions::default(),
acquire_timeout_secs: 60,
}
}
}
pub struct PooledPage {
page: Option<Page>,
pool: Arc<BrowserPoolInner>,
}
impl PooledPage {
pub fn page(&self) -> Result<&Page> {
self.page.as_ref().ok_or_else(|| {
crate::error::AuditError::ConfigError("Page already returned to pool".to_string())
})
}
}
impl Drop for PooledPage {
fn drop(&mut self) {
if let Some(page) = self.page.take() {
if tokio::runtime::Handle::try_current().is_ok() {
let pool = Arc::clone(&self.pool);
tokio::spawn(async move {
pool.return_page(page).await;
});
} else {
warn!("No active tokio runtime; dropping page without returning it to the pool");
}
}
}
}
struct BrowserPoolInner {
browser: BrowserManager,
pages: Mutex<Vec<Page>>,
semaphore: Semaphore,
pages_created: AtomicUsize,
max_pages: usize,
acquire_timeout: Duration,
page_create_timeout: Duration,
}
impl BrowserPoolInner {
async fn return_page(&self, page: Page) {
let reset = tokio::time::timeout(PAGE_RESET_TIMEOUT, page.goto("about:blank")).await;
match reset {
Ok(Ok(_)) => {
let mut pages = self.pages.lock().await;
pages.push(page);
self.semaphore.add_permits(1);
debug!("Page returned to pool ({} available)", pages.len());
}
Ok(Err(e)) => {
warn!("Failed to reset page: {}", e);
self.discard_page(page).await;
}
Err(_) => {
warn!(
"Page reset timed out after {}s, discarding page",
PAGE_RESET_TIMEOUT.as_secs()
);
self.discard_page(page).await;
}
}
}
async fn discard_page(&self, page: Page) {
match tokio::time::timeout(PAGE_CLOSE_TIMEOUT, page.close()).await {
Ok(Ok(())) => {}
Ok(Err(e)) => warn!("Failed to close discarded page: {}", e),
Err(_) => warn!(
"Closing discarded page timed out after {}s",
PAGE_CLOSE_TIMEOUT.as_secs()
),
}
self.pages_created.fetch_sub(1, Ordering::SeqCst);
self.semaphore.add_permits(1);
}
}
pub struct BrowserPool {
inner: Arc<BrowserPoolInner>,
}
impl BrowserPool {
pub async fn new(config: PoolConfig) -> Result<Self> {
info!("Creating browser pool with max {} pages", config.max_pages);
let page_create_timeout = Duration::from_secs(config.browser_options.timeout_secs);
let browser = BrowserManager::with_options(config.browser_options).await?;
let inner = Arc::new(BrowserPoolInner {
browser,
pages: Mutex::new(Vec::with_capacity(config.max_pages)),
semaphore: Semaphore::new(config.max_pages),
pages_created: AtomicUsize::new(0),
max_pages: config.max_pages,
acquire_timeout: Duration::from_secs(config.acquire_timeout_secs),
page_create_timeout,
});
Ok(Self { inner })
}
pub async fn with_concurrency(concurrency: usize) -> Result<Self> {
Self::new(PoolConfig {
max_pages: concurrency,
..Default::default()
})
.await
}
pub async fn acquire(&self) -> Result<PooledPage> {
debug!("Acquiring page from pool...");
let permit =
tokio::time::timeout(self.inner.acquire_timeout, self.inner.semaphore.acquire())
.await
.map_err(|_| AuditError::PoolTimeout {
timeout_secs: self.inner.acquire_timeout.as_secs(),
})?
.map_err(|_| AuditError::PoolClosed)?;
permit.forget();
let mut pages = self.inner.pages.lock().await;
if let Some(page) = pages.pop() {
debug!("Reusing existing page from pool");
return Ok(PooledPage {
page: Some(page),
pool: Arc::clone(&self.inner),
});
}
drop(pages);
let current = self.inner.pages_created.fetch_add(1, Ordering::SeqCst);
if current >= self.inner.max_pages {
self.inner.pages_created.fetch_sub(1, Ordering::SeqCst);
return Err(AuditError::PoolExhausted);
}
debug!(
"Creating new page ({}/{})",
current + 1,
self.inner.max_pages
);
let created = tokio::time::timeout(
self.inner.page_create_timeout,
self.inner.browser.new_page(),
)
.await
.unwrap_or_else(|_| {
Err(AuditError::BrowserLaunchFailed {
reason: format!(
"Failed to create new page: no answer after {} seconds",
self.inner.page_create_timeout.as_secs()
),
})
});
let page = match created {
Ok(page) => page,
Err(e) => {
self.inner.pages_created.fetch_sub(1, Ordering::SeqCst);
self.inner.semaphore.add_permits(1);
return Err(e);
}
};
Ok(PooledPage {
page: Some(page),
pool: Arc::clone(&self.inner),
})
}
pub fn browser(&self) -> &BrowserManager {
&self.inner.browser
}
pub async fn available_pages(&self) -> usize {
self.inner.pages.lock().await.len()
}
pub fn stats(&self) -> PoolStats {
PoolStats {
max_pages: self.inner.max_pages,
pages_created: self.inner.pages_created.load(Ordering::SeqCst),
permits_available: self.inner.semaphore.available_permits(),
}
}
pub async fn close(self) -> Result<()> {
info!("Closing browser pool...");
{
let mut pages = self.inner.pages.lock().await;
for page in pages.drain(..) {
if let Err(e) = page.close().await {
warn!("Failed to close pooled page: {}", e);
}
}
}
let mut inner = Arc::try_unwrap(self.inner);
for _ in 0..20 {
if inner.is_ok() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
inner = match inner {
Ok(i) => Ok(i),
Err(arc) => Arc::try_unwrap(arc),
};
}
match inner {
Ok(inner) => {
if let Err(e) = inner.browser.close().await {
warn!("Failed to close browser: {}", e);
}
}
Err(_) => {
warn!(
"Browser pool inner state still had outstanding references after \
waiting; the browser process may not shut down cleanly"
);
}
}
info!("Browser pool closed");
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct PoolStats {
pub max_pages: usize,
pub pages_created: usize,
pub permits_available: usize,
}
impl std::fmt::Display for PoolStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Pool: {}/{} pages, {} available",
self.pages_created, self.max_pages, self.permits_available
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pool_config_default() {
let config = PoolConfig::default();
assert_eq!(config.max_pages, 4);
assert_eq!(config.acquire_timeout_secs, 60);
}
#[test]
fn test_pool_stats_display() {
let stats = PoolStats {
max_pages: 4,
pages_created: 2,
permits_available: 2,
};
let display = format!("{}", stats);
assert!(display.contains("2/4"));
assert!(display.contains("2 available"));
}
}