Skip to main content

playwright_cdp/
browser_process.rs

1//! Chrome/Chromium process management: executable discovery, spawning with
2//! `--remote-debugging-port`, DevToolsActivePort discovery, and `/json/version`
3//! resolution to the browser WebSocket endpoint.
4
5use crate::error::{Error, Result};
6use crate::options::LaunchOptions;
7use std::path::PathBuf;
8use std::time::Duration;
9use tokio::io::AsyncReadExt;
10use tokio::process::{Child, Command};
11
12/// The result of launching a browser: the owning process handle plus the
13/// browser-level WebSocket endpoint to connect to.
14pub struct BrowserProcess {
15    child: Option<Child>,
16    _user_data_dir: tempfile::TempDir,
17    ws_url: String,
18    stderr_buf: std::sync::Arc<parking_lot::Mutex<String>>,
19}
20
21impl BrowserProcess {
22    /// Spawn a Chromium-based browser per `opts` and discover its DevTools
23    /// WebSocket endpoint. Blocks until the browser is ready (or `timeout`).
24    pub async fn launch(opts: &LaunchOptions) -> Result<Self> {
25        let executable = discover_executable(opts)
26            .map_err(|e| e.context("could not find a Chrome/Chromium executable"))?;
27
28        let user_data_dir = tempfile::Builder::new()
29            .prefix("playwright-cdp-")
30            .tempdir()
31            .map_err(|e| Error::Io(e).context("failed to create user-data-dir"))?;
32
33        let mut args = base_args(user_data_dir.path(), opts);
34        if opts.headless.unwrap_or(true) {
35            args.push("--headless=new".to_string());
36        }
37        if opts.devtools.unwrap_or(false) {
38            args.push("--auto-open-devtools-for-tabs".to_string());
39        }
40        if let Some(proxy) = &opts.proxy {
41            args.push(format!("--proxy-server={}", proxy.server));
42        }
43        // User args last so they can override defaults.
44        if let Some(extra) = &opts.args {
45            args.extend(extra.iter().cloned());
46        }
47
48        tracing::debug!(exe = %executable.display(), args = ?args, "launching browser");
49
50        let stderr_buf = std::sync::Arc::new(parking_lot::Mutex::new(String::new()));
51        let stderr_for_task = stderr_buf.clone();
52
53        let mut cmd = Command::new(&executable);
54        cmd.args(&args)
55            .stdin(std::process::Stdio::null())
56            .stdout(std::process::Stdio::null())
57            .stderr(std::process::Stdio::piped())
58            .kill_on_drop(true);
59        // On Linux, sandbox requires CAP_SYS_ADMIN; disable it when running as root (CI).
60        #[cfg(target_os = "linux")]
61        if unsafe { libc_geteuid() } == 0 {
62            // already passed? ensure --no-sandbox present
63            if !args.iter().any(|a| a == "--no-sandbox") {
64                cmd.arg("--no-sandbox");
65            }
66        }
67        // Propagate env overrides.
68        if let Some(env) = &opts.env {
69            for (k, v) in env {
70                cmd.env(k, v);
71            }
72        }
73
74        let mut child = cmd
75            .spawn()
76            .map_err(|e| Error::LaunchFailed(format!("failed to spawn {executable:?}: {e}")))?;
77
78        // Drain stderr in the background, keeping the most recent ~8KB.
79        if let Some(stderr) = child.stderr.take() {
80            tokio::spawn(async move {
81                drain_stderr(stderr, stderr_for_task).await;
82            });
83        }
84
85        let timeout_ms = opts.timeout_ms_or_default() as u64;
86        let port = wait_for_devtools_port(user_data_dir.path(), timeout_ms).await.map_err(|e| {
87            let stderr_snapshot = stderr_buf.lock().clone();
88            Error::LaunchFailed(format!(
89                "{e}\n--- browser stderr (tail) ---\n{}",
90                stderr_snapshot.chars().rev().take(4096).collect::<String>().chars().rev().collect::<String>()
91            ))
92        })?;
93
94        let ws_url = fetch_browser_ws_url(port, timeout_ms).await?;
95
96        Ok(Self {
97            child: Some(child),
98            _user_data_dir: user_data_dir,
99            ws_url,
100            stderr_buf,
101        })
102    }
103
104    /// The browser-level WebSocket endpoint (`ws://host:port/devtools/browser/<id>`).
105    pub fn ws_url(&self) -> &str {
106        &self.ws_url
107    }
108
109    /// Send SIGKILL (best-effort) and wait for exit.
110    pub async fn kill(&mut self) -> Result<()> {
111        if let Some(child) = self.child.as_mut() {
112            let _ = child.start_kill();
113            let _ = child.wait().await;
114        }
115        Ok(())
116    }
117
118    /// Recent stderr output (for diagnostics).
119    pub fn stderr_tail(&self) -> String {
120        self.stderr_buf.lock().clone()
121    }
122}
123
124impl Drop for BrowserProcess {
125    fn drop(&mut self) {
126        // kill_on_drop(true) on the Command handles SIGKILL; this is a fallback.
127        if let Some(child) = self.child.as_mut() {
128            let _ = child.start_kill();
129        }
130    }
131}
132
133async fn drain_stderr<R: tokio::io::AsyncRead + Unpin>(
134    mut stderr: R,
135    buf: std::sync::Arc<parking_lot::Mutex<String>>,
136) {
137    let mut tmp = [0u8; 1024];
138    loop {
139        match stderr.read(&mut tmp).await {
140            Ok(0) | Err(_) => break,
141            Ok(n) => {
142                let s = String::from_utf8_lossy(&tmp[..n]).to_string();
143                let mut guard = buf.lock();
144                guard.push_str(&s);
145                // Keep only the last ~16KB.
146                const MAX: usize = 16 * 1024;
147                if guard.len() > MAX {
148                    let drop = guard.len() - MAX;
149                    guard.drain(..drop);
150                }
151            }
152        }
153    }
154}
155
156fn base_args(user_data_dir: &std::path::Path, _opts: &LaunchOptions) -> Vec<String> {
157    vec![
158        format!(
159            "--user-data-dir={}",
160            user_data_dir.display()
161        ),
162        "--remote-debugging-port=0".to_string(),
163        "--no-first-run".to_string(),
164        "--no-default-browser-check".to_string(),
165        "--disable-background-networking".to_string(),
166        "--password-store=basic".to_string(),
167        "--use-mock-keychain".to_string(),
168        "--disable-features=Translate,IsolateOrigins,site-per-process".to_string(),
169        "about:blank".to_string(),
170    ]
171}
172
173/// Poll `<user-data-dir>/DevToolsActivePort` until it appears and parses.
174///
175/// The file is `"<port>\n<path>"`. It is written asynchronously once Chrome is
176/// ready, and may be momentarily empty/truncated, so we read-and-parse in a loop.
177async fn wait_for_devtools_port(user_data_dir: &std::path::Path, timeout_ms: u64) -> Result<u16> {
178    let path = user_data_dir.join("DevToolsActivePort");
179    let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms.max(1000));
180    loop {
181        if let Ok(contents) = tokio::fs::read_to_string(&path).await {
182            if let Some(first_line) = contents.lines().next() {
183                if let Ok(port) = first_line.trim().parse::<u16>() {
184                    if port != 0 {
185                        return Ok(port);
186                    }
187                }
188            }
189        }
190        if tokio::time::Instant::now() >= deadline {
191            return Err(Error::LaunchFailed(format!(
192                "timed out after {timeout_ms}ms waiting for DevToolsActivePort at {}",
193                path.display()
194            )));
195        }
196        tokio::time::sleep(Duration::from_millis(100)).await;
197    }
198}
199
200/// `GET http://127.0.0.1:<port>/json/version` and extract `webSocketDebuggerUrl`.
201async fn fetch_browser_ws_url(port: u16, timeout_ms: u64) -> Result<String> {
202    let url = format!("http://127.0.0.1:{port}/json/version");
203    let deadline = tokio::time::Instant::now() + Duration::from_millis(timeout_ms.max(1000));
204    loop {
205        match reqwest::get(&url).await {
206            Ok(resp) if resp.status().is_success() => {
207                let v: serde_json::Value = resp
208                    .json()
209                    .await
210                    .map_err(|e| Error::Http(format!("/json/version parse: {e}")))?;
211                let ws = v
212                    .get("webSocketDebuggerUrl")
213                    .and_then(|x| x.as_str())
214                    .ok_or_else(|| {
215                        Error::ProtocolError("/json/version missing webSocketDebuggerUrl".into())
216                    })?
217                    .to_string();
218                return Ok(ws);
219            }
220            _ => {}
221        }
222        if tokio::time::Instant::now() >= deadline {
223            return Err(Error::ConnectionFailed(format!(
224                "timed out waiting for {url}"
225            )));
226        }
227        tokio::time::sleep(Duration::from_millis(100)).await;
228    }
229}
230
231/// Resolve an arbitrary CDP endpoint to a `ws://` URL.
232///
233/// - `http(s)://host:port` → `GET {endpoint}/json/version` → `webSocketDebuggerUrl`
234/// - `ws(s)://...` → returned unchanged
235pub async fn resolve_ws_endpoint(endpoint: &str) -> Result<String> {
236    if endpoint.starts_with("ws://") || endpoint.starts_with("wss://") {
237        return Ok(endpoint.to_string());
238    }
239    let mut base = endpoint.trim_end_matches('/').to_string();
240    base.push_str("/json/version");
241    let resp = reqwest::get(&base)
242        .await
243        .map_err(|e| Error::Http(format!("GET {base}: {e}")))?;
244    let v: serde_json::Value = resp
245        .json()
246        .await
247        .map_err(|e| Error::Http(format!("parse {base}: {e}")))?;
248    v.get("webSocketDebuggerUrl")
249        .and_then(|x| x.as_str())
250        .map(|s| s.to_string())
251        .ok_or_else(|| Error::ProtocolError("response missing webSocketDebuggerUrl".into()))
252}
253
254// ---------------------------------------------------------------------------
255// Executable discovery
256// ---------------------------------------------------------------------------
257
258pub(crate) fn discover_executable(opts: &LaunchOptions) -> Result<PathBuf> {
259    if let Some(p) = &opts.executable_path {
260        let path = PathBuf::from(p);
261        if path.exists() {
262            return Ok(path);
263        }
264        return Err(Error::LaunchFailed(format!(
265            "executable_path does not exist: {p}"
266        )));
267    }
268
269    for var in ["PWC_CHROME_PATH", "CHROME_PATH"] {
270        if let Ok(p) = std::env::var(var) {
271            let path = PathBuf::from(&p);
272            if path.exists() {
273                return Ok(path);
274            }
275        }
276    }
277
278    for candidate in executable_candidates(opts.channel.as_deref()) {
279        if candidate.exists() {
280            return Ok(candidate);
281        }
282    }
283
284    Err(Error::LaunchFailed(
285        "no Chrome/Chromium executable found; set executable_path, the CHROME_PATH env var, or channel".into(),
286    ))
287}
288
289/// Ordered candidate executables for a channel (or all channels if `None`).
290fn executable_candidates(channel: Option<&str>) -> Vec<PathBuf> {
291    let channels: Vec<&str> = match channel {
292        Some(c) => vec![c],
293        None => vec!["chrome", "chromium", "msedge"],
294    };
295
296    let mut out = Vec::new();
297    for ch in channels {
298        out.extend(known_locations(ch));
299        out.extend(path_lookup(ch));
300    }
301    out
302}
303
304/// `which`-style scan of `$PATH` for the channel's binary names.
305fn path_lookup(channel: &str) -> Vec<PathBuf> {
306    let names: &[&str] = match channel {
307        "chrome" => &["google-chrome", "google-chrome-stable", "chrome"],
308        "chromium" => &["chromium", "chromium-browser"],
309        "msedge" => &["microsoft-edge", "microsoft-edge-stable"],
310        _ => &[],
311    };
312    let path = match std::env::var_os("PATH") {
313        Some(p) => std::env::split_paths(&p).collect::<Vec<_>>(),
314        None => return Vec::new(),
315    };
316    let mut out = Vec::new();
317    for name in names {
318        for dir in &path {
319            let candidate = dir.join(name);
320            if candidate.is_file() {
321                out.push(candidate);
322            }
323        }
324    }
325    out
326}
327
328/// Well-known absolute install locations per channel per OS.
329fn known_locations(channel: &str) -> Vec<PathBuf> {
330    let mut out = Vec::new();
331
332    match channel {
333        "chrome" => {
334            #[cfg(target_os = "linux")]
335            out.extend([
336                "/opt/google/chrome/chrome",
337                "/usr/bin/google-chrome",
338                "/usr/bin/google-chrome-stable",
339                "/usr/bin/google-chrome-beta",
340            ]);
341            #[cfg(target_os = "macos")]
342            out.extend([
343                "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
344                "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
345            ]);
346            #[cfg(target_os = "windows")]
347            {
348                for dir in windows_program_dirs() {
349                    out.push(PathBuf::from(dir).join("Google\\Chrome\\Application\\chrome.exe"));
350                }
351            }
352        }
353        "chromium" => {
354            #[cfg(target_os = "linux")]
355            out.extend(["/usr/bin/chromium", "/usr/bin/chromium-browser", "/snap/bin/chromium"]);
356            #[cfg(target_os = "macos")]
357            out.push("/Applications/Chromium.app/Contents/MacOS/Chromium");
358            #[cfg(target_os = "windows")]
359            {
360                for dir in windows_program_dirs() {
361                    out.push(PathBuf::from(dir).join("Chromium\\Application\\chrome.exe"));
362                }
363            }
364        }
365        "msedge" => {
366            #[cfg(target_os = "linux")]
367            out.extend(["/usr/bin/microsoft-edge", "/opt/microsoft/edge/microsoft-edge"]);
368            #[cfg(target_os = "macos")]
369            out.push("/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge");
370            #[cfg(target_os = "windows")]
371            {
372                for dir in windows_program_dirs() {
373                    out.push(PathBuf::from(dir).join("Microsoft\\Edge\\Application\\msedge.exe"));
374                }
375            }
376        }
377        _ => {}
378    }
379
380    out.into_iter().map(PathBuf::from).collect()
381}
382
383/// Program-file / local-app candidate roots on Windows.
384#[cfg(target_os = "windows")]
385fn windows_program_dirs() -> Vec<String> {
386    let mut dirs = Vec::new();
387    if let Ok(v) = std::env::var("ProgramFiles") {
388        dirs.push(v);
389    } else {
390        dirs.push("C:\\Program Files".into());
391    }
392    if let Ok(v) = std::env::var("ProgramFiles(x86)") {
393        dirs.push(v);
394    }
395    if let Ok(v) = std::env::var("LOCALAPPDATA") {
396        dirs.push(v);
397    }
398    dirs
399}
400
401/// libc geteuid without taking a libc dependency (raw syscall on Linux).
402#[cfg(target_os = "linux")]
403unsafe fn libc_geteuid() -> u32 {
404    unsafe extern "C" {
405        fn geteuid() -> u32;
406    }
407    unsafe { geteuid() }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn known_locations_returns_something_on_supported_platforms() {
416        // On the supported platforms at least the chrome channel yields entries.
417        let c = known_locations("chrome");
418        #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
419        assert!(!c.is_empty(), "expected chrome known locations");
420        #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
421        let _ = c;
422    }
423
424    #[test]
425    fn discover_respects_explicit_executable_path() {
426        let opts = LaunchOptions::default().executable_path("/nonexistent/path/to/chrome-xyz");
427        let r = discover_executable(&opts);
428        assert!(r.is_err());
429    }
430
431    #[test]
432    fn devtools_port_parses_well_formed_contents() {
433        // The parser reads line 1 as the port; verify the parsing rule directly.
434        let contents = "9333\n/devtools/browser/abc-123\n";
435        let port: u16 = contents
436            .lines()
437            .next()
438            .unwrap()
439            .trim()
440            .parse()
441            .unwrap();
442        assert_eq!(port, 9333);
443    }
444}