8d95ab1b37
- _write_machine_sentinel_line: wrap the print() fallback so a closed redirected stream (ValueError, not OSError) can't propagate out of the ready path and kill a healthy serve; document that pythonw port discovery relies on the HERMES_DESKTOP_READY_FILE channel, not stdout - regression test: stderr=DEVNULL instead of PIPE — with the stdout redirect active all server logging lands on stderr, and an unread stderr pipe can fill and block the child before the sentinel, flaking the test at the 120s timeout
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()
|