# 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
184 lines
7.6 KiB
Python
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
|
|
# ---------------------------------------------------------------------------
|