diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index f17936cc83..b74fe7b64f 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1353,11 +1353,18 @@ def _on_server_started( _best_effort("host rendezvous publish", lambda: _publish_host_rendezvous(host, actual_port)) _write_dashboard_ready_file(actual_port) - # Port-discovery sentinel parsed by the Desktop spawn (matches either - # token). Written to fd 1: tui_gateway.server redirects sys.stdout to - # stderr at import, and the Desktop watches child.stdout (#96282). - ready_token = "HERMES_BACKEND_READY" if headless else "HERMES_DASHBOARD_READY" - _write_machine_sentinel_line(f"{ready_token} port={actual_port}") + # Port-discovery sentinel parsed by the Desktop spawn. Written to fd 1: + # tui_gateway.server redirects sys.stdout to stderr at import, and the + # Desktop watches child.stdout (#96282). A headless `serve` announces the + # neutral token FIRST and the legacy HERMES_DASHBOARD_READY one after it: + # a packaged Desktop artifact whose parser predates the neutral token + # (#60772) still matches the legacy sentinel, while current parsers match + # either. The legacy `dashboard` backend keeps its own single token. + if headless: + _write_machine_sentinel_line(f"HERMES_BACKEND_READY port={actual_port}") + _write_machine_sentinel_line(f"HERMES_DASHBOARD_READY port={actual_port}") + else: + _write_machine_sentinel_line(f"HERMES_DASHBOARD_READY port={actual_port}") if headless: # Auth-gated JSON-RPC/WS only — announce the bind, not a URL. flush: # a piped stdout otherwise surfaces this minutes after the sentinel. diff --git a/tests/hermes_cli/test_serve_port_in_use.py b/tests/hermes_cli/test_serve_port_in_use.py index 59a207c575..4a0382c08e 100644 --- a/tests/hermes_cli/test_serve_port_in_use.py +++ b/tests/hermes_cli/test_serve_port_in_use.py @@ -216,3 +216,46 @@ def test_ready_sentinel_arrives_on_stdout_not_stderr(tmp_path): proc.wait(timeout=30) except subprocess.TimeoutExpired: proc.kill() + + +def test_headless_serve_announces_both_ready_tokens(tmp_path): + """#60772 — a headless ``serve`` must announce BOTH ready tokens. + + The packaged Desktop artifact can be older than the Python backend (the + CLI-only ``hermes update`` path does not rebuild the packaged app). Its + readiness parser may still match only the legacy ``HERMES_DASHBOARD_READY`` + line; a backend that announces only ``HERMES_BACKEND_READY`` then boots + healthily and gets killed after the port-announcement timeout — the exact + artifact-skew signature in #60772. The neutral token must come first so + current parsers match it before the legacy one. + """ + 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: + # Read past the NEUTRAL token to the legacy one: _read_until stops at + # its token, so waiting for the legacy line proves BOTH were written + # (the neutral one precedes it) without a timing race. + ready, lines = _read_until(proc, f"HERMES_DASHBOARD_READY port={port}") + out = "".join(lines) + assert ready, ( + f"legacy token missing (a stale packaged Desktop would time out); output:\n{out}" + ) + # The neutral token must come first: current parsers stop at their + # first hit, and they should bind to the newer contract. + assert f"HERMES_BACKEND_READY port={port}" in out + backend_at = out.index(f"HERMES_BACKEND_READY port={port}") + legacy_at = out.index(f"HERMES_DASHBOARD_READY port={port}") + assert backend_at < legacy_at + # Exactly one of each — never a loop of announcements. + assert out.count("HERMES_BACKEND_READY port=") == 1 + assert out.count("HERMES_DASHBOARD_READY port=") == 1 + finally: + proc.terminate() + try: + proc.wait(timeout=30) + except subprocess.TimeoutExpired: + proc.kill()