Skip to content

Commit b02e485

Browse files
fix: prefer DevToolsActivePort websocket path over HTTP discovery in --auto-connect (#1218)
* fix: prefer DevToolsActivePort websocket path over HTTP discovery in --auto-connect Reverses the discovery order in `auto_connect_cdp()` so the exact WebSocket path from DevToolsActivePort is tried first, falling back to legacy HTTP endpoints (`/json/version`, `/json/list`) only when the direct path fails. This eliminates the duplicate remote-debugging permission prompts caused by unnecessary HTTP probes on Chrome M144+. Also adds `verify_ws_endpoint()` to validate the WebSocket URL is a live CDP server before returning it, preventing stale URLs from being handed to callers. Fixes #1210 Fixes #1206 * chore: remove unrelated issue references from test comment * style: apply rustfmt --------- Co-authored-by: hyunjinee <leehj0110@kakao.com>
1 parent db29d5f commit b02e485

File tree

1 file changed

+145
-14
lines changed

1 file changed

+145
-14
lines changed

cli/src/native/cdp/chrome.rs

Lines changed: 145 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -654,16 +654,7 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
654654

655655
for dir in &user_data_dirs {
656656
if let Some((port, ws_path)) = read_devtools_active_port(dir) {
657-
// Try HTTP endpoint first (pre-M144)
658-
if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
659-
return Ok(ws_url);
660-
}
661-
// M144+: direct WebSocket — verify the port is actually listening
662-
// before returning, otherwise a stale DevToolsActivePort file
663-
// (left behind after Chrome exits/crashes) produces a confusing
664-
// "connection refused" error instead of falling through.
665-
if is_port_reachable(port) {
666-
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
657+
if let Ok(ws_url) = resolve_cdp_from_active_port(port, &ws_path).await {
667658
return Ok(ws_url);
668659
}
669660
// Port is dead — remove the stale file so future runs skip it.
@@ -682,10 +673,54 @@ pub async fn auto_connect_cdp() -> Result<String, String> {
682673
Err("No running Chrome instance found. Launch Chrome with --remote-debugging-port or use --cdp.".to_string())
683674
}
684675

685-
fn is_port_reachable(port: u16) -> bool {
686-
use std::net::TcpStream;
687-
let addr = format!("127.0.0.1:{}", port);
688-
TcpStream::connect_timeout(&addr.parse().unwrap(), Duration::from_millis(500)).is_ok()
676+
/// Resolve a CDP WebSocket URL from a DevToolsActivePort entry.
677+
///
678+
/// Tries the exact WebSocket path from DevToolsActivePort first (single
679+
/// prompt on M144+), then falls back to legacy HTTP discovery for older
680+
/// Chrome versions. This order avoids triggering duplicate remote-debugging
681+
/// permission prompts (#1210, #1206).
682+
async fn resolve_cdp_from_active_port(port: u16, ws_path: &str) -> Result<String, String> {
683+
let ws_url = format!("ws://127.0.0.1:{}{}", port, ws_path);
684+
if verify_ws_endpoint(&ws_url).await {
685+
return Ok(ws_url);
686+
}
687+
688+
// Pre-M144 fallback: HTTP endpoints (/json/version, /json/list, etc.)
689+
if let Ok(ws_url) = discover_cdp_url("127.0.0.1", port, None).await {
690+
return Ok(ws_url);
691+
}
692+
693+
Err(format!(
694+
"Cannot connect to Chrome on port {}: both direct WebSocket and HTTP discovery failed",
695+
port
696+
))
697+
}
698+
699+
/// Verify that a WebSocket endpoint is a live CDP server by sending
700+
/// `Browser.getVersion` and checking for a valid response.
701+
async fn verify_ws_endpoint(ws_url: &str) -> bool {
702+
use futures_util::{SinkExt, StreamExt};
703+
use tokio_tungstenite::tungstenite::Message;
704+
705+
let timeout = Duration::from_secs(2);
706+
let result = tokio::time::timeout(timeout, async {
707+
let (mut ws, _) = tokio_tungstenite::connect_async(ws_url).await.ok()?;
708+
let cmd = r#"{"id":1,"method":"Browser.getVersion"}"#;
709+
ws.send(Message::Text(cmd.into())).await.ok()?;
710+
while let Some(Ok(msg)) = ws.next().await {
711+
if let Message::Text(text) = msg {
712+
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
713+
if v.get("id").and_then(|id| id.as_u64()) == Some(1) {
714+
let _ = ws.close(None).await;
715+
return Some(());
716+
}
717+
}
718+
}
719+
}
720+
None
721+
})
722+
.await;
723+
matches!(result, Ok(Some(())))
689724
}
690725

691726
/// Returns the default Chrome user-data directory paths for the current platform.
@@ -1826,4 +1861,100 @@ mod tests {
18261861
"profile path should keep keychain flags"
18271862
);
18281863
}
1864+
1865+
// -------------------------------------------------------------------
1866+
// auto_connect_cdp discovery-order tests (#1210, #1206)
1867+
// -------------------------------------------------------------------
1868+
1869+
/// When DevToolsActivePort provides a ws_path and the port is reachable,
1870+
/// `resolve_cdp_from_active_port` should return the exact ws_path URL
1871+
/// WITHOUT calling HTTP discovery first.
1872+
#[tokio::test]
1873+
async fn test_resolve_cdp_from_active_port_prefers_ws_path() {
1874+
use futures_util::{SinkExt, StreamExt};
1875+
use tokio_tungstenite::tungstenite::Message as WsMsg;
1876+
1877+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1878+
let port = listener.local_addr().unwrap().port();
1879+
let ws_path = "/devtools/browser/test-uuid-1234".to_string();
1880+
1881+
let server = tokio::spawn(async move {
1882+
// accept: verify_ws_endpoint() WebSocket handshake
1883+
let (stream, _) = listener.accept().await.unwrap();
1884+
let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
1885+
if let Some(Ok(WsMsg::Text(text))) = ws.next().await {
1886+
let req: serde_json::Value = serde_json::from_str(&text).unwrap();
1887+
let id = req.get("id").unwrap();
1888+
let reply = format!(
1889+
r#"{{"id":{},"result":{{"protocolVersion":"1.3","product":"Chrome/147"}}}}"#,
1890+
id
1891+
);
1892+
ws.send(WsMsg::Text(reply)).await.unwrap();
1893+
}
1894+
let _ = ws.close(None).await;
1895+
});
1896+
1897+
let result = resolve_cdp_from_active_port(port, &ws_path).await;
1898+
assert!(result.is_ok(), "should succeed: {:?}", result);
1899+
let url = result.unwrap();
1900+
assert!(
1901+
url.contains("test-uuid-1234"),
1902+
"should use exact ws_path from DevToolsActivePort, got: {}",
1903+
url
1904+
);
1905+
assert_eq!(url, format!("ws://127.0.0.1:{}{}", port, ws_path));
1906+
server.await.unwrap();
1907+
}
1908+
1909+
/// When the exact ws_path connection fails, `resolve_cdp_from_active_port`
1910+
/// should fall back to HTTP discovery.
1911+
#[tokio::test]
1912+
async fn test_resolve_cdp_from_active_port_falls_back_to_http_discovery() {
1913+
use tokio::io::{AsyncReadExt, AsyncWriteExt};
1914+
1915+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1916+
let port = listener.local_addr().unwrap().port();
1917+
1918+
let server = tokio::spawn(async move {
1919+
// 1st accept: verify_ws_endpoint() ws_path probe — reject (just close)
1920+
let (s1, _) = listener.accept().await.unwrap();
1921+
drop(s1);
1922+
1923+
// 2nd accept: HTTP /json/version from discover_cdp_url()
1924+
let (mut s2, _) = listener.accept().await.unwrap();
1925+
let mut buf = [0u8; 2048];
1926+
let _ = s2.read(&mut buf).await;
1927+
let body = format!(
1928+
r#"{{"webSocketDebuggerUrl":"ws://127.0.0.1:{}/devtools/browser/fallback-uuid"}}"#,
1929+
port
1930+
);
1931+
let resp = format!(
1932+
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{}",
1933+
body.len(),
1934+
body
1935+
);
1936+
s2.write_all(resp.as_bytes()).await.unwrap();
1937+
});
1938+
1939+
let result = resolve_cdp_from_active_port(port, "/devtools/browser/nonexistent-uuid").await;
1940+
assert!(result.is_ok(), "should fall back to HTTP: {:?}", result);
1941+
let url = result.unwrap();
1942+
assert!(
1943+
url.contains("fallback-uuid"),
1944+
"should use HTTP discovery fallback, got: {}",
1945+
url
1946+
);
1947+
server.await.unwrap();
1948+
}
1949+
1950+
/// When neither ws_path nor HTTP discovery works, return an error.
1951+
#[tokio::test]
1952+
async fn test_resolve_cdp_from_active_port_both_fail() {
1953+
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1954+
let port = listener.local_addr().unwrap().port();
1955+
drop(listener);
1956+
1957+
let result = resolve_cdp_from_active_port(port, "/devtools/browser/dead").await;
1958+
assert!(result.is_err(), "should fail when nothing is listening");
1959+
}
18291960
}

0 commit comments

Comments
 (0)