fix: support remote host in CDP discovery (#854)

* fix: support remote host in CDP discovery (#851)

  `discover_cdp_url` now accepts a host parameter instead of hardcoding
  127.0.0.1, allowing `connect "http://<remote-ip>:<port>"` to query the
  correct remote `/json/version` endpoint. The returned webSocketDebuggerUrl
  is rewritten to match the requested host and port, since Chrome always
  reports 127.0.0.1 regardless of the interface it was reached through.

* style: apply cargo fmt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: unify discover_cdp_url and discover_cdp_url_with_request_timeout

Merge the two discovery functions into discover_cdp_url(host, port) and
discover_cdp_url_with_timeout(host, port, timeout), eliminating duplicated
logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: merge discover_cdp_url into single function with optional timeout

Replace discover_cdp_url + discover_cdp_url_with_timeout with a single
discover_cdp_url(host, port, timeout) where timeout is Option<Duration>.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: replace Option<Duration> with separate discover_cdp_url_with_timeout

Split back into two functions for cleaner call sites:
- discover_cdp_url(host, port) for default timeout
- discover_cdp_url_with_timeout(host, port, timeout) for custom timeout

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: bracket IPv6 addresses in CDP discovery HTTP URL

Extract bracket_ipv6 helper and apply it in fetch_cdp_info to produce
valid URLs like http://[::1]:9222/json/version instead of malformed
http://::1:9222/json/version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: hyunjinee <leehj0110@kakao.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
jin.2
2026-03-16 08:37:23 -05:00
committed by GitHub
co-authored by Claude Opus 4.6 hyunjinee
parent 8163f6cdca
commit 0883813cd3
4 changed files with 83 additions and 23 deletions
+5 -3
View File
@@ -1285,15 +1285,17 @@ async fn resolve_cdp_url(input: &str) -> Result<String, String> {
} }
if input.starts_with("http://") || input.starts_with("https://") { if input.starts_with("http://") || input.starts_with("https://") {
// Parse out the port and discover
let parsed = url::Url::parse(input).map_err(|e| format!("Invalid CDP URL: {}", e))?; let parsed = url::Url::parse(input).map_err(|e| format!("Invalid CDP URL: {}", e))?;
let host = parsed
.host_str()
.ok_or_else(|| format!("No host in CDP URL: {}", input))?;
let port = parsed.port().unwrap_or(9222); let port = parsed.port().unwrap_or(9222);
return discover_cdp_url(port).await; return discover_cdp_url(host, port).await;
} }
// Try as numeric port // Try as numeric port
if let Ok(port) = input.parse::<u16>() { if let Ok(port) = input.parse::<u16>() {
return discover_cdp_url(port).await; return discover_cdp_url("127.0.0.1", port).await;
} }
Err(format!( Err(format!(
+2 -2
View File
@@ -450,7 +450,7 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
for dir in &user_data_dirs { for dir in &user_data_dirs {
if let Some((port, ws_path)) = read_devtools_active_port(dir) { if let Some((port, ws_path)) = read_devtools_active_port(dir) {
// Try HTTP endpoint first (pre-M144) // Try HTTP endpoint first (pre-M144)
if let Ok(ws_url) = discover_cdp_url(port).await { if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port).await {
return Ok(ws_url); return Ok(ws_url);
} }
// M144+: direct WebSocket // M144+: direct WebSocket
@@ -461,7 +461,7 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
// Fallback: probe common ports // Fallback: probe common ports
for port in [9222u16, 9229] { for port in [9222u16, 9229] {
if let Ok(ws_url) = discover_cdp_url(port).await { if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port).await {
return Ok(ws_url); return Ok(ws_url);
} }
} }
+73 -15
View File
@@ -2,26 +2,70 @@ use std::time::Duration;
use super::types::BrowserVersionInfo; use super::types::BrowserVersionInfo;
pub async fn discover_cdp_url(port: u16) -> Result<String, String> { /// Default timeout for CDP discovery HTTP requests.
discover_cdp_url_with_request_timeout(port, Duration::from_secs(2)).await const DEFAULT_DISCOVERY_TIMEOUT: Duration = Duration::from_secs(2);
/// Discover the CDP WebSocket URL by querying `/json/version` at the given host and port.
/// The returned `webSocketDebuggerUrl` has its host/port rewritten to match
/// the requested target, since Chrome always reports `127.0.0.1` regardless
/// of the interface it was reached through.
pub async fn discover_cdp_url(host: &str, port: u16) -> Result<String, String> {
discover_cdp_url_with_timeout(host, port, DEFAULT_DISCOVERY_TIMEOUT).await
} }
pub async fn discover_cdp_url_with_request_timeout( /// Like [`discover_cdp_url`] but with a custom request timeout.
pub async fn discover_cdp_url_with_timeout(
host: &str,
port: u16, port: u16,
request_timeout: Duration, timeout: Duration,
) -> Result<String, String> { ) -> Result<String, String> {
let url = format!("http://127.0.0.1:{}/json/version", port); 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))
}
let body = tokio::time::timeout(request_timeout, async { reqwest_get_string(&url).await }) /// Bracket an IPv6 address for use in URLs. No-op for IPv4 or already-bracketed addresses.
fn bracket_ipv6(host: &str) -> String {
if host.contains(':') && !host.starts_with('[') {
format!("[{}]", host)
} else {
host.to_string()
}
}
/// Fetch `/json/version` from the given host:port and parse the response.
async fn fetch_cdp_info(
host: &str,
port: u16,
timeout: Duration,
) -> Result<BrowserVersionInfo, String> {
let url = format!("http://{}:{}/json/version", bracket_ipv6(host), port);
let body = tokio::time::timeout(timeout, reqwest_get_string(&url))
.await .await
.map_err(|_| format!("Timeout connecting to CDP on port {}", port))? .map_err(|_| format!("Timeout connecting to CDP at {}:{}", host, port))?
.map_err(|e| format!("Failed to connect to CDP on port {}: {}", port, e))?; .map_err(|e| format!("Failed to connect to CDP at {}:{}: {}", host, port, e))?;
let info: BrowserVersionInfo = serde_json::from_str(&body) serde_json::from_str(&body).map_err(|e| format!("Invalid /json/version response: {}", e))
.map_err(|e| format!("Invalid /json/version response: {}", e))?; }
info.web_socket_debugger_url /// Rewrite the host and port in a WebSocket URL to match the target we
.ok_or_else(|| format!("No webSocketDebuggerUrl in /json/version on port {}", port)) /// actually connected to. Chrome's `/json/version` always returns
/// `ws://127.0.0.1:<local-port>/...` which is unreachable when the
/// browser is on a remote machine or behind a port-forward.
fn rewrite_ws_host(ws_url: &str, host: &str, port: u16) -> String {
if let Ok(mut parsed) = url::Url::parse(ws_url) {
let _ = parsed.set_host(Some(&bracket_ipv6(host)));
let _ = parsed.set_port(Some(port));
parsed.to_string()
} else {
ws_url.to_string()
}
} }
async fn reqwest_get_string(url: &str) -> Result<String, String> { async fn reqwest_get_string(url: &str) -> Result<String, String> {
@@ -57,8 +101,8 @@ mod tests {
let (port, server) = let (port, server) =
spawn_json_server(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#).await; spawn_json_server(r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:1234/"}"#).await;
let ws_url = discover_cdp_url(port).await.unwrap(); let ws_url = discover_cdp_url("127.0.0.1", port).await.unwrap();
assert_eq!(ws_url, "ws://127.0.0.1:1234/"); assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
server.await.unwrap(); server.await.unwrap();
} }
@@ -66,8 +110,22 @@ mod tests {
async fn invalid_json_returns_parse_error() { async fn invalid_json_returns_parse_error() {
let (port, server) = spawn_json_server("not-json").await; let (port, server) = spawn_json_server("not-json").await;
let err = discover_cdp_url(port).await.unwrap_err(); let err = discover_cdp_url("127.0.0.1", port).await.unwrap_err();
assert!(err.contains("Invalid /json/version response")); assert!(err.contains("Invalid /json/version response"));
server.await.unwrap(); server.await.unwrap();
} }
#[test]
fn rewrite_ws_host_replaces_host_and_port() {
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
let rewritten = rewrite_ws_host(original, "10.211.55.12", 9223);
assert_eq!(rewritten, "ws://10.211.55.12:9223/devtools/browser/abc");
}
#[test]
fn rewrite_ws_host_handles_ipv6() {
let original = "ws://127.0.0.1:9222/devtools/browser/abc";
let rewritten = rewrite_ws_host(original, "::1", 9222);
assert_eq!(rewritten, "ws://[::1]:9222/devtools/browser/abc");
}
} }
+3 -3
View File
@@ -6,7 +6,7 @@ use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Duration; use std::time::Duration;
use super::discovery::discover_cdp_url_with_request_timeout; use super::discovery::discover_cdp_url_with_timeout;
const LIGHTPANDA_STARTUP_TIMEOUT: Duration = Duration::from_secs(10); const LIGHTPANDA_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
const LIGHTPANDA_POLL_INTERVAL: Duration = Duration::from_millis(100); const LIGHTPANDA_POLL_INTERVAL: Duration = Duration::from_millis(100);
@@ -257,7 +257,7 @@ async fn wait_for_lightpanda_ready(
)); ));
} }
match discover_cdp_url_with_request_timeout(port, LIGHTPANDA_DISCOVERY_TIMEOUT).await { match discover_cdp_url_with_timeout("127.0.0.1", port, LIGHTPANDA_DISCOVERY_TIMEOUT).await {
Ok(ws_url) => return Ok(ws_url), Ok(ws_url) => return Ok(ws_url),
Err(err) => last_probe_error = Some(err), Err(err) => last_probe_error = Some(err),
} }
@@ -365,7 +365,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(ws_url, "ws://127.0.0.1:9222/"); assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
let _ = child.kill(); let _ = child.kill();
let _ = child.wait(); let _ = child.wait();
} }