@@ -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 \n Content-Length: {}\r \n Content-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