use std::time::{Duration, Instant};
use chromiumoxide::Browser;
use futures::StreamExt;
use tokio::time::timeout;
use tracing::{debug, info, warn};
use crate::{
BrowserConfig,
error::{BrowserError, Result},
};
pub struct BrowserInstance {
browser: Browser,
config: BrowserConfig,
launched_at: Instant,
healthy: bool,
id: String,
}
impl BrowserInstance {
pub async fn launch(config: BrowserConfig) -> Result<Self> {
let id = ulid::Ulid::new().to_string();
let launch_timeout = config.launch_timeout;
info!(browser_id = %id, "Launching browser");
let args = config.effective_args();
debug!(browser_id = %id, ?args, "Chrome launch arguments");
let mut builder = chromiumoxide::BrowserConfig::builder();
if !config.headless {
builder = builder.with_head();
} else if config.headless_mode == crate::config::HeadlessMode::New {
builder = builder.new_headless_mode();
}
if let Some(path) = &config.chrome_path {
builder = builder.chrome_executable(path);
}
let data_dir = config
.user_data_dir
.clone()
.unwrap_or_else(|| std::env::temp_dir().join(format!("stygian-{id}")));
builder = builder.user_data_dir(&data_dir);
for arg in &args {
let stripped = arg.strip_prefix("--").unwrap_or(arg.as_str());
builder = builder.arg(stripped);
}
if let Some((w, h)) = config.window_size {
builder = builder.window_size(w, h);
}
let cdp_cfg = builder
.build()
.map_err(|e| BrowserError::LaunchFailed { reason: e })?;
let (browser, mut handler) = timeout(launch_timeout, Browser::launch(cdp_cfg))
.await
.map_err(|_| BrowserError::Timeout {
operation: "browser.launch".to_string(),
duration_ms: u64::try_from(launch_timeout.as_millis()).unwrap_or(u64::MAX),
})?
.map_err(|e| BrowserError::LaunchFailed {
reason: e.to_string(),
})?;
tokio::spawn(async move { while handler.next().await.is_some() {} });
info!(browser_id = %id, "Browser launched successfully");
Ok(Self {
browser,
config,
launched_at: Instant::now(),
healthy: true,
id,
})
}
pub const fn is_healthy_cached(&self) -> bool {
self.healthy
}
pub async fn is_healthy(&mut self) -> bool {
match self.health_check().await {
Ok(()) => true,
Err(e) => {
warn!(browser_id = %self.id, error = %e, "Health check failed");
false
}
}
}
pub async fn health_check(&mut self) -> Result<()> {
let op_timeout = self.config.cdp_timeout;
timeout(op_timeout, self.browser.version())
.await
.map_err(|_| {
self.healthy = false;
BrowserError::Timeout {
operation: "Browser.getVersion".to_string(),
duration_ms: u64::try_from(op_timeout.as_millis()).unwrap_or(u64::MAX),
}
})?
.map_err(|e| {
self.healthy = false;
BrowserError::CdpError {
operation: "Browser.getVersion".to_string(),
message: e.to_string(),
}
})?;
self.healthy = true;
Ok(())
}
pub const fn browser(&self) -> &Browser {
&self.browser
}
pub const fn browser_mut(&mut self) -> &mut Browser {
&mut self.browser
}
pub fn id(&self) -> &str {
&self.id
}
pub fn uptime(&self) -> Duration {
self.launched_at.elapsed()
}
pub const fn config(&self) -> &BrowserConfig {
&self.config
}
pub async fn shutdown(mut self) -> Result<()> {
info!(browser_id = %self.id, "Shutting down browser");
let op_timeout = self.config.cdp_timeout;
if let Err(e) = timeout(op_timeout, self.browser.close()).await {
warn!(
browser_id = %self.id,
"Browser.close timed out after {}ms: {e}",
op_timeout.as_millis()
);
}
self.healthy = false;
info!(browser_id = %self.id, "Browser shut down");
Ok(())
}
pub async fn new_page(&self) -> crate::error::Result<crate::page::PageHandle> {
use tokio::time::timeout;
let cdp_timeout = self.config.cdp_timeout;
let page = timeout(cdp_timeout, self.browser.new_page("about:blank"))
.await
.map_err(|_| crate::error::BrowserError::Timeout {
operation: "Browser.newPage".to_string(),
duration_ms: u64::try_from(cdp_timeout.as_millis()).unwrap_or(u64::MAX),
})?
.map_err(|e| crate::error::BrowserError::CdpError {
operation: "Browser.newPage".to_string(),
message: e.to_string(),
})?;
#[cfg(feature = "stealth")]
crate::stealth::apply_stealth_to_page(&page, &self.config).await?;
Ok(crate::page::PageHandle::new(page, cdp_timeout))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn effective_args_contain_automation_flag() {
let config = BrowserConfig::default();
let args = config.effective_args();
assert!(
args.iter().any(|a| a.contains("AutomationControlled")),
"Expected --disable-blink-features=AutomationControlled in args: {args:?}"
);
}
#[test]
fn proxy_arg_injected_when_set() {
let config = BrowserConfig::builder()
.proxy("http://proxy.example.com:8080".to_string())
.build();
let args = config.effective_args();
assert!(
args.iter().any(|a| a.contains("proxy.example.com")),
"Expected proxy arg in {args:?}"
);
}
#[test]
fn window_size_arg_injected() {
let config = BrowserConfig::builder().window_size(1280, 720).build();
let args = config.effective_args();
assert!(
args.iter().any(|a| a.contains("1280")),
"Expected window-size arg in {args:?}"
);
}
#[test]
fn browser_instance_is_send_sync() {
fn assert_send<T: Send>() {}
fn assert_sync<T: Sync>() {}
assert_send::<BrowserInstance>();
assert_sync::<BrowserInstance>();
}
#[test]
fn no_sandbox_absent_by_default_on_non_linux() {
#[cfg(not(target_os = "linux"))]
{
let cfg = BrowserConfig::default();
let args = cfg.effective_args();
assert!(!args.iter().any(|a| a == "--no-sandbox"));
}
}
#[test]
fn effective_args_include_disable_dev_shm() {
let cfg = BrowserConfig::default();
let args = cfg.effective_args();
assert!(args.iter().any(|a| a.contains("disable-dev-shm-usage")));
}
#[test]
fn no_window_size_arg_when_none() {
let cfg = BrowserConfig {
window_size: None,
..BrowserConfig::default()
};
let args = cfg.effective_args();
assert!(!args.iter().any(|a| a.contains("--window-size")));
}
#[test]
fn custom_arg_appended() {
let cfg = BrowserConfig::builder()
.arg("--user-agent=MyCustomBot/1.0".to_string())
.build();
let args = cfg.effective_args();
assert!(args.iter().any(|a| a.contains("MyCustomBot")));
}
#[test]
fn proxy_bypass_list_arg_injected() {
let cfg = BrowserConfig::builder()
.proxy("http://proxy:8080".to_string())
.proxy_bypass_list("<local>,localhost".to_string())
.build();
let args = cfg.effective_args();
assert!(args.iter().any(|a| a.contains("proxy-bypass-list")));
}
#[test]
fn headless_mode_preserved_in_config() {
let cfg = BrowserConfig::builder().headless(false).build();
assert!(!cfg.headless);
let cfg2 = BrowserConfig::builder().headless(true).build();
assert!(cfg2.headless);
}
#[test]
fn launch_timeout_default_is_non_zero() {
let cfg = BrowserConfig::default();
assert!(!cfg.launch_timeout.is_zero());
}
#[test]
fn cdp_timeout_default_is_non_zero() {
let cfg = BrowserConfig::default();
assert!(!cfg.cdp_timeout.is_zero());
}
}