Skip to main content

browser_commander/browser/
debugging_port.rs

1//! Fixed remote debugging ports for the real browser (issue #101).
2//!
3//! A fixed, non-zero `--remote-debugging-port` is the only CDP transport that
4//! leaves `navigator.webdriver` false without the unsupported
5//! `--disable-blink-features=AutomationControlled` switch: Chromium's
6//! `content/child/runtime_features.cc` treats `--remote-debugging-pipe` and
7//! `--remote-debugging-port=0` as automation, but a specific port as a human
8//! attaching a debugger.
9//!
10//! The port is reserved by binding `127.0.0.1:0`, reading the port the kernel
11//! picked and closing the socket. Another process can take the port between
12//! that close and Chrome's bind, so the launcher confirms ownership and
13//! retries.
14//!
15//! Chrome only writes `DevToolsActivePort` into the profile for port 0 (see
16//! `chrome/browser/devtools/remote_debugging_server.cc`), so for a fixed port
17//! ownership is confirmed from the line Chromium prints to stderr when its
18//! DevTools server starts:
19//!
20//! ```text
21//! DevTools listening on ws://127.0.0.1:<port>/devtools/browser/<id>
22//! ```
23//!
24//! When the loopback port is taken Chromium logs `bind() failed: Address
25//! already in use` and falls back to `[::1]:<port>`; when both fail it logs
26//! `Cannot start http server for devtools`. Either outcome is reported as a
27//! race. This mirrors `js/src/browser/debugging-port.js`.
28
29use std::fmt;
30use std::net::{Ipv4Addr, SocketAddr, TcpListener};
31use std::sync::{Arc, LazyLock, Mutex};
32
33use anyhow::{anyhow, Result};
34use regex::Regex;
35
36use crate::utilities::subprocess::OutputListener;
37
38/// Interface the reserved port and the DevTools server are bound to.
39pub const LOOPBACK_HOST: &str = "127.0.0.1";
40
41/// Most stderr kept while waiting for the DevTools line; only the startup
42/// lines matter.
43const OUTPUT_BUFFER_LIMIT: usize = 65_536;
44
45/// The reserved port was taken before the browser could bind it.
46///
47/// The launcher retries with a new port when it reserved the port itself.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct PortRaceError {
50    /// The port that was lost.
51    pub port: u16,
52    /// What gave the race away, if known.
53    pub detail: Option<String>,
54}
55
56impl PortRaceError {
57    /// Create a race error for `port`.
58    pub fn new(port: u16, detail: impl Into<Option<String>>) -> Self {
59        Self {
60            port,
61            detail: detail.into(),
62        }
63    }
64}
65
66impl fmt::Display for PortRaceError {
67    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
68        write!(
69            formatter,
70            "Remote debugging port {} was taken by another process before the browser bound it",
71            self.port
72        )?;
73        match &self.detail {
74            Some(detail) if !detail.is_empty() => write!(formatter, " ({detail})"),
75            _ => Ok(()),
76        }
77    }
78}
79
80impl std::error::Error for PortRaceError {}
81
82/// Reserve a free loopback TCP port for `--remote-debugging-port`.
83///
84/// Binds `127.0.0.1:0`, reads the port the kernel picked and closes the
85/// socket again, so the port was free a moment ago.
86pub fn reserve_loopback_port() -> Result<u16> {
87    let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
88        .map_err(|error| anyhow!("Could not reserve a loopback port: {error}"))?;
89    let port = listener.local_addr()?.port();
90    drop(listener);
91    Ok(port)
92}
93
94/// Validate a caller-supplied debugging port; zero is refused on purpose,
95/// because port 0 makes Chrome enable AutomationControlled.
96pub fn assert_fixed_debugging_port(port: u16) -> Result<u16> {
97    if port == 0 {
98        return Err(anyhow!(
99            "remote_debugging_port 0 makes Chrome enable AutomationControlled (navigator.webdriver === true); omit it so a free fixed port is reserved"
100        ));
101    }
102    Ok(port)
103}
104
105/// The `DevTools listening on ...` announcement.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct DevToolsListening {
108    /// Full browser WebSocket URL.
109    pub url: String,
110    /// Host the server bound, without IPv6 brackets.
111    pub host: String,
112    /// Port the server bound.
113    pub port: u16,
114}
115
116/// What the browser's stderr says about its DevTools server so far.
117#[derive(Debug, Clone, PartialEq, Eq, Default)]
118pub struct DevToolsOutput {
119    /// The listening announcement, once printed.
120    pub listening: Option<DevToolsListening>,
121    /// Whether DevTools gave up binding its HTTP server.
122    pub bind_failed: bool,
123}
124
125static LISTENING_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
126    Regex::new(r"DevTools listening on (ws://(\[[^\]]+\]|[^:/\s]+):(\d+)/devtools/browser/[^\s]+)")
127        .expect("valid DevTools listening pattern")
128});
129
130// A bare `bind() failed` can come from unrelated sockets (media router, mDNS),
131// so only DevTools' own give-up message counts as a failure on its own.
132static BIND_FAILURE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
133    Regex::new(r"(?i)Cannot start http server for devtools")
134        .expect("valid DevTools bind failure pattern")
135});
136
137/// Parse Chromium's DevTools startup output.
138pub fn parse_dev_tools_output(text: &str) -> DevToolsOutput {
139    let listening = LISTENING_PATTERN.captures(text).and_then(|captures| {
140        let port = captures[3].parse::<u16>().ok()?;
141        Some(DevToolsListening {
142            url: captures[1].to_owned(),
143            host: captures[2]
144                .trim_start_matches('[')
145                .trim_end_matches(']')
146                .to_owned(),
147            port,
148        })
149    });
150    DevToolsOutput {
151        listening,
152        bind_failed: BIND_FAILURE_PATTERN.is_match(text),
153    }
154}
155
156#[derive(Debug, Default)]
157struct WatcherState {
158    text: String,
159    settled: Option<DevToolsOutput>,
160}
161
162/// Collects a browser's stderr so the launcher can confirm which process owns
163/// the debugging port.
164///
165/// Register [`listener`](Self::listener) with the process before it starts;
166/// the process output keeps being drained after startup so a chatty browser
167/// never blocks on a full pipe.
168#[derive(Debug, Clone, Default)]
169pub struct DevToolsOutputWatcher {
170    state: Arc<Mutex<WatcherState>>,
171}
172
173impl DevToolsOutputWatcher {
174    /// Create an empty watcher.
175    pub fn new() -> Self {
176        Self::default()
177    }
178
179    /// Feed a chunk of stderr.
180    pub fn push(&self, chunk: &[u8]) {
181        let Ok(mut state) = self.state.lock() else {
182            return;
183        };
184        if state.settled.is_some() {
185            return;
186        }
187        state.text.push_str(&String::from_utf8_lossy(chunk));
188        if state.text.len() > OUTPUT_BUFFER_LIMIT {
189            let mut start = state.text.len() - OUTPUT_BUFFER_LIMIT;
190            while !state.text.is_char_boundary(start) {
191                start += 1;
192            }
193            state.text.drain(..start);
194        }
195        let parsed = parse_dev_tools_output(&state.text);
196        if parsed.listening.is_some() {
197            state.settled = Some(parsed);
198        }
199    }
200
201    /// A stderr listener that feeds this watcher.
202    pub fn listener(&self) -> OutputListener {
203        let watcher = self.clone();
204        Arc::new(move |chunk: &[u8]| watcher.push(chunk))
205    }
206
207    /// The DevTools state seen so far.
208    pub fn state(&self) -> DevToolsOutput {
209        let Ok(state) = self.state.lock() else {
210            return DevToolsOutput::default();
211        };
212        state
213            .settled
214            .clone()
215            .unwrap_or_else(|| parse_dev_tools_output(&state.text))
216    }
217}
218
219/// Whether the DevTools output proves the port is ours, proves a race, or is
220/// not conclusive yet.
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum DevToolsOwnership {
223    /// Our browser announced a loopback server on the reserved port.
224    Owned,
225    /// Our browser bound elsewhere, or gave up binding.
226    Race,
227    /// Nothing conclusive has been printed yet.
228    Pending,
229}
230
231/// Decide whether the DevTools output proves that `port` belongs to our
232/// browser, proves a race, or is not conclusive yet.
233pub fn classify_dev_tools_ownership(output: &DevToolsOutput, port: u16) -> DevToolsOwnership {
234    match &output.listening {
235        Some(listening) if listening.port == port && listening.host == LOOPBACK_HOST => {
236            DevToolsOwnership::Owned
237        }
238        Some(_) => DevToolsOwnership::Race,
239        None if output.bind_failed => DevToolsOwnership::Race,
240        None => DevToolsOwnership::Pending,
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn reserves_a_bindable_non_zero_port() {
250        let port = reserve_loopback_port().unwrap();
251        assert_ne!(port, 0);
252        TcpListener::bind((LOOPBACK_HOST, port)).unwrap();
253    }
254
255    #[test]
256    fn refuses_port_zero() {
257        let error = assert_fixed_debugging_port(0).unwrap_err().to_string();
258        assert!(error.contains("AutomationControlled"), "{error}");
259        assert_eq!(assert_fixed_debugging_port(9222).unwrap(), 9222);
260    }
261
262    #[test]
263    fn parses_the_listening_line_and_bind_failures() {
264        let output = parse_dev_tools_output(
265            "noise\nDevTools listening on ws://127.0.0.1:40001/devtools/browser/abc-123\n",
266        );
267        assert_eq!(
268            output.listening,
269            Some(DevToolsListening {
270                url: "ws://127.0.0.1:40001/devtools/browser/abc-123".to_owned(),
271                host: "127.0.0.1".to_owned(),
272                port: 40001,
273            })
274        );
275        assert!(!output.bind_failed);
276
277        let fallback =
278            parse_dev_tools_output("DevTools listening on ws://[::1]:40001/devtools/browser/x");
279        assert_eq!(fallback.listening.unwrap().host, "::1");
280
281        let failed = parse_dev_tools_output(
282            "bind() failed: Address already in use\nCannot start http server for devtools.",
283        );
284        assert!(failed.bind_failed);
285        assert!(!parse_dev_tools_output("bind() failed: Address already in use").bind_failed);
286    }
287
288    #[test]
289    fn classifies_ownership() {
290        let owned =
291            parse_dev_tools_output("DevTools listening on ws://127.0.0.1:40001/devtools/browser/a");
292        assert_eq!(
293            classify_dev_tools_ownership(&owned, 40001),
294            DevToolsOwnership::Owned
295        );
296        assert_eq!(
297            classify_dev_tools_ownership(&owned, 40002),
298            DevToolsOwnership::Race
299        );
300        let fallback =
301            parse_dev_tools_output("DevTools listening on ws://[::1]:40001/devtools/browser/a");
302        assert_eq!(
303            classify_dev_tools_ownership(&fallback, 40001),
304            DevToolsOwnership::Race
305        );
306        assert_eq!(
307            classify_dev_tools_ownership(&DevToolsOutput::default(), 40001),
308            DevToolsOwnership::Pending
309        );
310    }
311
312    #[test]
313    fn watcher_settles_on_the_listening_line_across_chunks() {
314        let watcher = DevToolsOutputWatcher::new();
315        let listener = watcher.listener();
316        listener(b"DevTools listening on ws://127.0.0.1:4");
317        assert_eq!(watcher.state().listening, None);
318        listener(b"0001/devtools/browser/a\n");
319        listener(b"Cannot start http server for devtools\n");
320        let state = watcher.state();
321        assert_eq!(state.listening.unwrap().port, 40001);
322        assert!(!state.bind_failed);
323        assert_eq!(
324            PortRaceError::new(1, None).to_string(),
325            "Remote debugging port 1 was taken by another process before the browser bound it"
326        );
327    }
328}