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