fix: fall back to /json/list when /json/version is unavailable (#861)

* fix: fall back to /json/list when /json/version is unavailable

Chrome's UI-based remote debugging mode (the permission dialog flow)
only exposes a WebSocket endpoint and does not serve /json/version.
Discovery now tries /json/version first, then falls back to /json/list
to find the browser target's WebSocket URL.

Fixes #628

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: preserve original error message when /json/list fallback fails

When both /json/version and /json/list fail, return the original
/json/version error instead of a wrapped message. This preserves the
error format expected by callers like lightpanda's timeout test.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-03-16 23:53:04 -05:00
committed by GitHub
co-authored by Claude Opus 4.6 Matt Van Horn
parent 9a8d9f439b
commit a865dd56e0
+96 -5
View File
@@ -14,19 +14,37 @@ pub async fn discover_cdp_url(host: &str, port: u16) -> Result<String, String> {
}
/// Like [`discover_cdp_url`] but with a custom request timeout.
///
/// Tries `/json/version` first (standard CDP HTTP endpoint). If that fails
/// (e.g., Chrome's UI-based remote debugging only exposes a WebSocket
/// endpoint), falls back to `/json/list` to discover the browser target.
pub async fn discover_cdp_url_with_timeout(
host: &str,
port: u16,
timeout: Duration,
) -> Result<String, String> {
let info = fetch_cdp_info(host, port, timeout).await?;
let ws_url = info.web_socket_debugger_url.ok_or_else(|| {
// Primary: /json/version (standard path)
let version_err = match fetch_cdp_info(host, port, timeout).await {
Ok(info) => {
if let Some(ws_url) = info.web_socket_debugger_url {
return Ok(rewrite_ws_host(&ws_url, host, port));
}
format!(
"No webSocketDebuggerUrl in /json/version at {}:{}",
host, port
)
})?;
Ok(rewrite_ws_host(&ws_url, host, port))
}
Err(e) => e,
};
// Fallback: /json/list (returns target list; look for the browser target)
match fetch_cdp_list(host, port, timeout).await {
Ok(ws_url) => Ok(rewrite_ws_host(&ws_url, host, port)),
Err(_) => {
// Return the original /json/version error since that's the primary path
Err(version_err)
}
}
}
/// Bracket an IPv6 address for use in URLs. No-op for IPv4 or already-bracketed addresses.
@@ -68,6 +86,38 @@ fn rewrite_ws_host(ws_url: &str, host: &str, port: u16) -> String {
}
}
/// Fetch `/json/list` and extract the `webSocketDebuggerUrl` from the first
/// target with `type == "browser"`, or the first target if none has that type.
async fn fetch_cdp_list(host: &str, port: u16, timeout: Duration) -> Result<String, String> {
let url = format!("http://{}:{}/json/list", bracket_ipv6(host), port);
let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
.await
.map_err(|_| format!("Timeout connecting to /json/list at {}:{}", host, port))?
.map_err(|e| {
format!(
"Failed to connect to /json/list at {}:{}: {}",
host, port, e
)
})?;
let targets: Vec<serde_json::Value> =
serde_json::from_str(&body).map_err(|e| format!("Invalid /json/list response: {}", e))?;
// Prefer targets with type "browser", fall back to first target with a ws URL
let browser_target = targets
.iter()
.find(|t| t.get("type").and_then(|v| v.as_str()) == Some("browser"));
let target = browser_target.or_else(|| targets.first());
target
.and_then(|t| t.get("webSocketDebuggerUrl"))
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.ok_or_else(|| "No webSocketDebuggerUrl found in /json/list targets".to_string())
}
async fn reqwest_get_string(url: &str) -> Result<String, String> {
let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
resp.text().await.map_err(|e| e.to_string())
@@ -107,14 +157,55 @@ mod tests {
}
#[tokio::test]
async fn invalid_json_returns_parse_error() {
async fn invalid_json_falls_through_to_list_fallback() {
let (port, server) = spawn_json_server("not-json").await;
let err = discover_cdp_url("127.0.0.1", port).await.unwrap_err();
// /json/version returns invalid JSON; /json/list also fails (server
// closed), so the original /json/version error is returned
assert!(err.contains("Invalid /json/version response"));
server.await.unwrap();
}
async fn spawn_json_list_server(body: &'static str) -> (u16, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
let handle = tokio::spawn(async move {
// First request: /json/version -> 404
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 1024];
let _ = socket.read(&mut buf).await;
let not_found =
"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n";
socket.write_all(not_found.as_bytes()).await.unwrap();
drop(socket);
// Second request: /json/list -> 200
let (mut socket2, _) = listener.accept().await.unwrap();
let _ = socket2.read(&mut buf).await;
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
body.len(),
body
);
socket2.write_all(response.as_bytes()).await.unwrap();
});
(port, handle)
}
#[tokio::test]
async fn falls_back_to_json_list_on_version_404() {
let (port, server) = spawn_json_list_server(
r#"[{"type":"browser","webSocketDebuggerUrl":"ws://127.0.0.1:1234/devtools/browser/abc"}]"#,
)
.await;
let ws_url = discover_cdp_url("127.0.0.1", port).await.unwrap();
assert!(ws_url.contains("/devtools/browser/abc"));
assert!(ws_url.contains(&port.to_string()));
server.await.unwrap();
}
#[test]
fn rewrite_ws_host_replaces_host_and_port() {
let original = "ws://127.0.0.1:9222/devtools/browser/abc";