Skip to main content

millipede_browser_chromiumoxide/
provider.rs

1//! Chromiumoxide browser provider lifecycle.
2
3use std::time::Duration;
4
5use async_trait::async_trait;
6use chromiumoxide::{Browser, BrowserConfig};
7use futures_util::StreamExt;
8use millipede_browser::{BrowserError, BrowserProvider, LaunchContext};
9
10use crate::{ChromiumLaunchOptions, ChromiumPage};
11
12fn page_was_already_closed(error: &chromiumoxide::error::CdpError) -> bool {
13    if matches!(
14        error,
15        chromiumoxide::error::CdpError::ChannelSendError(_)
16            | chromiumoxide::error::CdpError::NoResponse
17            | chromiumoxide::error::CdpError::NotFound
18    ) {
19        return true;
20    }
21    let message = error.to_string().to_ascii_lowercase();
22    message.contains("no target")
23        || message.contains("target closed")
24        || message.contains("target not found")
25}
26
27/// A launched Chromium process and its CDP event driver.
28///
29/// Millipede runs browsers headlessly in its tests and CI. Explicit close-and-wait is the normal
30/// shutdown path; chromiumoxide's verified kill-on-drop behavior remains a last resort.
31pub struct ChromiumBrowser {
32    browser: Browser,
33    handler_task: tokio::task::JoinHandle<()>,
34    _profile_dir: Option<tempfile::TempDir>,
35}
36
37/// Chromium CDP provider backed by chromiumoxide.
38#[derive(Debug, Default, Clone)]
39pub struct ChromiumoxideProvider;
40
41#[async_trait]
42impl BrowserProvider for ChromiumoxideProvider {
43    type Browser = ChromiumBrowser;
44    type Page = ChromiumPage;
45    type LaunchOptions = ChromiumLaunchOptions;
46
47    async fn launch(
48        &self,
49        opts: Self::LaunchOptions,
50        ctx: &LaunchContext,
51    ) -> Result<Self::Browser, BrowserError> {
52        let executable = opts
53            .executable_path()
54            .map(ToOwned::to_owned)
55            .or_else(crate::discovery::find_browser)
56            .ok_or_else(|| BrowserError::BrowserNotFound {
57                hint: "set MILLIPEDE_CHROME or install Google Chrome/Chromium".to_owned(),
58            })?;
59
60        let (profile_dir, user_data_dir) = if let Some(path) = opts.profile_path() {
61            (None, path.to_owned())
62        } else {
63            let directory = tempfile::Builder::new()
64                .prefix("millipede-cdp-profile")
65                .tempdir()
66                .map_err(|error| BrowserError::Launch(anyhow::Error::new(error)))?;
67            let path = directory.path().to_owned();
68            (Some(directory), path)
69        };
70
71        let mut builder = BrowserConfig::builder()
72            .chrome_executable(executable)
73            .user_data_dir(user_data_dir)
74            .launch_timeout(opts.browser_launch_timeout())
75            .request_timeout(opts.cdp_request_timeout());
76        if !opts.is_headless() {
77            builder = builder.with_head();
78        }
79        if let Some((width, height)) = opts.viewport() {
80            builder = builder.window_size(width, height);
81        }
82        if let Some(proxy) = &ctx.proxy {
83            builder = builder.arg(format!("--proxy-server={}", proxy.url));
84        }
85        for argument in opts.additional_args().iter().chain(&ctx.extra_args) {
86            builder = builder.arg(argument.clone());
87        }
88
89        let config = builder
90            .build()
91            .map_err(|error| BrowserError::Launch(anyhow::anyhow!(error)))?;
92        let (browser, mut handler) = Browser::launch(config)
93            .await
94            .map_err(|error| BrowserError::Launch(anyhow::Error::new(error)))?;
95        let handler_task = tokio::spawn(async move {
96            while let Some(event) = handler.next().await {
97                if event.is_err() {
98                    tracing::debug!(?event, "cdp handler event error");
99                }
100            }
101        });
102
103        Ok(ChromiumBrowser {
104            browser,
105            handler_task,
106            _profile_dir: profile_dir,
107        })
108    }
109
110    async fn new_page(&self, browser: &Self::Browser) -> Result<Self::Page, BrowserError> {
111        let page = browser
112            .browser
113            .new_page("about:blank")
114            .await
115            .map_err(|error| BrowserError::PageCreate(anyhow::Error::new(error)))?;
116        Ok(ChromiumPage::new(page))
117    }
118
119    async fn close_page(&self, page: Self::Page) -> Result<(), BrowserError> {
120        match page.into_inner().close().await {
121            Ok(()) => Ok(()),
122            Err(error) if page_was_already_closed(&error) => Ok(()),
123            Err(error) => Err(BrowserError::Protocol(anyhow::Error::new(error))),
124        }
125    }
126
127    async fn close_browser(&self, mut browser: Self::Browser) -> Result<(), BrowserError> {
128        let close_error = browser
129            .browser
130            .close()
131            .await
132            .err()
133            .map(|error| BrowserError::Protocol(anyhow::Error::new(error)));
134
135        if let Err(error) = browser.browser.wait().await {
136            tracing::debug!(?error, "failed to wait for Chromium child process");
137        }
138
139        let mut handler_task = browser.handler_task;
140        if tokio::time::timeout(Duration::from_secs(5), &mut handler_task)
141            .await
142            .is_err()
143        {
144            handler_task.abort();
145            let _ = handler_task.await;
146        }
147
148        if let Some(error) = close_error {
149            Err(error)
150        } else {
151            Ok(())
152        }
153    }
154}