Skip to main content

a3s_use_browser/
pool.rs

1//! Chromiumoxide-backed Browser provider lifecycle.
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    /// Spawn a Lightpanda process and connect via CDP over WebSocket.
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 managing a single browser process with tab concurrency control.
91///
92/// The browser is lazily launched on the first `acquire_browser()` call.
93/// A semaphore limits the number of concurrent tabs to prevent memory exhaustion.
94pub 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    /// Creates a new browser pool with the given configuration.
110    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    /// Returns the tab semaphore for acquiring permits before opening tabs.
128    pub(crate) fn tab_semaphore(&self) -> &Arc<Semaphore> {
129        &self.tab_semaphore
130    }
131
132    /// Returns the number of tabs that may be opened immediately.
133    pub fn available_tab_permits(&self) -> usize {
134        self.tab_semaphore.available_permits()
135    }
136
137    /// Starts the configured provider without exposing its implementation handle.
138    pub async fn warm_up(&self) -> UseResult<()> {
139        self.acquire_browser().await.map(|_| ())
140    }
141
142    /// Lazily acquires the browser, launching it on the first call.
143    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    /// Shuts down the browser and reaps its spawned child process.
317    ///
318    /// Runtime ownership is detached into a cleanup task before this method
319    /// awaits process termination. If the caller itself is cancelled, cleanup
320    /// therefore continues instead of dropping Chrome's parent and orphaning
321    /// its renderer processes. Callers should release every `Arc<Browser>`
322    /// returned by [`Self::acquire_browser`] first. If a shared handle remains,
323    /// shutdown can request CDP close but final reaping is deferred until the
324    /// last external handle is dropped.
325    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}