Skip to main content

proofsheet_core/
cdp.rs

1//! Launching a headless Chromium and speaking the DevTools Protocol to it.
2
3use std::io::{BufRead, BufReader, Read};
4use std::net::TcpStream;
5use std::path::{Path, PathBuf};
6use std::process::{Child, Command, Stdio};
7use std::time::{Duration, Instant};
8
9use serde_json::{json, Value};
10
11use crate::error::{Error, Result};
12use crate::ws::Ws;
13
14/// Where to look for a browser binary when the caller does not name one.
15const CANDIDATES: &[&str] = &[
16    "chrome-headless-shell",
17    "chromium",
18    "chromium-browser",
19    "google-chrome-stable",
20    "google-chrome",
21];
22
23/// Locate a browser binary.
24///
25/// Order: the `PROOFSHEET_CHROME` environment variable, then the local
26/// managed download, then anything on `PATH`. Explicit beats implicit, and a
27/// pinned local build beats whatever the machine happens to have.
28pub fn find_browser(managed_root: Option<&Path>) -> Result<PathBuf> {
29    // Empty means unset. `export PROOFSHEET_CHROME=$(which chrome)` on a box
30    // without chrome sets it to "", and env::var happily returns Ok(""),
31    // which produced the nonsense "points at , which is not a file" instead
32    // of falling through to discovery.
33    if let Some(p) = std::env::var("PROOFSHEET_CHROME")
34        .ok()
35        .filter(|v| !v.trim().is_empty())
36    {
37        let p = PathBuf::from(p.trim());
38        if p.is_file() {
39            return Ok(p);
40        }
41        return Err(Error::Browser(format!(
42            "PROOFSHEET_CHROME points at {}, which is not a file",
43            p.display()
44        )));
45    }
46    if let Some(root) = managed_root {
47        if root.is_dir() {
48            if let Some(found) = find_in_tree(root, "chrome-headless-shell") {
49                return Ok(found);
50            }
51        }
52    }
53    for name in CANDIDATES {
54        if let Some(p) = which(name) {
55            return Ok(p);
56        }
57    }
58    Err(Error::Browser(
59        "no browser found. Set PROOFSHEET_CHROME, or run `proofsheet install-browser`.".into(),
60    ))
61}
62
63fn which(name: &str) -> Option<PathBuf> {
64    let path = std::env::var_os("PATH")?;
65    std::env::split_paths(&path)
66        .map(|d| d.join(name))
67        .find(|c| c.is_file())
68}
69
70fn find_in_tree(root: &Path, name: &str) -> Option<PathBuf> {
71    let entries = std::fs::read_dir(root).ok()?;
72    let mut dirs = Vec::new();
73    for e in entries.flatten() {
74        let p = e.path();
75        if p.is_file() && p.file_name().map(|f| f == name).unwrap_or(false) {
76            return Some(p);
77        }
78        if p.is_dir() {
79            dirs.push(p);
80        }
81    }
82    dirs.iter().find_map(|d| find_in_tree(d, name))
83}
84
85/// Options for launching the browser.
86#[derive(Debug, Clone)]
87pub struct LaunchOptions {
88    pub binary: PathBuf,
89    pub port: u16,
90    pub user_data_dir: Option<PathBuf>,
91    pub extra_args: Vec<String>,
92    pub timeout: Duration,
93}
94
95impl LaunchOptions {
96    pub fn new(binary: impl Into<PathBuf>) -> Self {
97        LaunchOptions {
98            binary: binary.into(),
99            // 0 asks the OS for a free port, which Chrome reports back on
100            // stderr. Fixed ports collide when runs overlap.
101            port: 0,
102            user_data_dir: None,
103            extra_args: Vec::new(),
104            timeout: Duration::from_secs(30),
105        }
106    }
107}
108
109/// A live browser process plus an attached CDP session.
110#[derive(Debug)]
111pub struct Browser {
112    child: Child,
113    ws: Ws,
114    next_id: u64,
115    /// The port Chrome actually bound, which may differ from the requested one.
116    pub port: u16,
117}
118
119impl Browser {
120    pub fn launch(opts: &LaunchOptions) -> Result<Browser> {
121        let mut cmd = Command::new(&opts.binary);
122        cmd.arg(format!("--remote-debugging-port={}", opts.port))
123            .arg("--headless")
124            .arg("--no-sandbox")
125            .arg("--disable-gpu")
126            .arg("--hide-scrollbars")
127            .arg("--mute-audio")
128            .arg("--no-first-run")
129            .arg("--no-default-browser-check")
130            .arg("--disable-dev-shm-usage")
131            // Keep the browser's own scaling out of it: every scale decision
132            // is made explicitly per device via Emulation.
133            .arg("--force-device-scale-factor=1")
134            // Background throttling would make timing depend on wall clock.
135            .arg("--disable-background-timer-throttling")
136            .arg("--disable-renderer-backgrounding")
137            .arg("--disable-backgrounding-occluded-windows");
138        if let Some(dir) = &opts.user_data_dir {
139            cmd.arg(format!("--user-data-dir={}", dir.display()));
140        }
141        for a in &opts.extra_args {
142            cmd.arg(a);
143        }
144        cmd.arg("about:blank");
145        cmd.stdout(Stdio::null()).stderr(Stdio::piped());
146
147        let mut child = cmd.spawn().map_err(|e| {
148            Error::Browser(format!("could not spawn {}: {e}", opts.binary.display()))
149        })?;
150
151        let stderr = child
152            .stderr
153            .take()
154            .ok_or_else(|| Error::Browser("no stderr pipe".into()))?;
155        let port = match read_devtools_port(stderr, opts.timeout) {
156            Ok(p) => p,
157            Err(e) => {
158                let _ = child.kill();
159                return Err(e);
160            }
161        };
162
163        match attach(port, opts.timeout) {
164            Ok(ws) => Ok(Browser {
165                child,
166                ws,
167                next_id: 0,
168                port,
169            }),
170            Err(e) => {
171                let _ = child.kill();
172                Err(e)
173            }
174        }
175    }
176
177    /// Issue a CDP command and wait for its matching reply, discarding events.
178    pub fn call(&mut self, method: &str, params: Value) -> Result<Value> {
179        self.next_id += 1;
180        let id = self.next_id;
181        let msg = json!({ "id": id, "method": method, "params": params });
182        self.ws.send_text(&msg.to_string())?;
183        loop {
184            let raw = self.ws.recv_text()?;
185            let v: Value = serde_json::from_str(&raw)?;
186            if v.get("id").and_then(Value::as_u64) != Some(id) {
187                continue; // an event, or a reply we are not waiting on
188            }
189            if let Some(err) = v.get("error") {
190                let message = err
191                    .get("message")
192                    .and_then(Value::as_str)
193                    .unwrap_or("unknown")
194                    .to_string();
195                return Err(Error::Cdp {
196                    method: method.to_string(),
197                    message,
198                });
199            }
200            return Ok(v.get("result").cloned().unwrap_or(Value::Null));
201        }
202    }
203}
204
205impl Drop for Browser {
206    fn drop(&mut self) {
207        self.ws.close();
208        let _ = self.child.kill();
209        let _ = self.child.wait();
210    }
211}
212
213/// Chrome prints `DevTools listening on ws://127.0.0.1:<port>/...` to stderr
214/// once it is ready. Reading it is how we support `--remote-debugging-port=0`
215/// and avoid guessing whether the browser has finished starting.
216fn read_devtools_port(stderr: impl Read + Send + 'static, timeout: Duration) -> Result<u16> {
217    let (tx, rx) = std::sync::mpsc::channel();
218    std::thread::spawn(move || {
219        let reader = BufReader::new(stderr);
220        for line in reader.lines().map_while(std::result::Result::ok) {
221            if let Some(rest) = line.split("ws://").nth(1) {
222                if let Some(hostport) = rest.split('/').next() {
223                    if let Some((_, p)) = hostport.rsplit_once(':') {
224                        if let Ok(port) = p.parse::<u16>() {
225                            let _ = tx.send(port);
226                            return;
227                        }
228                    }
229                }
230            }
231        }
232    });
233    rx.recv_timeout(timeout)
234        .map_err(|_| Error::Browser("browser did not report a DevTools port".into()))
235}
236
237/// Fetch the target list over the plain HTTP endpoint and attach to a page.
238fn attach(port: u16, timeout: Duration) -> Result<Ws> {
239    let deadline = Instant::now() + timeout;
240    let mut last = String::from("no attempt made");
241    while Instant::now() < deadline {
242        match http_get(port, "/json/list", Duration::from_secs(5)) {
243            Ok(body) => match serde_json::from_str::<Value>(&body) {
244                Ok(Value::Array(targets)) => {
245                    let page = targets
246                        .iter()
247                        .find(|t| t.get("type").and_then(Value::as_str) == Some("page"));
248                    if let Some(url) = page
249                        .and_then(|t| t.get("webSocketDebuggerUrl"))
250                        .and_then(Value::as_str)
251                    {
252                        return Ws::connect(url, timeout);
253                    }
254                    last = "no page target yet".into();
255                }
256                Ok(_) => last = "target list was not an array".into(),
257                Err(e) => last = format!("bad target list: {e}"),
258            },
259            Err(e) => last = e.to_string(),
260        }
261        std::thread::sleep(Duration::from_millis(100));
262    }
263    Err(Error::Browser(format!("could not attach: {last}")))
264}
265
266/// A single-shot HTTP/1.1 GET. The DevTools HTTP endpoint is the only thing
267/// we need it for, so it stays deliberately small.
268///
269/// Reads by `Content-Length` rather than to EOF. `read_to_end` on a socket
270/// carrying a read timeout surfaces `WouldBlock`/`TimedOut` as a hard error
271/// even when the full body already arrived, which presented as an opaque
272/// "Resource temporarily unavailable" during bring-up.
273fn http_get(port: u16, path: &str, timeout: Duration) -> Result<String> {
274    use std::io::Write;
275    let mut s = TcpStream::connect(("127.0.0.1", port))?;
276    s.set_read_timeout(Some(timeout))?;
277    s.set_write_timeout(Some(timeout))?;
278    write!(
279        s,
280        "GET {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n"
281    )?;
282    s.flush()?;
283
284    let mut raw: Vec<u8> = Vec::with_capacity(8192);
285    let mut chunk = [0u8; 8192];
286
287    // Headers first.
288    let head_end = loop {
289        if let Some(i) = find_subslice(&raw, b"\r\n\r\n") {
290            break i;
291        }
292        match s.read(&mut chunk) {
293            Ok(0) => return Err(Error::Shape("http response ended in headers".into())),
294            Ok(n) => raw.extend_from_slice(&chunk[..n]),
295            Err(e) => return Err(Error::Io(e)),
296        }
297    };
298
299    let head = String::from_utf8_lossy(&raw[..head_end]).to_string();
300    let want: Option<usize> = head
301        .split("\r\n")
302        .filter_map(|l| l.split_once(':'))
303        .find(|(k, _)| k.trim().eq_ignore_ascii_case("content-length"))
304        .and_then(|(_, v)| v.trim().parse().ok());
305
306    let body_start = head_end + 4;
307    loop {
308        let have = raw.len() - body_start;
309        match want {
310            Some(n) if have >= n => break,
311            _ => {}
312        }
313        match s.read(&mut chunk) {
314            Ok(0) => break, // clean EOF
315            Ok(n) => raw.extend_from_slice(&chunk[..n]),
316            Err(ref e)
317                if matches!(
318                    e.kind(),
319                    std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
320                ) =>
321            {
322                // Timed out with a body already in hand: use what we have
323                // rather than discarding a complete response.
324                if want.is_none() && !raw[body_start..].is_empty() {
325                    break;
326                }
327                return Err(Error::Shape("timed out reading http body".into()));
328            }
329            Err(e) => return Err(Error::Io(e)),
330        }
331    }
332
333    Ok(String::from_utf8_lossy(&raw[body_start..]).to_string())
334}
335
336fn find_subslice(hay: &[u8], needle: &[u8]) -> Option<usize> {
337    if needle.is_empty() || hay.len() < needle.len() {
338        return None;
339    }
340    hay.windows(needle.len()).position(|w| w == needle)
341}
342
343#[cfg(test)]
344mod env_tests {
345    /// An empty PROOFSHEET_CHROME must fall through to discovery rather than
346    /// being treated as a path. `export PROOFSHEET_CHROME=$(which chrome)` on
347    /// a machine without chrome sets it to "", and the old code reported
348    /// "PROOFSHEET_CHROME points at , which is not a file".
349    #[test]
350    fn empty_env_var_is_not_a_path() {
351        let raw = Some(String::new());
352        let kept = raw.filter(|v: &String| !v.trim().is_empty());
353        assert!(kept.is_none(), "empty string must be discarded");
354
355        let blank = Some("   ".to_string()).filter(|v: &String| !v.trim().is_empty());
356        assert!(blank.is_none(), "whitespace-only must be discarded");
357
358        let real = Some(" /usr/bin/chrome ".to_string())
359            .filter(|v: &String| !v.trim().is_empty())
360            .map(|v| v.trim().to_string());
361        assert_eq!(
362            real.as_deref(),
363            Some("/usr/bin/chrome"),
364            "real path survives, trimmed"
365        );
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    #[test]
374    fn missing_env_binary_is_an_error_not_a_fallback() {
375        // Pointing at a nonexistent path must fail loudly rather than
376        // silently searching PATH -- substituting a different browser than
377        // the caller named would make runs irreproducible.
378        std::env::set_var("PROOFSHEET_CHROME", "/nonexistent/definitely/not/here");
379        let r = find_browser(None);
380        std::env::remove_var("PROOFSHEET_CHROME");
381        assert!(matches!(r, Err(Error::Browser(_))));
382    }
383
384    #[test]
385    fn launch_options_default_to_ephemeral_port() {
386        let o = LaunchOptions::new("/bin/true");
387        assert_eq!(o.port, 0);
388    }
389}