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:
co-authored by
Claude Opus 4.6
Matt Van Horn
parent
9a8d9f439b
commit
a865dd56e0
@@ -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.
|
/// 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(
|
pub async fn discover_cdp_url_with_timeout(
|
||||||
host: &str,
|
host: &str,
|
||||||
port: u16,
|
port: u16,
|
||||||
timeout: Duration,
|
timeout: Duration,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let info = fetch_cdp_info(host, port, timeout).await?;
|
// Primary: /json/version (standard path)
|
||||||
let ws_url = info.web_socket_debugger_url.ok_or_else(|| {
|
let version_err = match fetch_cdp_info(host, port, timeout).await {
|
||||||
format!(
|
Ok(info) => {
|
||||||
"No webSocketDebuggerUrl in /json/version at {}:{}",
|
if let Some(ws_url) = info.web_socket_debugger_url {
|
||||||
host, port
|
return Ok(rewrite_ws_host(&ws_url, host, port));
|
||||||
)
|
}
|
||||||
})?;
|
format!(
|
||||||
Ok(rewrite_ws_host(&ws_url, host, port))
|
"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.
|
/// 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> {
|
async fn reqwest_get_string(url: &str) -> Result<String, String> {
|
||||||
let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
|
let resp = reqwest::get(url).await.map_err(|e| e.to_string())?;
|
||||||
resp.text().await.map_err(|e| e.to_string())
|
resp.text().await.map_err(|e| e.to_string())
|
||||||
@@ -107,14 +157,55 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 (port, server) = spawn_json_server("not-json").await;
|
||||||
|
|
||||||
let err = discover_cdp_url("127.0.0.1", port).await.unwrap_err();
|
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"));
|
assert!(err.contains("Invalid /json/version response"));
|
||||||
server.await.unwrap();
|
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]
|
#[test]
|
||||||
fn rewrite_ws_host_replaces_host_and_port() {
|
fn rewrite_ws_host_replaces_host_and_port() {
|
||||||
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
|
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
|
||||||
|
|||||||
Reference in New Issue
Block a user