browser_commander/browser/
debugging_port.rs1use 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
38pub const LOOPBACK_HOST: &str = "127.0.0.1";
40
41const OUTPUT_BUFFER_LIMIT: usize = 65_536;
44
45#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct PortRaceError {
50 pub port: u16,
52 pub detail: Option<String>,
54}
55
56impl PortRaceError {
57 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
82pub 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
94pub 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#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct DevToolsListening {
108 pub url: String,
110 pub host: String,
112 pub port: u16,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Default)]
118pub struct DevToolsOutput {
119 pub listening: Option<DevToolsListening>,
121 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
130static 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
137pub 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#[derive(Debug, Clone, Default)]
169pub struct DevToolsOutputWatcher {
170 state: Arc<Mutex<WatcherState>>,
171}
172
173impl DevToolsOutputWatcher {
174 pub fn new() -> Self {
176 Self::default()
177 }
178
179 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 pub fn listener(&self) -> OutputListener {
203 let watcher = self.clone();
204 Arc::new(move |chunk: &[u8]| watcher.push(chunk))
205 }
206
207 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum DevToolsOwnership {
223 Owned,
225 Race,
227 Pending,
229}
230
231pub 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}