Files
hermes-agent/tests/test_web_server.py
ethernet 30da5d0a89 test: run os-specific tests on their real host, not a faked one
many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.

this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.

each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
  dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has

bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.

running on real hosts found real errors: a chrome-sandbox failure in
test_gui_command that main hides, and two windows failures fixed here.
the agents.md testing section now documents the policy.
2026-08-09 22:09:49 -04:00

246 lines
8.9 KiB
Python

"""Test that start_server configures ws-ping keepalive.
The server now uses uvicorn.Server directly (not uvicorn.run) so we stub
Config + Server + asyncio.run to capture kwargs without starting an event loop.
"""
import asyncio
import contextlib
import pytest
import uvicorn
from hermes_cli import web_server
def _stub_uvicorn(monkeypatch):
"""Replace uvicorn.Config/Server with fakes so start_server returns
immediately. Returns a dict with captured Config kwargs."""
captured: dict = {}
class _FakeConfig:
loaded = True
host = "127.0.0.1"
port = 8000
_loop_factory = None
def __init__(self, *args, **kwargs):
captured.update(kwargs)
def load(self):
pass
def get_loop_factory(self):
return self._loop_factory
class lifespan_class:
should_exit = False
state: dict = {}
def __init__(self, *a, **kw):
pass
async def startup(self):
pass
async def shutdown(self):
pass
class _FakeServer:
should_exit = False
started = True
servers: list = []
lifespan = None
@staticmethod
def capture_signals():
return contextlib.nullcontext()
async def startup(self, sockets=None):
pass
async def main_loop(self):
pass
async def shutdown(self, sockets=None):
pass
monkeypatch.setattr(uvicorn, "Config", _FakeConfig)
monkeypatch.setattr(uvicorn, "Server", lambda config: _FakeServer())
return captured
def test_start_server_applies_process_local_ssh_bootstrap_state(monkeypatch):
captured = _stub_uvicorn(monkeypatch)
web_server.start_server(
host="127.0.0.1",
port=0,
open_browser=False,
ssh_session_token="s" * 64,
ssh_owner_nonce="0123456789abcdef",
)
assert web_server._SESSION_TOKEN == "s" * 64
assert web_server._SSH_OWNER_NONCE == "0123456789abcdef"
assert captured["port"] == 0
def test_start_server_disables_ws_ping_on_loopback(monkeypatch):
"""Loopback binds (the Desktop case) MUST disable uvicorn's protocol-level
keepalive ping so an event-loop stall can never trigger a false disconnect.
uvicorn's ws ping runs on the same event loop as agent turns. A single
synchronous GIL-holding call on a worker thread can starve that loop for
minutes, so the loop can't process the pong and uvicorn kills an
otherwise-healthy local connection (#53773 "event loop stalled 226.3s",
#48445/#50005). On loopback there is no network/proxy path where a
half-open connection can occur — a dead local client tears the socket down
with a real FIN/RST that surfaces as WebSocketDisconnect regardless — so
the ping provides no liveness value and only harms. Assert it is disabled.
"""
captured = _stub_uvicorn(monkeypatch)
# Loopback bind => no auth gate, so this reaches the Config constructor.
web_server.start_server(host="127.0.0.1", port=0, open_browser=False)
assert captured["ws_ping_interval"] is None
assert captured["ws_ping_timeout"] is None
def test_start_server_accepts_base64_desktop_attachments_above_preview_limit(monkeypatch):
"""The gateway frame cap must fit the Desktop attachment default after
base64 expansion and JSON framing; uvicorn's 16 MiB default would reject
the request before ``file.attach`` can stage it.
"""
captured = _stub_uvicorn(monkeypatch)
web_server.start_server(host="127.0.0.1", port=0, open_browser=False)
raw_attachment_bytes = 256 * 1024 * 1024
base64_bytes = ((raw_attachment_bytes + 2) // 3) * 4
assert captured["ws_max_size"] > base64_bytes
def test_start_server_enables_ws_ping_for_half_open_detection(monkeypatch):
"""Non-loopback (public) binds MUST keep the ws ping enabled so half-open
connections (reverse-proxy 524, dropped Cloudflare Tunnel) raise
WebSocketDisconnect into the reaping path (#32377).
The invariant asserted here is that ping stays enabled (non-None, positive)
and the timeout is never shorter than the interval — not a frozen literal,
which churns every time the window is retuned. Loopback disables the ping
(see test_start_server_disables_ws_ping_on_loopback); this covers the
public-bind half-open case, so the auth gate is active here.
"""
captured = _stub_uvicorn(monkeypatch)
# Non-loopback bind so the _is_loopback branch selects the enabled-ping
# window. Neutralize the auth gate so start_server reaches uvicorn.Config
# without requiring a registered provider (a real public bind would raise
# SystemExit here). The ping window keys off the host, not the auth flag.
monkeypatch.setattr(web_server, "should_require_auth", lambda *a, **k: False)
web_server.start_server(host="0.0.0.0", port=0, open_browser=False)
assert captured["ws_ping_interval"] and captured["ws_ping_interval"] > 0
assert captured["ws_ping_timeout"] and captured["ws_ping_timeout"] > 0
assert captured["ws_ping_timeout"] >= captured["ws_ping_interval"]
@pytest.mark.windows_only
def test_start_server_runs_on_uvicorns_loop_factory(monkeypatch):
"""The dashboard/desktop backend must serve uvicorn on the loop *uvicorn*
selects, not the interpreter default.
On Windows ``asyncio.run`` defaults to a ProactorEventLoop, but uvicorn's
socket-serving stack forces a SelectorEventLoop on win32
(``uvicorn/loops/asyncio.py``). Serving on the proactor loop binds a socket
that never accepts — the backend prints "Skipping web UI build" and hangs
forever with the port LISTENING but no TCP handshake (#50641). We fix that
by routing the serve call through ``uvicorn._compat.asyncio_run`` with
``config.get_loop_factory()`` — exactly what ``uvicorn.Server.run`` does.
This asserts the behavioral contract: on Windows the loop factory the runner
receives is the one uvicorn's own Config produced, and bare ``asyncio.run``
is never the serve path when the loop-factory runner exists.
Windows-only: faking ``sys.platform`` selected the branch but left the
proactor/selector loop policy this exists for entirely absent.
"""
_stub_uvicorn(monkeypatch)
# The fake Config (installed by _stub_uvicorn) returns its ``_loop_factory``
# from get_loop_factory(). Pin a sentinel so we can assert it is threaded
# through to the runner unchanged.
sentinel_factory = object()
monkeypatch.setattr(uvicorn.Config, "_loop_factory", sentinel_factory, raising=False)
seen: dict = {}
def _fake_runner(coro, *, loop_factory=None):
seen["loop_factory"] = loop_factory
coro.close() # drain without an event loop
monkeypatch.setattr("uvicorn._compat.asyncio_run", _fake_runner, raising=False)
# Bare asyncio.run must NOT be the serve path on Windows when the
# loop-factory runner is importable.
called_bare = {"hit": False}
def _guard_asyncio_run(coro):
called_bare["hit"] = True
coro.close()
return None
monkeypatch.setattr(asyncio, "run", _guard_asyncio_run)
web_server.start_server(host="127.0.0.1", port=0, open_browser=False)
assert seen.get("loop_factory") is sentinel_factory, (
"start_server must pass uvicorn's get_loop_factory() result to the "
"runner so Windows serves on a SelectorEventLoop"
)
assert called_bare["hit"] is False, (
"start_server must not fall back to bare asyncio.run when uvicorn's "
"loop-factory runner is available"
)
def test_start_server_keeps_bare_asyncio_run_on_posix(monkeypatch):
"""POSIX behavior must be byte-for-byte unchanged: serve via the plain
``asyncio.run(_serve())`` path, never the Windows loop-factory branch.
The #50641 fix is intentionally win32-scoped to keep the blast radius
minimal — Python's default loop on POSIX is already a SelectorEventLoop
(or uvloop), which is what uvicorn serves on, so there is nothing to fix.
No platform patching: the Linux CI host is already POSIX, so this asserts
the real host's serve path.
"""
_stub_uvicorn(monkeypatch)
# If the Windows branch were taken, the loop-factory runner would fire.
runner_called = {"hit": False}
def _fake_runner(coro, *, loop_factory=None):
runner_called["hit"] = True
coro.close()
monkeypatch.setattr("uvicorn._compat.asyncio_run", _fake_runner, raising=False)
bare_called = {"hit": False}
def _fake_asyncio_run(coro):
bare_called["hit"] = True
coro.close()
return None
monkeypatch.setattr(asyncio, "run", _fake_asyncio_run)
web_server.start_server(host="127.0.0.1", port=0, open_browser=False)
assert bare_called["hit"] is True, "POSIX must serve via bare asyncio.run"
assert runner_called["hit"] is False, (
"POSIX must not take the Windows loop-factory branch"
)