use std::fmt;
use std::net::{Ipv4Addr, SocketAddr, TcpListener};
use std::sync::{Arc, LazyLock, Mutex};
use anyhow::{anyhow, Result};
use regex::Regex;
use crate::utilities::subprocess::OutputListener;
pub const LOOPBACK_HOST: &str = "127.0.0.1";
const OUTPUT_BUFFER_LIMIT: usize = 65_536;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortRaceError {
pub port: u16,
pub detail: Option<String>,
}
impl PortRaceError {
pub fn new(port: u16, detail: impl Into<Option<String>>) -> Self {
Self {
port,
detail: detail.into(),
}
}
}
impl fmt::Display for PortRaceError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"Remote debugging port {} was taken by another process before the browser bound it",
self.port
)?;
match &self.detail {
Some(detail) if !detail.is_empty() => write!(formatter, " ({detail})"),
_ => Ok(()),
}
}
}
impl std::error::Error for PortRaceError {}
pub fn reserve_loopback_port() -> Result<u16> {
let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
.map_err(|error| anyhow!("Could not reserve a loopback port: {error}"))?;
let port = listener.local_addr()?.port();
drop(listener);
Ok(port)
}
pub fn assert_fixed_debugging_port(port: u16) -> Result<u16> {
if port == 0 {
return Err(anyhow!(
"remote_debugging_port 0 makes Chrome enable AutomationControlled (navigator.webdriver === true); omit it so a free fixed port is reserved"
));
}
Ok(port)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DevToolsListening {
pub url: String,
pub host: String,
pub port: u16,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DevToolsOutput {
pub listening: Option<DevToolsListening>,
pub bind_failed: bool,
}
static LISTENING_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"DevTools listening on (ws://(\[[^\]]+\]|[^:/\s]+):(\d+)/devtools/browser/[^\s]+)")
.expect("valid DevTools listening pattern")
});
static BIND_FAILURE_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)Cannot start http server for devtools")
.expect("valid DevTools bind failure pattern")
});
pub fn parse_dev_tools_output(text: &str) -> DevToolsOutput {
let listening = LISTENING_PATTERN.captures(text).and_then(|captures| {
let port = captures[3].parse::<u16>().ok()?;
Some(DevToolsListening {
url: captures[1].to_owned(),
host: captures[2]
.trim_start_matches('[')
.trim_end_matches(']')
.to_owned(),
port,
})
});
DevToolsOutput {
listening,
bind_failed: BIND_FAILURE_PATTERN.is_match(text),
}
}
#[derive(Debug, Default)]
struct WatcherState {
text: String,
settled: Option<DevToolsOutput>,
}
#[derive(Debug, Clone, Default)]
pub struct DevToolsOutputWatcher {
state: Arc<Mutex<WatcherState>>,
}
impl DevToolsOutputWatcher {
pub fn new() -> Self {
Self::default()
}
pub fn push(&self, chunk: &[u8]) {
let Ok(mut state) = self.state.lock() else {
return;
};
if state.settled.is_some() {
return;
}
state.text.push_str(&String::from_utf8_lossy(chunk));
if state.text.len() > OUTPUT_BUFFER_LIMIT {
let mut start = state.text.len() - OUTPUT_BUFFER_LIMIT;
while !state.text.is_char_boundary(start) {
start += 1;
}
state.text.drain(..start);
}
let parsed = parse_dev_tools_output(&state.text);
if parsed.listening.is_some() {
state.settled = Some(parsed);
}
}
pub fn listener(&self) -> OutputListener {
let watcher = self.clone();
Arc::new(move |chunk: &[u8]| watcher.push(chunk))
}
pub fn state(&self) -> DevToolsOutput {
let Ok(state) = self.state.lock() else {
return DevToolsOutput::default();
};
state
.settled
.clone()
.unwrap_or_else(|| parse_dev_tools_output(&state.text))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DevToolsOwnership {
Owned,
Race,
Pending,
}
pub fn classify_dev_tools_ownership(output: &DevToolsOutput, port: u16) -> DevToolsOwnership {
match &output.listening {
Some(listening) if listening.port == port && listening.host == LOOPBACK_HOST => {
DevToolsOwnership::Owned
}
Some(_) => DevToolsOwnership::Race,
None if output.bind_failed => DevToolsOwnership::Race,
None => DevToolsOwnership::Pending,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reserves_a_bindable_non_zero_port() {
let port = reserve_loopback_port().unwrap();
assert_ne!(port, 0);
TcpListener::bind((LOOPBACK_HOST, port)).unwrap();
}
#[test]
fn refuses_port_zero() {
let error = assert_fixed_debugging_port(0).unwrap_err().to_string();
assert!(error.contains("AutomationControlled"), "{error}");
assert_eq!(assert_fixed_debugging_port(9222).unwrap(), 9222);
}
#[test]
fn parses_the_listening_line_and_bind_failures() {
let output = parse_dev_tools_output(
"noise\nDevTools listening on ws://127.0.0.1:40001/devtools/browser/abc-123\n",
);
assert_eq!(
output.listening,
Some(DevToolsListening {
url: "ws://127.0.0.1:40001/devtools/browser/abc-123".to_owned(),
host: "127.0.0.1".to_owned(),
port: 40001,
})
);
assert!(!output.bind_failed);
let fallback =
parse_dev_tools_output("DevTools listening on ws://[::1]:40001/devtools/browser/x");
assert_eq!(fallback.listening.unwrap().host, "::1");
let failed = parse_dev_tools_output(
"bind() failed: Address already in use\nCannot start http server for devtools.",
);
assert!(failed.bind_failed);
assert!(!parse_dev_tools_output("bind() failed: Address already in use").bind_failed);
}
#[test]
fn classifies_ownership() {
let owned =
parse_dev_tools_output("DevTools listening on ws://127.0.0.1:40001/devtools/browser/a");
assert_eq!(
classify_dev_tools_ownership(&owned, 40001),
DevToolsOwnership::Owned
);
assert_eq!(
classify_dev_tools_ownership(&owned, 40002),
DevToolsOwnership::Race
);
let fallback =
parse_dev_tools_output("DevTools listening on ws://[::1]:40001/devtools/browser/a");
assert_eq!(
classify_dev_tools_ownership(&fallback, 40001),
DevToolsOwnership::Race
);
assert_eq!(
classify_dev_tools_ownership(&DevToolsOutput::default(), 40001),
DevToolsOwnership::Pending
);
}
#[test]
fn watcher_settles_on_the_listening_line_across_chunks() {
let watcher = DevToolsOutputWatcher::new();
let listener = watcher.listener();
listener(b"DevTools listening on ws://127.0.0.1:4");
assert_eq!(watcher.state().listening, None);
listener(b"0001/devtools/browser/a\n");
listener(b"Cannot start http server for devtools\n");
let state = watcher.state();
assert_eq!(state.listening.unwrap().port, 40001);
assert!(!state.bind_failed);
assert_eq!(
PortRaceError::new(1, None).to_string(),
"Remote debugging port 1 was taken by another process before the browser bound it"
);
}
}