refactor(desktop): use port 0 for ephemeral port discovery instead of PortPool reservation

Replace the PortPool-based port reservation system (9120-9199 range) with OS-assigned ephemeral ports via --port 0.

Before: Desktop probed a hardcoded port range, reserved ports in-process to close TOCTOU races, and passed the chosen port to the dashboard via CLI arg.

After: Desktop spawns dashboard with --port 0, parses the actual port from a stdout announcement line (HERMES_DASHBOARD_READY port=<N>), and uses that for WebSocket connections.

Changes:
- web_server.py: add --port 0 support with SO_REUSEADDR pre-bind + announcement; add EADDRINUSE preflight for explicit ports
- main.cjs: remove PortPool, PORT_FLOOR/CEILING, pickPort(), isPortAvailable(); add waitForDashboardPort() stdout parser
- Delete port-pool.cjs and port-pool.test.cjs (106 lines removed)

Net effect: eliminates the entire TOCTOU-mitigation reservation infrastructure and arbitrary port range constraints. OS handles port allocation natively.
This commit is contained in:
ethernet
2026-06-12 14:02:19 -04:00
parent 8044bf0206
commit 1e25358a8f
9 changed files with 305 additions and 270 deletions
+106 -50
View File
@@ -11568,6 +11568,62 @@ app.include_router(_dashboard_auth_router)
mount_spa(app)
def _read_bound_port(server: "uvicorn.Server", fallback: int) -> int:
"""Read the OS-assigned port from a live uvicorn server socket.
After ``server.startup()`` the socket is bound. Returns the actual
port so ephemeral (port-0) discovery works without a pre-bind TOCTOU.
Falls back to *fallback* if the socket list is empty (shouldn't happen
but guards against uvicorn internals changing).
"""
if server.servers and server.servers[0].sockets:
return server.servers[0].sockets[0].getsockname()[1]
return fallback
def _maybe_open_browser(
host: str, actual_port: int, open_browser: bool, initial_profile: str
) -> None:
"""Open the dashboard URL in the user's browser if appropriate.
Skips on headless Linux (no ``DISPLAY`` / ``WAYLAND_DISPLAY``) to avoid
TUI browsers (links, lynx) that would SIGHUP the server process.
Maps ``0.0.0.0`` / ``::`` binds to ``127.0.0.1`` so the browser opens
a reachable URL.
"""
if not open_browser:
return
import webbrowser
_has_display = (
sys.platform != "linux"
or bool(os.environ.get("DISPLAY"))
or bool(os.environ.get("WAYLAND_DISPLAY"))
)
if not _has_display:
_log.debug(
"Skipping browser-open: no DISPLAY or WAYLAND_DISPLAY detected "
"(headless Linux). Pass --no-open to suppress this detection."
)
return
_display_host = host if host not in ("0.0.0.0", "::") else "127.0.0.1"
_open_url = f"http://{_display_host}:{actual_port}"
if initial_profile:
from urllib.parse import quote
_open_url += f"/?profile={quote(initial_profile)}"
def _open():
try:
time.sleep(1.0)
webbrowser.open(_open_url)
except Exception:
pass
threading.Thread(target=_open, daemon=True).start()
def start_server(
host: str = "127.0.0.1",
port: int = 9119,
@@ -11650,60 +11706,60 @@ def start_server(
# Record the bound host so host_header_middleware can validate incoming
# Host headers against it. Defends against DNS rebinding (GHSA-ppp5-vxwm-4cf7).
# bound_port is also stashed so /api/pty can build the back-WS URL the
# PTY child uses to publish events to the dashboard sidebar.
app.state.bound_host = host
app.state.bound_port = port
if open_browser:
import webbrowser
# On headless Linux (no DISPLAY or WAYLAND_DISPLAY) some registered
# browsers are TUI programs (links, lynx, www-browser) that try to
# take over the terminal. That can send SIGHUP to the server process
# and cause an immediate exit even though uvicorn bound successfully.
# Skip the auto-open attempt on headless systems and let the user
# open the URL manually. macOS and Windows are always considered
# display-capable.
_has_display = (
sys.platform != "linux"
or bool(os.environ.get("DISPLAY"))
or bool(os.environ.get("WAYLAND_DISPLAY"))
)
if _has_display:
_open_url = f"http://{host}:{port}"
if initial_profile:
from urllib.parse import quote
_open_url += f"/?profile={quote(initial_profile)}"
def _open():
try:
time.sleep(1.0)
webbrowser.open(_open_url)
except Exception:
pass
threading.Thread(target=_open, daemon=True).start()
else:
_log.debug(
"Skipping browser-open: no DISPLAY or WAYLAND_DISPLAY detected "
"(headless Linux). Pass --no-open to suppress this detection."
)
print(f" Hermes Web UI → http://{host}:{port}")
# proxy_headers defaults to False so _ws_client_is_allowed sees the real
# connection peer rather than X-Forwarded-For's rewritten value (which
# would defeat the loopback gate when behind a reverse proxy). When the
# OAuth gate is active we are explicitly running behind a TLS terminator
# (Fly.io) and need X-Forwarded-Proto to decide cookie Secure flags, so
# we flip proxy_headers on for that mode.
uvicorn.run(
# ── Start uvicorn with direct Server API ─────────────────────────
# We use uvicorn.Server directly (not uvicorn.run) so we can split
# startup from the main loop. After startup() the socket is actually
# bound — we read the OS-assigned port from the live socket, print
# HERMES_DASHBOARD_READY, open the browser, *then* serve.
#
# This eliminates the TOCTOU of the old pre-bind-then-close approach
# (bind port 0 → close → uvicorn rebind): the socket is held by
# uvicorn the entire time, so no other process can steal the port.
#
# For explicit non-zero ports, if the port is taken uvicorn catches
# OSError inside create_server() and exits with a clear error — no
# separate preflight probe needed.
config = uvicorn.Config(
app, host=host, port=port, log_level="warning",
# proxy_headers defaults to False so _ws_client_is_allowed sees
# the real connection peer rather than X-Forwarded-For's rewritten
# value (which would defeat the loopback gate when behind a reverse
# proxy). When the OAuth gate is active we are explicitly running
# behind a TLS terminator (Fly.io) and need X-Forwarded-Proto to
# decide cookie Secure flags, so we flip proxy_headers on for that
# mode.
proxy_headers=bool(app.state.auth_required),
# Detect half-open WS connections (reverse-proxy 524, dropped tunnels)
# within ~20-40s so WebSocketDisconnect fires the disconnect→reap path.
# 20s stays under Cloudflare Tunnel's idle timeout, keeping it warm.
# Detect half-open WS connections (reverse-proxy 524, dropped
# tunnels) within ~20-40s so WebSocketDisconnect fires the
# disconnect→reap path. 20s stays under Cloudflare Tunnel's idle
# timeout, keeping it warm.
ws_ping_interval=20.0,
ws_ping_timeout=20.0,
)
server = uvicorn.Server(config)
async def _serve():
# Split startup from main_loop so we can read the bound port
# after the socket is live (ephemeral port discovery).
if not config.loaded:
config.load()
server.lifespan = config.lifespan_class(config)
with server.capture_signals():
await server.startup()
if server.should_exit:
return
actual_port = _read_bound_port(server, fallback=port)
app.state.bound_port = actual_port
print(f"HERMES_DASHBOARD_READY port={actual_port}", flush=True)
print(f" Hermes Web UI → http://{host}:{actual_port}")
_maybe_open_browser(host, actual_port, open_browser, initial_profile)
await server.main_loop()
if server.started:
await server.shutdown()
asyncio.run(_serve())