fix: /browser connect times out when another app squats the CDP port

On Windows (and some Linux setups), an application like VS Code's
js-debug can hold 127.0.0.1:9222 while a Chromium browser launched
with --remote-debugging-port=9222 silently binds [::1]:9222 only.
The IPv4-only probe then (a) missed the live browser entirely and
(b) hung against the squatter — which accepts TCP but never answers
the /json/version HTTP probe — repeatedly, driving the whole connect
past the desktop GUI's RPC deadline:
'error: request timed out: browser.manage'.

Fix, applied to both the gateway browser.manage RPC and the CLI
/browser connect path via shared helpers in browser_connect.py:

- discover_local_cdp_url(): probe BOTH loopbacks (127.0.0.1 first,
  then [::1]) and adopt whichever actually speaks CDP.
- local_port_in_use() + find_free_debug_port(): when neither loopback
  speaks CDP but the port is held by another application, report the
  squatter explicitly and launch the debug browser on a nearby free
  port instead of fighting a bind conflict on 9222.
- Bound the gateway's post-launch wait to a 10s deadline (was up to
  20 unbounded probe cycles) so connect always answers inside the
  client RPC timeout.
- _wait_for_browser_debug_ready_or_exit() also probes dual-stack so a
  successful launch pushed onto [::1] is classified 'ready'.

Verified on a live Windows repro (VS Code holding 127.0.0.1:9222,
Chrome 148 on [::1]:9222): connect now resolves http://[::1]:9222
in ~4.5s instead of timing out.
This commit is contained in:
Teknium 2026-07-18 02:32:26 -07:00
parent edfa4cd9b7
commit d93c905808
5 changed files with 389 additions and 37 deletions

View file

@ -174,6 +174,75 @@ def is_browser_debug_ready(url: str, timeout: float = 1.0) -> bool:
return False
# Both loopback literals: Windows (and some Linux setups) can hand the IPv4
# loopback to one process and the IPv6 loopback to another. Chrome asked to
# bind :9222 while e.g. VS Code's js-debug holds 127.0.0.1:9222 will come up
# on [::1]:9222 only — reachable, but invisible to an IPv4-only probe.
_LOOPBACK_PROBE_HOSTS = ("127.0.0.1", "[::1]")
_LOOPBACK_SOCKET_HOSTS = ("127.0.0.1", "::1")
def discover_local_cdp_url(port: int, timeout: float = 1.0) -> str | None:
"""Return the first loopback URL (IPv4 first, then IPv6) speaking CDP.
Dual-stack discovery: when another application squats the IPv4
loopback on ``port``, a debug browser launched with
``--remote-debugging-port`` may bind only ``[::1]``. Probing both
literals finds it either way. Returns ``None`` when neither
loopback exposes a CDP discovery endpoint.
"""
for host in _LOOPBACK_PROBE_HOSTS:
url = f"http://{host}:{port}"
if is_browser_debug_ready(url, timeout=timeout):
return url
return None
def local_port_in_use(port: int, timeout: float = 0.5) -> bool:
"""Return True when either loopback accepts TCP on ``port``.
Callers use this AFTER a failed CDP probe to distinguish "port is
free, we can launch a browser on it" from "another application
(IDE debugger, dev server) is squatting the port and a launch
would fight it".
"""
import socket
for host in _LOOPBACK_SOCKET_HOSTS:
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except OSError:
continue
return False
def find_free_debug_port(preferred: int = DEFAULT_BROWSER_CDP_PORT, attempts: int = 10) -> int:
"""Return the first port after ``preferred`` bindable on both loopbacks.
Used when ``preferred`` is occupied by a non-CDP application: rather
than launching a browser into a bind conflict, pick a nearby free
port. Falls back to ``preferred + 1`` if nothing binds (the launch
will then fail with a clear browser-side error instead of silently
doing nothing).
"""
import socket
for port in range(preferred + 1, preferred + 1 + attempts):
bindable = True
for family, host in ((socket.AF_INET, "127.0.0.1"), (socket.AF_INET6, "::1")):
try:
with socket.socket(family, socket.SOCK_STREAM) as sock:
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((host, port))
except OSError:
bindable = False
break
if bindable:
return port
return preferred + 1
def manual_chrome_debug_command(port: int = DEFAULT_BROWSER_CDP_PORT, system: str | None = None) -> str | None:
system = system or platform.system()
candidates = get_chrome_debug_candidates(system)
@ -213,11 +282,12 @@ def _wait_for_browser_debug_ready_or_exit(
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)):
# Dual-stack: a squatter on the IPv4 loopback can push the browser
# to bind [::1] only — check both so a successful launch is seen.
if discover_local_cdp_url(port, timeout=min(interval, 0.2)):
return "ready"
if proc.poll() is not None:
return "exited"

View file

@ -30,8 +30,11 @@ from rich.panel import Panel
from hermes_constants import display_hermes_home, is_termux as _is_termux_environment
from hermes_cli.browser_connect import (
DEFAULT_BROWSER_CDP_URL,
discover_local_cdp_url,
find_free_debug_port,
is_browser_debug_ready,
launch_chrome_debug,
local_port_in_use,
manual_chrome_debug_command,
)
@ -1849,26 +1852,48 @@ class CLICommandsMixin:
print()
# Check if a Chromium-family browser is already serving CDP on the debug port
_already_open = is_browser_debug_ready(cdp_url, timeout=1.0)
# Check if a Chromium-family browser is already serving CDP on the debug port.
# For the default-local URL, probe both loopbacks (IPv4 + IPv6): a
# squatter on 127.0.0.1:<port> (e.g. an IDE's JS debugger) can push
# the debug browser to bind [::1] only.
_is_default = cdp_url == _DEFAULT_CDP
if _is_default:
_found = discover_local_cdp_url(_port, timeout=1.0)
_already_open = _found is not None
if _found:
cdp_url = _found
else:
_already_open = is_browser_debug_ready(cdp_url, timeout=1.0)
if _already_open:
print(f" ✓ Chromium-family browser is already listening on port {_port}")
elif cdp_url == _DEFAULT_CDP:
# Try to auto-launch a Chromium-family browser with remote debugging
print(" Chromium-family browser isn't running with remote debugging — attempting to launch...")
_launch = launch_chrome_debug(_port, _plat.system())
print(f" ✓ Chromium-family browser is already listening at {cdp_url}")
elif _is_default:
_launch_port = _port
if local_port_in_use(_port):
_launch_port = find_free_debug_port(_port)
print(
f" ⚠ Port {_port} is occupied by another application that isn't a CDP browser"
)
print(
f" (an IDE debugger or dev server may be using it) — launching on port {_launch_port} instead..."
)
else:
# Try to auto-launch a Chromium-family browser with remote debugging
print(" Chromium-family browser isn't running with remote debugging — attempting to launch...")
_launch = launch_chrome_debug(_launch_port, _plat.system())
if _launch.launched:
# Wait for the DevTools discovery endpoint to come up
for _wait in range(10):
if is_browser_debug_ready(cdp_url, timeout=1.0):
_found = discover_local_cdp_url(_launch_port, timeout=1.0)
if _found:
cdp_url = _found
_already_open = True
break
time.sleep(0.5)
if _already_open:
print(f" ✓ Chromium-family browser launched and listening on port {_port}")
print(f" ✓ Chromium-family browser launched and listening on port {_launch_port}")
else:
print(f" ⚠ Browser launched but port {_port} isn't responding yet")
print(f" ⚠ Browser launched but port {_launch_port} isn't responding yet")
print(" Try again in a few seconds — the debug instance may still be starting")
else:
print(" ⚠ Could not auto-launch a Chromium-family browser")
@ -1876,7 +1901,7 @@ class CLICommandsMixin:
if _hint:
print(f" {_hint}")
sys_name = _plat.system()
chrome_cmd = manual_chrome_debug_command(_port, sys_name)
chrome_cmd = manual_chrome_debug_command(_launch_port, sys_name)
if chrome_cmd:
print(" Launch a Chromium-family browser manually:")
print(f" {chrome_cmd}")

View file

@ -0,0 +1,122 @@
"""Dual-stack loopback discovery + port-squatter handling for /browser connect.
Regression context: on Windows, an IDE debugger (VS Code js-debug) holding
127.0.0.1:9222 pushes a Chrome launched with --remote-debugging-port=9222
onto [::1]:9222 only. The old IPv4-only probe missed the live browser AND
hung against the squatter (accepts TCP, never answers HTTP), driving the
whole connect past the desktop GUI's RPC timeout
("error: request timed out: browser.manage").
"""
from __future__ import annotations
import socket
import threading
import pytest
from hermes_cli.browser_connect import (
DEFAULT_BROWSER_CDP_PORT,
discover_local_cdp_url,
find_free_debug_port,
local_port_in_use,
)
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
@pytest.fixture
def ipv4_squatter():
"""A listener on the IPv4 loopback that accepts TCP but never speaks HTTP."""
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 0))
server.listen(5)
port = server.getsockname()[1]
conns: list[socket.socket] = []
stop = threading.Event()
def _accept_loop() -> None:
server.settimeout(0.2)
while not stop.is_set():
try:
conn, _ = server.accept()
conns.append(conn) # hold open, say nothing — like a debug adapter
except OSError:
continue
thread = threading.Thread(target=_accept_loop, daemon=True)
thread.start()
try:
yield port
finally:
stop.set()
thread.join(timeout=2)
for conn in conns:
try:
conn.close()
except OSError:
pass
server.close()
class TestDiscoverLocalCdpUrl:
def test_returns_none_when_nothing_listens(self):
port = _free_port()
assert discover_local_cdp_url(port, timeout=0.3) is None
def test_does_not_hang_on_non_cdp_squatter(self, ipv4_squatter):
"""A TCP-accepting, HTTP-silent squatter must fail the probe within
the timeout instead of being mistaken for a browser."""
assert discover_local_cdp_url(ipv4_squatter, timeout=0.3) is None
def test_finds_ipv6_only_endpoint(self, monkeypatch):
"""When only [::1] speaks CDP (IPv4 side squatted), discovery
returns the IPv6 URL instead of giving up."""
import hermes_cli.browser_connect as bc
def _ready(url: str, timeout: float = 1.0) -> bool:
return "[::1]" in url
monkeypatch.setattr(bc, "is_browser_debug_ready", _ready)
assert bc.discover_local_cdp_url(9222) == "http://[::1]:9222"
def test_prefers_ipv4_when_both_answer(self, monkeypatch):
import hermes_cli.browser_connect as bc
monkeypatch.setattr(bc, "is_browser_debug_ready", lambda *_a, **_k: True)
assert bc.discover_local_cdp_url(9222) == "http://127.0.0.1:9222"
class TestLocalPortInUse:
def test_free_port_reports_unused(self):
assert local_port_in_use(_free_port(), timeout=0.3) is False
def test_squatted_port_reports_used(self, ipv4_squatter):
assert local_port_in_use(ipv4_squatter, timeout=0.5) is True
class TestFindFreeDebugPort:
def test_returns_port_above_preferred(self):
port = find_free_debug_port(DEFAULT_BROWSER_CDP_PORT)
assert port > DEFAULT_BROWSER_CDP_PORT
def test_skips_occupied_successor(self):
"""When preferred+1 is held on IPv4, the next candidate is chosen."""
preferred = _free_port()
blocker = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
blocker.bind(("127.0.0.1", preferred + 1))
blocker.listen(1)
port = find_free_debug_port(preferred)
assert port != preferred + 1
assert port > preferred
except OSError:
pytest.skip("successor port unavailable to bind in this environment")
finally:
blocker.close()

View file

@ -8183,7 +8183,9 @@ def test_browser_manage_connect_sets_env_and_cleans_twice(monkeypatch):
assert resp["result"]["connected"] is True
assert resp["result"]["url"] == "http://127.0.0.1:9222"
assert resp["result"]["messages"] == ["Chromium-family browser is already listening on port 9222"]
assert resp["result"]["messages"] == [
"Chromium-family browser is already listening at http://127.0.0.1:9222"
]
assert os.environ.get("BROWSER_CDP_URL") == "http://127.0.0.1:9222"
# First cleanup runs against the OLD env (none here), second against the NEW.
assert cleanup_calls == ["", "http://127.0.0.1:9222"]
@ -8203,7 +8205,9 @@ def test_browser_manage_connect_defaults_to_loopback(monkeypatch):
assert resp["result"]["connected"] is True
assert resp["result"]["url"] == "http://127.0.0.1:9222"
assert resp["result"]["messages"] == ["Chromium-family browser is already listening on port 9222"]
assert resp["result"]["messages"] == [
"Chromium-family browser is already listening at http://127.0.0.1:9222"
]
assert urls[0] == "http://127.0.0.1:9222/json/version"
@ -8226,6 +8230,7 @@ def test_browser_manage_connect_default_local_reports_launch_hint(monkeypatch):
"hermes_cli.browser_connect.launch_chrome_debug",
return_value=ChromeDebugLaunch(),
),
patch("hermes_cli.browser_connect.local_port_in_use", return_value=False),
patch("hermes_cli.browser_connect.manual_chrome_debug_command", return_value=None),
patch(
"hermes_cli.browser_connect.get_chrome_debug_candidates",
@ -8358,9 +8363,13 @@ def test_browser_manage_connect_default_local_retries_after_launch(monkeypatch):
def __exit__(self, *_):
return False
# IPv4 answers only from the 3rd probe onwards (browser still starting);
# the IPv6 loopback never answers.
attempts = {"n": 0}
def _opener(_url, timeout=2.0): # noqa: ARG001 — match urllib signature
def _opener(url, timeout=2.0): # noqa: ARG001 — match urllib signature
if "[::1]" in url:
raise OSError("no IPv6 listener")
attempts["n"] += 1
if attempts["n"] < 3:
raise OSError("not ready")
@ -8369,9 +8378,14 @@ def test_browser_manage_connect_default_local_retries_after_launch(monkeypatch):
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", _opener)
launched = ChromeDebugLaunch(launched=True)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
with patch(
"hermes_cli.browser_connect.try_launch_chrome_debug", return_value=True
with (
patch(
"hermes_cli.browser_connect.launch_chrome_debug",
return_value=launched,
),
patch("hermes_cli.browser_connect.local_port_in_use", return_value=False),
):
resp = server.handle_request(
{"id": "1", "method": "browser.manage", "params": {"action": "connect"}}
@ -8386,6 +8400,94 @@ def test_browser_manage_connect_default_local_retries_after_launch(monkeypatch):
assert os.environ["BROWSER_CDP_URL"] == "http://127.0.0.1:9222"
def test_browser_manage_connect_finds_ipv6_only_browser(monkeypatch):
"""Regression: an IDE debugger squatting 127.0.0.1:9222 pushes the debug
browser onto [::1]:9222. Connect must discover and adopt the IPv6
endpoint instead of timing out against the squatter."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: None,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
class _Resp:
status = 200
def __enter__(self):
return self
def __exit__(self, *_):
return False
def _opener(url, timeout=2.0): # noqa: ARG001 — match urllib signature
if "[::1]" in url:
return _Resp()
raise OSError("IPv4 loopback held by a non-CDP squatter")
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", _opener)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
resp = server.handle_request(
{"id": "1", "method": "browser.manage", "params": {"action": "connect"}}
)
assert resp["result"]["connected"] is True
assert resp["result"]["url"] == "http://[::1]:9222"
assert os.environ["BROWSER_CDP_URL"] == "http://[::1]:9222"
def test_browser_manage_connect_squatted_port_launches_on_alternate(monkeypatch):
"""When neither loopback speaks CDP but the port is held by another
application, connect must pick an alternate port for the launch and
say so never fight the squatter for 9222."""
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
monkeypatch.setattr(server.time, "sleep", lambda _seconds: None)
fake = types.SimpleNamespace(
cleanup_all_browsers=lambda: None,
_get_cdp_override=lambda: os.environ.get("BROWSER_CDP_URL", ""),
)
class _Resp:
status = 200
def __enter__(self):
return self
def __exit__(self, *_):
return False
def _opener(url, timeout=2.0): # noqa: ARG001 — match urllib signature
if ":9223" in url and "127.0.0.1" in url:
return _Resp() # relaunched browser comes up on the alternate port
raise OSError("9222 squatted / nothing else listening")
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", _opener)
launch_ports: list[int] = []
def _launch(port, _system):
launch_ports.append(port)
return ChromeDebugLaunch(launched=True)
with patch.dict(sys.modules, {"tools.browser_tool": fake}):
with (
patch("hermes_cli.browser_connect.launch_chrome_debug", side_effect=_launch),
patch("hermes_cli.browser_connect.local_port_in_use", return_value=True),
patch("hermes_cli.browser_connect.find_free_debug_port", return_value=9223),
):
resp = server.handle_request(
{"id": "1", "method": "browser.manage", "params": {"action": "connect"}}
)
assert launch_ports == [9223]
assert resp["result"]["connected"] is True
assert resp["result"]["url"] == "http://127.0.0.1:9223"
assert os.environ["BROWSER_CDP_URL"] == "http://127.0.0.1:9223"
assert any("occupied by another application" in m for m in resp["result"]["messages"])
def test_browser_manage_connect_rejects_unreachable_endpoint(monkeypatch):
"""An unreachable endpoint must NOT mutate the env or reap sessions."""
monkeypatch.setenv("BROWSER_CDP_URL", "http://existing:9222")

View file

@ -14975,40 +14975,73 @@ def _browser_connect(rid, params: dict) -> dict:
pass
except OSError as e:
return _err(rid, 5031, f"could not reach browser CDP at {url}: {e}")
else:
probes = _probe_urls(parsed)
ok = any(_http_ok(p, timeout=2.0) for p in probes)
elif _is_default_local_cdp(parsed):
from hermes_cli.browser_connect import (
discover_local_cdp_url,
find_free_debug_port,
launch_chrome_debug,
local_port_in_use,
)
if not ok and _is_default_local_cdp(parsed):
from hermes_cli.browser_connect import launch_chrome_debug
# Dual-stack discovery: when another app (an IDE debugger,
# a dev server) squats the IPv4 loopback on the debug port,
# a browser asked to bind that port comes up on [::1] only.
# An IPv4-only probe misses it AND hangs against squatters
# that accept TCP but never answer HTTP — the historic
# cause of `browser.manage` RPC timeouts.
discovered = discover_local_cdp_url(port, timeout=2.0)
launch_port = port
announce(
"Chromium-family browser isn't running with remote debugging — attempting to launch..."
)
if discovered is None:
if local_port_in_use(port):
launch_port = find_free_debug_port(port)
announce(
f"Port {port} is occupied by another application that "
"isn't a CDP browser (an IDE debugger or dev server may "
f"be using it) — launching a debug browser on port "
f"{launch_port} instead..."
)
else:
announce(
"Chromium-family browser isn't running with remote debugging — attempting to launch..."
)
launch = launch_chrome_debug(port, system)
launch = launch_chrome_debug(launch_port, system)
if launch.launched:
for _ in range(20):
time.sleep(0.5)
if any(_http_ok(p, timeout=1.0) for p in probes):
ok = True
# Bounded wait: the whole connect must finish well
# inside the client RPC timeout.
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
discovered = discover_local_cdp_url(launch_port, timeout=1.0)
if discovered:
break
time.sleep(0.5)
if ok:
announce(f"Chromium-family browser launched and listening on port {port}")
if discovered:
announce(
f"Chromium-family browser launched and listening on port {launch_port}"
)
else:
hint = launch.hint
if hint:
announce(hint, level="error")
for line in _failure_messages(url, port, system)[1:]:
for line in _failure_messages(url, launch_port, system)[1:]:
announce(line, level="error")
return _ok(
rid, {"connected": False, "url": url, "messages": messages}
)
elif not ok:
else:
announce(f"Chromium-family browser is already listening at {discovered}")
# Adopt whatever loopback/port actually answered (may be
# [::1] and/or an alternate port when 9222 was squatted).
url = discovered
parsed = urlparse(url)
else:
probes = _probe_urls(parsed)
ok = any(_http_ok(p, timeout=2.0) for p in probes)
if not ok:
return _err(rid, 5031, f"could not reach browser CDP at {url}")
elif _is_default_local_cdp(parsed):
announce(f"Chromium-family browser is already listening on port {port}")
normalized = _normalize_cdp_url(parsed)