From a865dd56e0053a894a83e0569191985232000f26 Mon Sep 17 00:00:00 2001 From: Matt Van Horn Date: Mon, 16 Mar 2026 21:53:04 -0700 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- cli/src/native/cdp/discovery.rs | 109 +++++++++++++++++++++++++++++--- 1 file changed, 100 insertions(+), 9 deletions(-) diff --git a/cli/src/native/cdp/discovery.rs b/cli/src/native/cdp/discovery.rs index c8dda3b..54d1060 100644 --- a/cli/src/native/cdp/discovery.rs +++ b/cli/src/native/cdp/discovery.rs @@ -14,19 +14,37 @@ pub async fn discover_cdp_url(host: &str, port: u16) -> Result { } /// 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 { - let info = fetch_cdp_info(host, port, timeout).await?; - let ws_url = info.web_socket_debugger_url.ok_or_else(|| { - format!( - "No webSocketDebuggerUrl in /json/version at {}:{}", - host, port - ) - })?; - Ok(rewrite_ws_host(&ws_url, host, port)) + // 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 + ) + } + 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 { + 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::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 { 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";