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