Files
hermes-agent/tests/hermes_cli/test_web_server_boot_handshake.py
ethernet 16652eea18 Merge remote-tracking branch 'origin/main' into ethie/pm-clean
# Conflicts:
#	gateway/config.py
#	gateway/config_loader.py
#	gateway/readiness.py
#	hermes_cli/managed_scope.py
#	hermes_cli/plugin_python_deps.py
#	hermes_cli/plugins_cmd.py
#	hermes_cli/update_cmd_maint.py
#	plugin-catalog/hindsight.yaml
#	plugins/plugin_loader.py
#	providers/__init__.py
#	scripts/run_tests.sh
#	tests/gateway/test_control_socket_windows_live.py
#	tests/gateway/test_gateway_streaming_nested_config.py
#	tests/hermes_cli/test_doctor.py
#	tests/hermes_cli/test_plan_reconciliation_windows_live.py
#	tests/hermes_cli/test_update_apply_shallow_count.py
#	tests/hermes_cli/test_update_concurrent_quarantine.py
#	tests/hermes_cli/test_update_shim_self_lock.py
#	tests/hermes_cli/test_verify_console_scripts.py
#	tests/tools/test_lazy_deps.py
#	tests/tui_gateway/test_subprocess_encoding.py
#	tools/lazy_deps.py
2026-09-23 15:26:34 -04:00

184 lines
7.6 KiB
Python

"""
Integration tests for the desktop boot handshake fix (PR #50231 / issue #50209).
Simulates a slow hermes_cli.gateway import (15-30 s on a fresh Windows install
with Defender scanning every new .pyc) by patching the two helpers that touch
the blocking import and measuring response latency.
Covered: the gateway warmup import completes before the lifespan yields
(#73083/#73291), backend startup is not blocked by hosted-room recovery, shutdown joins the
state.db reconcile worker, and /api/status runs its slow drain-timeout resolution off
the event loop so a concurrent fast endpoint (/api/version) still responds.
"""
from __future__ import annotations
import asyncio
import time
import threading
from unittest.mock import patch
import hermes_cli.web_server as web_server_mod
import hermes_cli.web_server_lifecycle as _web_server_lifecycle
SLOW_SECONDS = 1 # represents the Defender worst-case (scaled down for CI speed)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_slow_drain(seconds: float):
"""Return a _resolve_restart_drain_timeout replacement that sleeps in thread."""
def _slow():
time.sleep(seconds)
return 180.0
return _slow
def test_lifespan_warmup_is_synchronous(monkeypatch):
"""_warm_gateway_module must finish on the event-loop thread before the
lifespan yields (#73083/#73291). On Windows + Python 3.11 the heavy import
holds the GIL, so moving it to run_in_executor / a background task froze
the loop after the socket opened and the Desktop's ready-probe timed out.
Running it before the yield means no request is served until it is done."""
from fastapi.testclient import TestClient
warm: dict[str, object] = {}
def _record_warm():
try:
asyncio.get_running_loop()
warm["on_loop_thread"] = True
except RuntimeError:
warm["on_loop_thread"] = False
warm["done"] = True
monkeypatch.setattr(web_server_mod, "_warm_gateway_module", _record_warm)
with TestClient(web_server_mod.app, raise_server_exceptions=False):
assert warm.get("done") is True, "startup completed before the gateway warmup ran"
assert warm.get("on_loop_thread") is True, (
"gateway warmup was moved off the lifespan (executor/background) — "
"the socket now accepts probes while the GIL-holding import runs"
)
def test_hosted_room_recovery_cannot_block_or_abort_backend_startup(monkeypatch):
from fastapi.testclient import TestClient
from tui_gateway import methods_groups
started = threading.Event()
release = threading.Event()
def blocked_failure():
started.set()
release.wait(timeout=2.0)
raise RuntimeError("state.db is locked")
monkeypatch.setattr(web_server_mod, "_warm_gateway_module", lambda: None)
monkeypatch.setattr(methods_groups, "stop_hosted_room_service", lambda **_kwargs: True)
# Warm lifespan imports first so the bound below measures the recovery hook, not cold imports.
monkeypatch.setattr(methods_groups, "start_hosted_room_service", lambda *a, **k: None)
with TestClient(web_server_mod.app, raise_server_exceptions=False):
pass
monkeypatch.setattr(methods_groups, "start_hosted_room_service", blocked_failure)
before = time.perf_counter()
with TestClient(web_server_mod.app, raise_server_exceptions=False):
assert started.wait(timeout=1.0)
assert time.perf_counter() - before < 1.0
release.set()
def test_lifespan_shutdown_joins_statedb_reconcile_worker(monkeypatch):
"""The eager state.db reconcile runs off the startup path but never outlives
the lifespan: shutdown joins it, so its sqlite connection is only ever closed
by the thread stepping it (a daemon copy left running had its connection
closed cross-thread by teardown and segfaulted the interpreter)."""
from fastapi.testclient import TestClient
started = threading.Event()
finished = threading.Event()
def slow_reconcile():
started.set()
time.sleep(SLOW_SECONDS)
finished.set()
monkeypatch.setattr(web_server_mod, "_warm_gateway_module", lambda: None)
monkeypatch.setattr(web_server_mod, "_eager_reconcile_own_session_db", slow_reconcile)
before = time.perf_counter()
with TestClient(web_server_mod.app, raise_server_exceptions=False):
assert started.wait(timeout=1.0)
# Off the startup path: the socket is up long before the worker is done.
assert time.perf_counter() - before < SLOW_SECONDS * 0.8
assert finished.is_set(), "lifespan shutdown returned before the reconcile worker finished"
assert not any(t.name == "statedb-eager-reconcile" for t in threading.enumerate())
# ---------------------------------------------------------------------------
# Test 2 — get_status run_in_executor keeps event loop free for other requests
# ---------------------------------------------------------------------------
def test_get_status_does_not_block_event_loop():
"""
/api/status calls _resolve_restart_drain_timeout via run_in_executor.
While that slow call is running in a thread, a concurrent fast request
(/api/version) must still get a response — proving the event loop stayed
free during the import.
"""
import httpx
results: dict[str, float] = {}
errors: list[str] = []
async def _run():
transport = httpx.ASGITransport(app=web_server_mod.app)
async with httpx.AsyncClient(
transport=transport, base_url="http://test"
) as client:
# Fire both requests concurrently
async with asyncio.TaskGroup() as tg:
async def _status():
t = time.perf_counter()
r = await client.get("/api/status", timeout=SLOW_SECONDS + 5)
results["status_ms"] = (time.perf_counter() - t) * 1000
results["status_code"] = r.status_code
async def _version():
# Small delay so /api/status starts first
await asyncio.sleep(0.1)
t = time.perf_counter()
r = await client.get("/api/version", timeout=5)
results["version_ms"] = (time.perf_counter() - t) * 1000
results["version_code"] = r.status_code
tg.create_task(_status())
tg.create_task(_version())
with patch.object(
_web_server_lifecycle, "_resolve_restart_drain_timeout", _make_slow_drain(SLOW_SECONDS)
):
asyncio.run(_run())
# /api/version must have responded well before /api/status finished
assert "version_ms" in results, "Fast endpoint never responded"
assert "status_ms" in results, "/api/status never responded"
version_ms = results["version_ms"]
status_ms = results["status_ms"]
# /api/version should respond in < SLOW_SECONDS (event loop free)
assert version_ms < SLOW_SECONDS * 1000, (
f"/api/version took {version_ms:.0f} ms — event loop was blocked by "
f"/api/status (which waited {status_ms:.0f} ms for the slow import)."
)
# /api/status itself eventually returns 200
assert results.get("status_code") == 200, (
f"/api/status returned {results.get('status_code')} instead of 200"
)
# ---------------------------------------------------------------------------
# Test 3 — no orphan accumulation: concurrent probes all receive 200
# ---------------------------------------------------------------------------