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)