Skip to main content

a3s_use_browser/
pool.rs

1//! Browser provider lifecycle for Chrome and Lightpanda.
2
3use 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/// Selects which headless browser backend the pool uses.
16#[derive(Debug, Clone, Default, PartialEq, Eq)]
17pub enum BrowserBackend {
18    /// Launch Chrome/Chromium locally.
19    #[default]
20    Chrome,
21
22    /// Use Lightpanda command rendering and CDP-backed interactive sessions.
23    #[cfg(feature = "lightpanda")]
24    Lightpanda,
25}
26
27/// Explicit provider selection for a Browser pool.
28#[derive(Debug, Clone, Default, PartialEq, Eq)]
29pub enum BrowserProvider {
30    /// Use an already installed or previously managed Chrome executable.
31    #[default]
32    DiscoveredChrome,
33    /// Permit A3S Use to download Chrome when no executable is available.
34    ManagedChrome,
35    /// Use this exact Chrome-compatible executable.
36    ChromeExecutable(std::path::PathBuf),
37
38    /// Use an already installed or previously managed Lightpanda executable.
39    #[cfg(feature = "lightpanda")]
40    DiscoveredLightpanda,
41    /// Permit A3S Use to download Lightpanda when it is unavailable.
42    #[cfg(feature = "lightpanda")]
43    ManagedLightpanda,
44    /// Use this exact Lightpanda executable.
45    #[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/// Configuration for the browser pool.
64#[derive(Debug, Clone)]
65pub struct BrowserPoolConfig {
66    /// Maximum number of concurrent browser tabs.
67    pub max_tabs: usize,
68    /// Whether to run Chrome in headless mode (ignored for Lightpanda).
69    pub headless: bool,
70    /// Typed provider selection. Downloads require a managed variant.
71    pub provider: BrowserProvider,
72    /// Proxy URL for the browser to use.
73    pub proxy_url: Option<String>,
74    /// Additional launch arguments for Chrome.
75    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
90/// A shared pool with bounded rendering and tab concurrency.
91///
92/// Chrome and interactive sessions lazily launch a reusable browser process.
93/// Lightpanda page rendering uses its bounded `fetch` process because its partial
94/// CDP implementation is not compatible with Chromiumoxide's navigation lifecycle.
95pub 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    /// Creates a new browser pool with the given configuration.
111    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    /// Returns the tab semaphore for acquiring permits before opening tabs.
129    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    /// Returns the number of tabs that may be opened immediately.
163    pub fn available_tab_permits(&self) -> usize {
164        self.tab_semaphore.available_permits()
165    }
166
167    /// Starts the configured provider without exposing its implementation handle.
168    pub async fn warm_up(&self) -> UseResult<()> {
169        self.acquire_browser().await.map(|_| ())
170    }
171
172    /// Lazily acquires the browser, launching it on the first call.
173    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    /// Shuts down the browser and reaps its spawned child process.
338    ///
339    /// Runtime ownership is detached into a cleanup task before this method
340    /// awaits process termination. If the caller itself is cancelled, cleanup
341    /// therefore continues instead of dropping Chrome's parent and orphaning
342    /// its renderer processes. Callers should release every `Arc<Browser>`
343    /// returned by [`Self::acquire_browser`] first. If a shared handle remains,
344    /// shutdown can request CDP close but final reaping is deferred until the
345    /// last external handle is dropped.
346    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}