diff --git a/hermes_cli/browser_connect.py b/hermes_cli/browser_connect.py index 7ed4f2e4da4..feac74ce83d 100644 --- a/hermes_cli/browser_connect.py +++ b/hermes_cli/browser_connect.py @@ -7,6 +7,7 @@ import platform import shlex import shutil import subprocess +import time from hermes_constants import get_hermes_home @@ -196,6 +197,31 @@ def _detach_kwargs(system: str) -> dict: return {"creationflags": flags} if flags else {} +def _wait_for_browser_debug_ready_or_exit( + proc: subprocess.Popen, + port: int, + timeout: float = 2.0, + interval: float = 0.1, +) -> str: + """Classify a launched browser as ready, exited, or still starting. + + We only need to wait long enough to catch the common failure mode where a + candidate binary exists but exits immediately before exposing the CDP port. + Slower browsers can still finish starting after this grace window. + """ + cdp_url = f"http://127.0.0.1:{port}" + deadline = time.monotonic() + timeout + + while time.monotonic() < deadline: + if is_browser_debug_ready(cdp_url, timeout=min(interval, 0.2)): + return "ready" + if proc.poll() is not None: + return "exited" + time.sleep(interval) + + return "starting" + + def try_launch_chrome_debug(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> bool: system = system or platform.system() candidates = get_chrome_debug_candidates(system) @@ -205,13 +231,15 @@ def try_launch_chrome_debug(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | os.makedirs(chrome_debug_data_dir(), exist_ok=True) for candidate in candidates: try: - subprocess.Popen( + proc = subprocess.Popen( [candidate, *_chrome_debug_args(port)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **_detach_kwargs(system), ) - return True + state = _wait_for_browser_debug_ready_or_exit(proc, port) + if state != "exited": + return True except Exception: continue return False diff --git a/tests/cli/test_cli_browser_connect.py b/tests/cli/test_cli_browser_connect.py index cf75a2ec9cf..93d5a42b496 100644 --- a/tests/cli/test_cli_browser_connect.py +++ b/tests/cli/test_cli_browser_connect.py @@ -9,6 +9,7 @@ from unittest.mock import patch from cli import HermesCLI from hermes_cli.browser_connect import ( + _wait_for_browser_debug_ready_or_exit, get_chrome_debug_candidates, is_browser_debug_ready, manual_chrome_debug_command, @@ -65,6 +66,7 @@ class TestChromeDebugLaunch: with patch("hermes_cli.browser_connect.shutil.which", side_effect=lambda name: r"C:\Chrome\chrome.exe" if name == "chrome.exe" else None), \ patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == r"C:\Chrome\chrome.exe"), \ + patch("hermes_cli.browser_connect._wait_for_browser_debug_ready_or_exit", return_value="ready"), \ patch("subprocess.Popen", side_effect=fake_popen): assert HermesCLI._try_launch_chrome_debug(9333, "Windows") is True @@ -94,6 +96,7 @@ class TestChromeDebugLaunch: with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \ patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == installed), \ + patch("hermes_cli.browser_connect._wait_for_browser_debug_ready_or_exit", return_value="ready"), \ patch("subprocess.Popen", side_effect=fake_popen): assert HermesCLI._try_launch_chrome_debug(9222, "Windows") is True @@ -199,6 +202,40 @@ class TestChromeDebugLaunch: assert attempts == [brave, chrome] + def test_wait_for_browser_debug_ready_or_exit_detects_early_exit(self, monkeypatch): + class _Proc: + def __init__(self): + self.calls = 0 + + def poll(self): + self.calls += 1 + return 1 if self.calls >= 2 else None + + monkeypatch.setattr("hermes_cli.browser_connect.time.sleep", lambda _seconds: None) + with patch("hermes_cli.browser_connect.is_browser_debug_ready", return_value=False): + state = _wait_for_browser_debug_ready_or_exit(_Proc(), 9222, timeout=0.3, interval=0.01) + + assert state == "exited" + + def test_launch_tries_next_browser_when_first_candidate_exits_before_debug_ready(self): + brave = "/usr/bin/brave-browser" + chrome = "/usr/bin/google-chrome" + attempts = [] + + class _Proc: + pass + + def fake_popen(cmd, **kwargs): + attempts.append(cmd[0]) + return _Proc() + + with patch("hermes_cli.browser_connect.get_chrome_debug_candidates", return_value=[brave, chrome]), \ + patch("hermes_cli.browser_connect._wait_for_browser_debug_ready_or_exit", side_effect=["exited", "ready"]), \ + patch("subprocess.Popen", side_effect=fake_popen): + assert HermesCLI._try_launch_chrome_debug(9222, "Linux") is True + + assert attempts == [brave, chrome] + def test_manual_command_uses_wsl_windows_chrome_when_available(self): chrome = "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"