1use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4use std::sync::Arc;
5#[cfg(feature = "lightpanda")]
6use std::time::Duration;
7
8use chromiumoxide::browser::{Browser, BrowserConfig};
9use futures::StreamExt;
10use tokio::sync::{Mutex, Semaphore};
11use tracing::{debug, warn};
12
13use a3s_use_core::{UseError, UseResult};
14
15#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub enum BrowserBackend {
18 #[default]
20 Chrome,
21
22 #[cfg(feature = "lightpanda")]
24 Lightpanda,
25}
26
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub enum BrowserProvider {
30 #[default]
32 DiscoveredChrome,
33 ManagedChrome,
35 ChromeExecutable(std::path::PathBuf),
37
38 #[cfg(feature = "lightpanda")]
40 DiscoveredLightpanda,
41 #[cfg(feature = "lightpanda")]
43 ManagedLightpanda,
44 #[cfg(feature = "lightpanda")]
46 LightpandaExecutable(std::path::PathBuf),
47}
48
49impl BrowserProvider {
50 pub fn backend(&self) -> BrowserBackend {
51 match self {
52 Self::DiscoveredChrome | Self::ManagedChrome | Self::ChromeExecutable(_) => {
53 BrowserBackend::Chrome
54 }
55 #[cfg(feature = "lightpanda")]
56 Self::DiscoveredLightpanda
57 | Self::ManagedLightpanda
58 | Self::LightpandaExecutable(_) => BrowserBackend::Lightpanda,
59 }
60 }
61}
62
63#[derive(Debug, Clone)]
65pub struct BrowserPoolConfig {
66 pub max_tabs: usize,
68 pub headless: bool,
70 pub provider: BrowserProvider,
72 pub proxy_url: Option<String>,
74 pub launch_args: Vec<String>,
76}
77
78impl Default for BrowserPoolConfig {
79 fn default() -> Self {
80 Self {
81 max_tabs: 4,
82 headless: true,
83 provider: BrowserProvider::default(),
84 proxy_url: None,
85 launch_args: Vec::new(),
86 }
87 }
88}
89
90pub struct BrowserPool {
96 config: BrowserPoolConfig,
97 chrome_profile_dir: std::path::PathBuf,
98 closed: AtomicBool,
99 runtime: Mutex<BrowserRuntime>,
100 tab_semaphore: Arc<Semaphore>,
101}
102
103#[derive(Default)]
104struct BrowserRuntime {
105 browser: Option<Arc<Browser>>,
106 child: Option<tokio::process::Child>,
107}
108
109impl BrowserPool {
110 pub fn new(config: BrowserPoolConfig) -> Self {
112 static NEXT_PROFILE_ID: AtomicU64 = AtomicU64::new(1);
113 let max_tabs = config.max_tabs.max(1);
114 let chrome_profile_dir = std::env::temp_dir().join(format!(
115 "a3s-use-chrome-{}-{}",
116 std::process::id(),
117 NEXT_PROFILE_ID.fetch_add(1, Ordering::Relaxed)
118 ));
119 Self {
120 config,
121 chrome_profile_dir,
122 closed: AtomicBool::new(false),
123 runtime: Mutex::new(BrowserRuntime::default()),
124 tab_semaphore: Arc::new(Semaphore::new(max_tabs)),
125 }
126 }
127
128 pub(crate) fn tab_semaphore(&self) -> &Arc<Semaphore> {
130 &self.tab_semaphore
131 }
132
133 #[cfg(feature = "lightpanda")]
134 pub(crate) fn uses_lightpanda(&self) -> bool {
135 self.config.provider.backend() == BrowserBackend::Lightpanda
136 }
137
138 #[cfg(feature = "lightpanda")]
139 pub(crate) fn ensure_open(&self) -> UseResult<()> {
140 (!self.closed.load(Ordering::Acquire))
141 .then_some(())
142 .ok_or_else(|| browser_error("Browser pool has already been shut down".to_string()))
143 }
144
145 #[cfg(feature = "lightpanda")]
146 pub(crate) fn lightpanda_proxy_url(&self) -> Option<&str> {
147 self.config.proxy_url.as_deref()
148 }
149
150 #[cfg(feature = "lightpanda")]
151 pub(crate) async fn lightpanda_executable(&self) -> UseResult<std::path::PathBuf> {
152 match &self.config.provider {
153 BrowserProvider::DiscoveredLightpanda => crate::lightpanda::resolve_lightpanda(),
154 BrowserProvider::ManagedLightpanda => crate::lightpanda::ensure_lightpanda().await,
155 BrowserProvider::LightpandaExecutable(path) => Ok(path.clone()),
156 _ => Err(browser_error(
157 "The selected provider is not Lightpanda-compatible.",
158 )),
159 }
160 }
161
162 pub fn available_tab_permits(&self) -> usize {
164 self.tab_semaphore.available_permits()
165 }
166
167 pub async fn warm_up(&self) -> UseResult<()> {
169 self.acquire_browser().await.map(|_| ())
170 }
171
172 pub(crate) async fn acquire_browser(&self) -> UseResult<Arc<Browser>> {
174 if self.closed.load(Ordering::Acquire) {
175 return Err(browser_error(
176 "Browser pool has already been shut down".to_string(),
177 ));
178 }
179 #[cfg(feature = "lightpanda")]
180 if self.config.provider.backend() == BrowserBackend::Lightpanda {
181 return self.acquire_lightpanda().await;
182 }
183
184 self.acquire_chrome().await
185 }
186
187 async fn acquire_chrome(&self) -> UseResult<Arc<Browser>> {
188 let mut runtime = self.runtime.lock().await;
189 if self.closed.load(Ordering::Acquire) {
190 return Err(browser_error(
191 "Browser pool has already been shut down".to_string(),
192 ));
193 }
194
195 if let Some(ref browser) = runtime.browser {
196 return Ok(Arc::clone(browser));
197 }
198
199 debug!("Launching Chrome headless browser");
200
201 let mut builder = BrowserConfig::builder().user_data_dir(&self.chrome_profile_dir);
202
203 if self.config.headless {
204 builder = builder.arg("--headless=new");
205 }
206
207 let chrome_path = match &self.config.provider {
208 BrowserProvider::DiscoveredChrome => crate::chrome::resolve_chrome()?,
209 BrowserProvider::ManagedChrome => crate::chrome::ensure_chrome().await?,
210 BrowserProvider::ChromeExecutable(path) => path.clone(),
211 #[cfg(feature = "lightpanda")]
212 _ => {
213 return Err(browser_error(
214 "The selected provider is not Chrome-compatible.",
215 ))
216 }
217 };
218 debug!("Using Chrome at: {}", chrome_path.display());
219 builder = builder.chrome_executable(chrome_path);
220
221 builder = builder.arg(
222 "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
223 AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
224 );
225
226 builder = builder.arg("--disable-blink-features=AutomationControlled");
227
228 builder = builder
229 .arg("--disable-gpu")
230 .arg("--no-sandbox")
231 .arg("--disable-dev-shm-usage")
232 .arg("--disable-extensions")
233 .arg("--disable-background-networking")
234 .arg("--disable-default-apps")
235 .arg("--disable-sync")
236 .arg("--disable-translate")
237 .arg("--mute-audio")
238 .arg("--no-first-run");
239
240 if let Some(ref proxy) = self.config.proxy_url {
241 builder = builder.arg(format!("--proxy-server={}", proxy));
242 }
243
244 for arg in &self.config.launch_args {
245 builder = builder.arg(arg);
246 }
247
248 let browser_config = builder
249 .build()
250 .map_err(|e| browser_error(format!("Failed to build browser config: {}", e)))?;
251
252 let (browser, mut handler) = Browser::launch(browser_config)
253 .await
254 .map_err(|e| browser_error(format!("Failed to launch Chrome: {}", e)))?;
255
256 tokio::spawn(async move {
257 while let Some(event) = handler.next().await {
258 if let Err(e) = event {
259 warn!("Chrome CDP handler error: {}", e);
260 }
261 }
262 debug!("Chrome CDP handler exited");
263 });
264
265 let browser = Arc::new(browser);
266 runtime.browser = Some(Arc::clone(&browser));
267
268 Ok(browser)
269 }
270
271 #[cfg(feature = "lightpanda")]
272 async fn acquire_lightpanda(&self) -> UseResult<Arc<Browser>> {
273 let mut runtime = self.runtime.lock().await;
274 if self.closed.load(Ordering::Acquire) {
275 return Err(browser_error(
276 "Browser pool has already been shut down".to_string(),
277 ));
278 }
279
280 if let Some(ref browser) = runtime.browser {
281 return Ok(Arc::clone(browser));
282 }
283
284 debug!("Launching Lightpanda browser");
285
286 let lp_path = self.lightpanda_executable().await?;
287
288 let port = find_free_port()?;
289
290 let child = tokio::process::Command::new(&lp_path)
291 .args(["serve", "--host", "127.0.0.1", "--port", &port.to_string()])
292 .kill_on_drop(true)
293 .spawn()
294 .map_err(|e| {
295 browser_error(format!(
296 "Failed to spawn Lightpanda ({}): {}",
297 lp_path.display(),
298 e
299 ))
300 })?;
301 runtime.child = Some(child);
302
303 if let Err(error) = wait_for_cdp_ready("127.0.0.1", port, Duration::from_secs(10)).await {
304 crate::cleanup::finish_child_cleanup(runtime.child.take()).await;
305 return Err(error);
306 }
307
308 let ws_url = format!("ws://127.0.0.1:{}", port);
309 debug!("Connecting to Lightpanda CDP at {}", ws_url);
310
311 let (browser, mut handler) = match Browser::connect(&ws_url).await {
312 Ok(connected) => connected,
313 Err(error) => {
314 crate::cleanup::finish_child_cleanup(runtime.child.take()).await;
315 return Err(browser_error(format!(
316 "Failed to connect to Lightpanda: {}",
317 error
318 )));
319 }
320 };
321
322 tokio::spawn(async move {
323 while let Some(event) = handler.next().await {
324 if let Err(e) = event {
325 warn!("Lightpanda CDP handler error: {}", e);
326 }
327 }
328 debug!("Lightpanda CDP handler exited");
329 });
330
331 let browser = Arc::new(browser);
332 runtime.browser = Some(Arc::clone(&browser));
333
334 Ok(browser)
335 }
336
337 pub async fn shutdown(&self) {
347 self.closed.store(true, Ordering::Release);
348 self.tab_semaphore.close();
349 let runtime = {
350 let mut guard = self.runtime.lock().await;
351 std::mem::take(&mut *guard)
352 };
353 let profile_dir = self.chrome_profile_dir.clone();
354 let cleanup = tokio::spawn(async move {
355 let browser_reaped = crate::cleanup::close_and_reap_browser(runtime.browser).await;
356 let _ = crate::cleanup::kill_and_reap_child(runtime.child).await;
357 if browser_reaped {
358 match tokio::fs::remove_dir_all(&profile_dir).await {
359 Ok(()) => debug!("Removed browser profile {}", profile_dir.display()),
360 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
361 Err(error) => warn!(
362 "Failed to remove browser profile {}: {}",
363 profile_dir.display(),
364 error
365 ),
366 }
367 }
368 });
369 if let Err(error) = cleanup.await {
370 warn!("Browser cleanup task failed: {}", error);
371 }
372 }
373}
374
375#[allow(dead_code)]
376fn find_free_port() -> UseResult<u16> {
377 let listener = std::net::TcpListener::bind("127.0.0.1:0")
378 .map_err(|e| browser_error(format!("Failed to find a free port: {}", e)))?;
379 let port = listener
380 .local_addr()
381 .map_err(|e| browser_error(format!("Failed to read assigned port: {}", e)))?
382 .port();
383 Ok(port)
384}
385
386#[cfg(feature = "lightpanda")]
387async fn wait_for_cdp_ready(host: &str, port: u16, timeout: Duration) -> UseResult<()> {
388 let addr = format!("{}:{}", host, port);
389 let deadline = tokio::time::Instant::now() + timeout;
390
391 loop {
392 if tokio::time::Instant::now() >= deadline {
393 return Err(browser_error(format!(
394 "Timed out waiting for CDP server at {} to become ready",
395 addr
396 )));
397 }
398
399 if tokio::net::TcpStream::connect(&addr).await.is_ok() {
400 debug!("CDP server at {} is ready", addr);
401 return Ok(());
402 }
403
404 tokio::time::sleep(Duration::from_millis(100)).await;
405 }
406}
407
408pub(crate) fn browser_error(message: impl Into<String>) -> UseError {
409 UseError::new("use.browser.provider_failed", message)
410}
411
412#[cfg(test)]
413mod tests {
414 use super::*;
415
416 #[test]
417 fn default_provider_discovers_chrome_without_authorizing_downloads() {
418 let config = BrowserPoolConfig::default();
419 assert_eq!(config.provider, BrowserProvider::DiscoveredChrome);
420 assert_eq!(config.provider.backend(), BrowserBackend::Chrome);
421 }
422
423 #[test]
424 fn zero_tab_configuration_is_clamped_to_one() {
425 let pool = BrowserPool::new(BrowserPoolConfig {
426 max_tabs: 0,
427 ..BrowserPoolConfig::default()
428 });
429 assert_eq!(pool.available_tab_permits(), 1);
430 }
431
432 #[tokio::test]
433 async fn shutdown_is_idempotent_and_prevents_restart() {
434 let pool = BrowserPool::new(BrowserPoolConfig::default());
435 pool.shutdown().await;
436 pool.shutdown().await;
437
438 assert!(pool.tab_semaphore().try_acquire().is_err());
439 let error = pool.warm_up().await.unwrap_err();
440 assert_eq!(error.code, "use.browser.provider_failed");
441 assert!(error.message.contains("shut down"));
442 }
443}