From 6dfcaa839f1175518e378963c79cc7bd3ceb7946 Mon Sep 17 00:00:00 2001 From: oxura Date: Fri, 5 Jun 2026 07:19:00 +0600 Subject: [PATCH] fix(wrap): report unbindable proxy ports (#602) --- headroom/cli/wrap.py | 29 ++++++++++++++++++++++++++ tests/test_cli/test_wrap_persistent.py | 25 ++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 0959a602f..3e51e0eb3 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -141,6 +141,29 @@ def _check_proxy(port: int) -> bool: return False +def _port_bind_error(port: int) -> OSError | None: + """Return the bind error for a local proxy port, or None when it is usable.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", port)) + except OSError as exc: + return exc + return None + + +def _format_unbindable_port_error(port: int, error: OSError, agent_type: str) -> str: + """Build an actionable message for ports that fail before uvicorn can bind.""" + command = "headroom proxy" + if agent_type != "unknown": + command = f"headroom wrap {agent_type}" + suggested_port = port + 1 + return ( + f"Port {port} is unavailable on 127.0.0.1 before the proxy can start: {error}. " + "On Windows this can happen when the port is in an excluded or reserved range. " + f"Rerun with a different port, for example `{command} --port {suggested_port}`." + ) + + def _get_log_path() -> Path: """Get path for proxy log file.""" from headroom import paths as _paths @@ -1740,6 +1763,12 @@ def _ensure_proxy( return None # Start (or restart) the proxy with the requested flags + bind_error = helpers._port_bind_error(port) + if bind_error is not None: + raise click.ClickException( + helpers._format_unbindable_port_error(port, bind_error, agent_type) + ) + click.echo(f" Starting Headroom proxy on port {port}...") try: proc = cast( diff --git a/tests/test_cli/test_wrap_persistent.py b/tests/test_cli/test_wrap_persistent.py index 83503a1e9..4531e5d73 100644 --- a/tests/test_cli/test_wrap_persistent.py +++ b/tests/test_cli/test_wrap_persistent.py @@ -88,6 +88,31 @@ def test_ensure_proxy_falls_back_when_persistent_manifest_is_stale(monkeypatch) assert calls == ["start"] +def test_ensure_proxy_reports_unbindable_port_before_starting_subprocess(monkeypatch) -> None: + calls: list[str] = [] + + monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: False) + monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None) + monkeypatch.setattr( + wrap_cli, + "_port_bind_error", + lambda port: PermissionError(10013, "access denied by OS port reservation"), + ) + monkeypatch.setattr(wrap_cli, "_start_proxy", lambda *args, **kwargs: calls.append("start")) + + try: + wrap_cli._ensure_proxy(8787, False, agent_type="cursor") + except click.ClickException as exc: + message = str(exc) + else: + raise AssertionError("expected unbindable port to raise before starting proxy") + + assert "Port 8787 is unavailable" in message + assert "Windows" in message + assert "headroom wrap cursor --port 8788" in message + assert calls == [] + + def test_ensure_proxy_restarts_idle_stale_persistent_deployment(monkeypatch) -> None: calls: list[str] = [] health = {