245 lines
8.4 KiB
Python
245 lines
8.4 KiB
Python
"""#93608 — port bind conflict must be machine-readable, not a bare exit 1.
|
|
|
|
The reporter's repro: something already listens on the serve port (another
|
|
``hermes serve``, the gateway, anything) → ``hermes serve`` printed only
|
|
uvicorn's ``ERROR: [Errno 98/10048] error while attempting to bind on
|
|
address`` and exited 1 — indistinguishable from a broken backend for the
|
|
desktop spawn and for scripts.
|
|
|
|
Contract under test:
|
|
|
|
* conflict → single stdout sentinel ``BACKEND_PORT_IN_USE port=<port>`` +
|
|
a human hint line + exit code 75 (EX_TEMPFAIL, the repo's existing
|
|
transient-condition convention) — and NO ``HERMES_BACKEND_READY``.
|
|
* free explicit port → boots and announces ``HERMES_BACKEND_READY`` exactly
|
|
as before (contract untouched).
|
|
* ``--port 0`` (ephemeral) → probe skipped, boots, announces the
|
|
OS-assigned port.
|
|
"""
|
|
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
pytestmark = pytest.mark.skipif(
|
|
sys.platform == "win32", reason="POSIX serve-runner path under test"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unit: the probe itself
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_probe_detects_held_socket():
|
|
from hermes_cli.web_server import _port_bind_conflict
|
|
|
|
holder = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
holder.bind(("127.0.0.1", 0))
|
|
holder.listen(1)
|
|
port = holder.getsockname()[1]
|
|
try:
|
|
assert _port_bind_conflict("127.0.0.1", port) is True
|
|
finally:
|
|
holder.close()
|
|
|
|
|
|
def test_probe_free_port_is_clean():
|
|
from hermes_cli.web_server import _port_bind_conflict
|
|
|
|
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
probe.bind(("127.0.0.1", 0))
|
|
port = probe.getsockname()[1]
|
|
probe.close()
|
|
assert _port_bind_conflict("127.0.0.1", port) is False
|
|
|
|
|
|
def test_probe_skips_ephemeral_port_zero():
|
|
from hermes_cli.web_server import _port_bind_conflict
|
|
|
|
# port 0 can never conflict — must short-circuit False, never bind.
|
|
assert _port_bind_conflict("127.0.0.1", 0) is False
|
|
|
|
|
|
def test_addr_in_use_error_classification():
|
|
import errno
|
|
|
|
from hermes_cli.web_server import _is_addr_in_use_error
|
|
|
|
assert _is_addr_in_use_error(OSError(errno.EADDRINUSE, "in use")) is True
|
|
assert _is_addr_in_use_error(OSError(98, "linux")) is True
|
|
assert _is_addr_in_use_error(OSError(10048, "winsock")) is True
|
|
assert _is_addr_in_use_error(OSError(errno.EACCES, "denied")) is False
|
|
|
|
|
|
def test_exit_code_is_distinct_tempfail():
|
|
from hermes_cli.web_server import PORT_IN_USE_EXIT_CODE
|
|
|
|
assert PORT_IN_USE_EXIT_CODE == 75 # EX_TEMPFAIL — repo convention
|
|
assert PORT_IN_USE_EXIT_CODE != 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# E2E: real start_server in a subprocess (temp HERMES_HOME)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _spawn_serve(port: int, tmp_path: Path, merge_stderr: bool = True) -> subprocess.Popen:
|
|
home = tmp_path / "hermes_home"
|
|
home.mkdir(exist_ok=True)
|
|
env = dict(os.environ)
|
|
env.update(
|
|
HERMES_HOME=str(home),
|
|
HERMES_SERVE_HEADLESS="1",
|
|
PYTHONUNBUFFERED="1",
|
|
)
|
|
# Ensure a stray desktop-parent env can't arm the watchdog/reaper paths.
|
|
for k in ("HERMES_DESKTOP", "HERMES_PARENT_PID", "HERMES_PARENT_START_MARKER",
|
|
"HERMES_PARENT_NONCE"):
|
|
env.pop(k, None)
|
|
code = (
|
|
"from hermes_cli.web_server import start_server\n"
|
|
f"start_server(host='127.0.0.1', port={port}, open_browser=False, "
|
|
"headless=True)\n"
|
|
)
|
|
return subprocess.Popen(
|
|
[sys.executable, "-c", code],
|
|
cwd=str(REPO_ROOT),
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
# merge_stderr=False: the caller only asserts on stdout. Discard
|
|
# stderr instead of piping it — with the serve-path stdout redirect
|
|
# active ALL server logging lands on stderr, and an unread stderr
|
|
# pipe can fill (~64KB) and block the child before it ever emits
|
|
# the READY sentinel, turning the test into a timeout flake.
|
|
stderr=subprocess.STDOUT if merge_stderr else subprocess.DEVNULL,
|
|
text=True,
|
|
)
|
|
|
|
|
|
def _read_until(proc: subprocess.Popen, token: str, timeout: float = 120.0):
|
|
"""Collect stdout lines until ``token`` appears, process exits, or timeout."""
|
|
lines: list[str] = []
|
|
hit = threading.Event()
|
|
|
|
def _pump():
|
|
assert proc.stdout is not None
|
|
for line in proc.stdout:
|
|
lines.append(line)
|
|
if token in line:
|
|
hit.set()
|
|
return
|
|
|
|
t = threading.Thread(target=_pump, daemon=True)
|
|
t.start()
|
|
hit.wait(timeout)
|
|
return hit.is_set(), lines
|
|
|
|
|
|
def test_conflict_emits_sentinel_and_exit_75(tmp_path):
|
|
"""Reporter's exact repro shape: a real held LISTEN socket on the port."""
|
|
holder = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
holder.bind(("127.0.0.1", 0))
|
|
holder.listen(1)
|
|
port = holder.getsockname()[1]
|
|
try:
|
|
proc = _spawn_serve(port, tmp_path)
|
|
try:
|
|
out, _ = proc.communicate(timeout=180)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
out = ""
|
|
pytest.fail("serve did not exit on a port conflict")
|
|
finally:
|
|
holder.close()
|
|
|
|
assert proc.returncode == 75, f"exit={proc.returncode}\n{out}"
|
|
assert f"BACKEND_PORT_IN_USE port={port}" in out
|
|
assert f"Port {port}" in out # human hint line
|
|
assert "HERMES_BACKEND_READY" not in out # never claimed ready
|
|
# exactly one machine sentinel line
|
|
assert out.count("BACKEND_PORT_IN_USE") == 1
|
|
|
|
|
|
def test_free_port_boots_and_announces_ready(tmp_path):
|
|
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
probe.bind(("127.0.0.1", 0))
|
|
port = probe.getsockname()[1]
|
|
probe.close()
|
|
|
|
proc = _spawn_serve(port, tmp_path)
|
|
try:
|
|
ready, lines = _read_until(proc, "HERMES_BACKEND_READY")
|
|
out = "".join(lines)
|
|
assert ready, f"no READY sentinel; output:\n{out}"
|
|
assert f"HERMES_BACKEND_READY port={port}" in out
|
|
assert "BACKEND_PORT_IN_USE" not in out
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=30)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
|
|
def test_ephemeral_port_zero_unaffected(tmp_path):
|
|
proc = _spawn_serve(0, tmp_path)
|
|
try:
|
|
ready, lines = _read_until(proc, "HERMES_BACKEND_READY")
|
|
out = "".join(lines)
|
|
assert ready, f"no READY sentinel; output:\n{out}"
|
|
# OS-assigned non-zero port announced
|
|
ready_line = next(l for l in lines if "HERMES_BACKEND_READY" in l)
|
|
announced = int(ready_line.strip().rsplit("port=", 1)[1])
|
|
assert announced > 0
|
|
assert "BACKEND_PORT_IN_USE" not in out
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=30)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
|
|
def test_ready_sentinel_arrives_on_stdout_not_stderr(tmp_path):
|
|
"""#96282 — the Desktop spawn watches child.stdout for the READY sentinel.
|
|
|
|
``tui_gateway.server`` (imported on the serve startup path since 6d4e851d8
|
|
for the flush-on-SIGTERM handlers) redirects ``sys.stdout`` to
|
|
``sys.stderr`` at import time. If the sentinel is printed through the
|
|
redirected ``sys.stdout``, it lands on stderr and the desktop times out
|
|
after 90s against a perfectly healthy backend. The sentinel must reach
|
|
the real stdout (fd 1).
|
|
|
|
The other E2E tests here merge stderr into stdout, which is exactly how
|
|
the regression slipped past CI — this test keeps the streams separate.
|
|
"""
|
|
probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
probe.bind(("127.0.0.1", 0))
|
|
port = probe.getsockname()[1]
|
|
probe.close()
|
|
|
|
proc = _spawn_serve(port, tmp_path, merge_stderr=False)
|
|
try:
|
|
# stdout only: the sentinel MUST arrive here (desktop watches this pipe)
|
|
ready, lines = _read_until(proc, "HERMES_BACKEND_READY")
|
|
out = "".join(lines)
|
|
assert ready, (
|
|
f"READY sentinel not on stdout (desktop boot would time out); "
|
|
f"stdout:\n{out}"
|
|
)
|
|
assert f"HERMES_BACKEND_READY port={port}" in out
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=30)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|