use std::future::Future;
use std::io;
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Result};
use serde_json::Value;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use crate::browser::browser_process::BrowserProcess;
use crate::browser::debugging_port::{
assert_fixed_debugging_port, classify_dev_tools_ownership, DevToolsOutputWatcher,
DevToolsOwnership, PortRaceError, LOOPBACK_HOST,
};
const POLL_INTERVAL: Duration = Duration::from_millis(100);
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
const RESPONSE_LIMIT: usize = 1024 * 1024;
#[derive(Debug, Clone, Copy)]
pub struct CdpEndpointRequest<'a> {
pub remote_debugging_port: u16,
pub user_data_dir: &'a Path,
pub browser_process: &'a BrowserProcess,
pub dev_tools_output: Option<&'a DevToolsOutputWatcher>,
pub timeout: Duration,
}
fn web_socket_debugger_url(response: &[u8]) -> Option<String> {
if !(response.starts_with(b"HTTP/1.1 200") || response.starts_with(b"HTTP/1.0 200")) {
return None;
}
let header_end = response
.windows(4)
.position(|window| window == b"\r\n\r\n")?;
serde_json::from_slice::<Value>(&response[header_end + 4..])
.ok()?
.get("webSocketDebuggerUrl")?
.as_str()
.map(str::to_owned)
}
pub async fn fetch_cdp_version(port: u16, timeout: Duration) -> Option<String> {
let request = format!(
"GET /json/version HTTP/1.1\r\nHost: {LOOPBACK_HOST}:{port}\r\nConnection: close\r\n\r\n"
);
let request_future = async {
let mut stream = TcpStream::connect((LOOPBACK_HOST, port)).await?;
stream.write_all(request.as_bytes()).await?;
let mut response = Vec::new();
let mut chunk = [0_u8; 4096];
loop {
let bytes_read = stream.read(&mut chunk).await?;
if bytes_read == 0 {
break;
}
response.extend_from_slice(&chunk[..bytes_read]);
if let Some(url) = web_socket_debugger_url(&response) {
return Ok::<Option<String>, io::Error>(Some(url));
}
if response.len() > RESPONSE_LIMIT {
return Ok(None);
}
}
Ok(web_socket_debugger_url(&response))
};
tokio::time::timeout(timeout, request_future)
.await
.ok()?
.ok()?
}
pub fn read_dev_tools_active_port(user_data_dir: &Path) -> Option<u16> {
std::fs::read_to_string(user_data_dir.join("DevToolsActivePort"))
.ok()?
.lines()
.next()?
.trim()
.parse()
.ok()
}
pub async fn wait_for_cdp_endpoint(request: CdpEndpointRequest<'_>) -> Result<String> {
wait_for_cdp_endpoint_with(request, fetch_cdp_version).await
}
pub(crate) async fn wait_for_cdp_endpoint_with<P, F>(
request: CdpEndpointRequest<'_>,
probe: P,
) -> Result<String>
where
P: Fn(u16, Duration) -> F,
F: Future<Output = Option<String>>,
{
let port = assert_fixed_debugging_port(request.remote_debugging_port)?;
let endpoint = format!("http://{LOOPBACK_HOST}:{port}");
let started = Instant::now();
while started.elapsed() < request.timeout {
let output = request
.dev_tools_output
.map(DevToolsOutputWatcher::state)
.unwrap_or_default();
let mut owned = match request.dev_tools_output {
Some(_) => match classify_dev_tools_ownership(&output, port) {
DevToolsOwnership::Race => {
let detail = output.listening.as_ref().map_or_else(
|| "bind failed".to_owned(),
|listening| listening.url.clone(),
);
return Err(PortRaceError::new(port, detail).into());
}
DevToolsOwnership::Owned => true,
DevToolsOwnership::Pending => false,
},
None => false,
};
if let Some(code) = request.browser_process.exit_code() {
return Err(anyhow!(
"Browser exited before its DevTools endpoint was ready (exit {code})"
));
}
if !owned && read_dev_tools_active_port(request.user_data_dir) == Some(port) {
owned = true;
}
if owned {
let remaining = request.timeout.saturating_sub(started.elapsed());
if let Some(served) = probe(port, remaining.min(PROBE_TIMEOUT)).await {
if let Some(announced) = output.listening.as_ref().map(|listening| &listening.url) {
if &served != announced {
return Err(PortRaceError::new(
port,
format!("port serves {served}, browser announced {announced}"),
)
.into());
}
}
return Ok(endpoint);
}
}
tokio::time::sleep(POLL_INTERVAL).await;
}
Err(anyhow!(
"Timed out after {}ms waiting for the DevTools endpoint on port {port}",
request.timeout.as_millis()
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::browser::browser_process::fake::FakeProcess;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
const ANNOUNCED: &str = "ws://127.0.0.1:40001/devtools/browser/ours";
fn request<'a>(
process: &'a BrowserProcess,
watcher: &'a DevToolsOutputWatcher,
timeout: Duration,
) -> CdpEndpointRequest<'a> {
CdpEndpointRequest {
remote_debugging_port: 40001,
user_data_dir: Path::new("/nonexistent/browser-commander-profile"),
browser_process: process,
dev_tools_output: Some(watcher),
timeout,
}
}
#[tokio::test]
async fn returns_the_endpoint_once_ownership_is_confirmed() {
let process = FakeProcess::new().handle();
let watcher = DevToolsOutputWatcher::new();
watcher.push(format!("\nDevTools listening on {ANNOUNCED}\n").as_bytes());
let endpoint = wait_for_cdp_endpoint_with(
request(&process, &watcher, Duration::from_secs(2)),
|_, _| async { Some(ANNOUNCED.to_owned()) },
)
.await
.unwrap();
assert_eq!(endpoint, "http://127.0.0.1:40001");
}
#[tokio::test]
async fn reports_a_race_when_the_port_serves_another_browser() {
let process = FakeProcess::new().handle();
let watcher = DevToolsOutputWatcher::new();
watcher.push(format!("DevTools listening on {ANNOUNCED}\n").as_bytes());
let error = wait_for_cdp_endpoint_with(
request(&process, &watcher, Duration::from_secs(2)),
|_, _| async { Some("ws://127.0.0.1:40001/devtools/browser/theirs".to_owned()) },
)
.await
.unwrap_err();
let race = error
.downcast_ref::<PortRaceError>()
.expect("a PortRaceError");
assert_eq!(race.port, 40001);
assert!(error.to_string().contains("browser announced"), "{error}");
}
#[tokio::test]
async fn reports_a_race_on_the_ipv6_fallback_without_probing() {
let process = FakeProcess::new().handle();
let watcher = DevToolsOutputWatcher::new();
watcher.push(b"DevTools listening on ws://[::1]:40001/devtools/browser/ours\n");
let probes = AtomicUsize::new(0);
let error = wait_for_cdp_endpoint_with(
request(&process, &watcher, Duration::from_secs(2)),
|_, _| {
probes.fetch_add(1, Ordering::SeqCst);
async { None }
},
)
.await
.unwrap_err();
assert!(error.downcast_ref::<PortRaceError>().is_some(), "{error}");
assert_eq!(probes.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn does_not_probe_before_the_browser_claims_the_port() {
let process = FakeProcess::new().handle();
let watcher = DevToolsOutputWatcher::new();
let probes = Arc::new(AtomicUsize::new(0));
let counter = Arc::clone(&probes);
let error = wait_for_cdp_endpoint_with(
request(&process, &watcher, Duration::from_millis(250)),
move |_, _| {
counter.fetch_add(1, Ordering::SeqCst);
async { Some(ANNOUNCED.to_owned()) }
},
)
.await
.unwrap_err();
assert!(error.to_string().starts_with("Timed out"), "{error}");
assert_eq!(probes.load(Ordering::SeqCst), 0);
}
#[tokio::test]
async fn reports_an_early_exit() {
let fake = FakeProcess::new();
fake.exit(21);
let process = fake.handle();
let watcher = DevToolsOutputWatcher::new();
let error = wait_for_cdp_endpoint_with(
request(&process, &watcher, Duration::from_secs(2)),
|_, _| async { None },
)
.await
.unwrap_err();
assert!(error.to_string().contains("(exit 21)"), "{error}");
}
#[tokio::test]
async fn confirms_ownership_through_dev_tools_active_port() {
let profile =
crate::browser::profile_directory::create_temporary_user_data_dir(None).unwrap();
std::fs::write(
profile.join("DevToolsActivePort"),
"40001\n/devtools/browser/x",
)
.unwrap();
let process = FakeProcess::new().handle();
let endpoint = wait_for_cdp_endpoint_with(
CdpEndpointRequest {
remote_debugging_port: 40001,
user_data_dir: &profile,
browser_process: &process,
dev_tools_output: None,
timeout: Duration::from_secs(2),
},
|_, _| async { Some(ANNOUNCED.to_owned()) },
)
.await
.unwrap();
assert_eq!(endpoint, "http://127.0.0.1:40001");
std::fs::remove_dir_all(profile).unwrap();
}
#[tokio::test]
async fn cdp_probe_does_not_wait_for_the_server_to_close_the_connection() {
let listener = tokio::net::TcpListener::bind((LOOPBACK_HOST, 0))
.await
.unwrap();
let port = listener.local_addr().unwrap().port();
let response_body = r#"{"webSocketDebuggerUrl":"ws://127.0.0.1/devtools/browser/id"}"#;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{response_body}",
response_body.len()
);
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request).await.unwrap();
stream.write_all(response.as_bytes()).await.unwrap();
tokio::time::sleep(Duration::from_secs(1)).await;
});
assert_eq!(
fetch_cdp_version(port, Duration::from_millis(200)).await,
Some("ws://127.0.0.1/devtools/browser/id".to_owned())
);
server.abort();
}
}