Files
aiturk-hermes-ide/tests/tui_gateway/test_94895_backend_heartbeat.py
T

172 lines
6.2 KiB
Python

"""Tests for #94895: gateway-side heartbeat refresher wiring.
The startup orphan sweep now consults ``gateway_heartbeats`` to tell
"row owned by another live backend" from "row truly orphaned". Each
serve / TUI-gateway process registers a heartbeat row at startup and
refreshes it periodically. This file verifies:
* The refresher registers a row on first call and is once-per-process.
* ``entry.main`` and ``ws.handle_ws`` both call it (mirroring the
existing orphan-sweep wiring pattern).
* Failure to start the refresher is swallowed so a malformed DB
never breaks gateway startup.
* ``HERMES_GATEWAY_HEARTBEAT_REFRESH_S=0`` disables the refresher.
"""
from __future__ import annotations
import io
import os
import pytest
from hermes_state import SessionDB
IDLE_S = 6 * 3600
@pytest.fixture
def db(tmp_path):
return SessionDB(tmp_path / "state.db")
class TestBackendHeartbeatRefresher:
def test_first_call_registers_row_in_db(self, db, monkeypatch, tmp_path):
from tui_gateway import server
monkeypatch.setattr(server, "_get_db", lambda: db)
monkeypatch.setattr(server, "_HEARTBEAT_REFRESH_S", 0.0) # never re-enter
# Reset the once-per-process guard so the test runs cleanly.
monkeypatch.setattr(server, "_heartbeat_refresher_started", False)
# Use a stable backend id for assertion.
monkeypatch.setattr(
server, "_backend_id_for_this_process",
lambda: "test-backend-A",
)
server._start_backend_heartbeat_refresher()
rows = db.list_backend_heartbeats()
assert len(rows) == 1
assert rows[0]["backend_id"] == "test-backend-A"
assert rows[0]["last_heartbeat"] > 0
def test_repeat_calls_are_noops(self, db, monkeypatch):
from tui_gateway import server
monkeypatch.setattr(server, "_get_db", lambda: db)
monkeypatch.setattr(server, "_HEARTBEAT_REFRESH_S", 0.0)
monkeypatch.setattr(server, "_heartbeat_refresher_started", False)
monkeypatch.setattr(
server, "_backend_id_for_this_process",
lambda: "test-backend-A",
)
server._start_backend_heartbeat_refresher()
server._start_backend_heartbeat_refresher()
server._start_backend_heartbeat_refresher()
rows = db.list_backend_heartbeats()
assert len(rows) == 1
def test_zero_refresh_disables_refresher(self, db, monkeypatch):
from tui_gateway import server
monkeypatch.setattr(server, "_get_db", lambda: db)
monkeypatch.setattr(server, "_HEARTBEAT_REFRESH_S", 0.0)
monkeypatch.setattr(server, "_heartbeat_refresher_started", False)
monkeypatch.setattr(
server, "_backend_id_for_this_process",
lambda: "test-backend-A",
)
server._start_backend_heartbeat_refresher()
# Should have written the initial row exactly once, no thread started.
# (The default flow with _HEARTBEAT_REFRESH_S > 0 would spawn a
# thread; we asserted the helper is a no-op for repeat calls above.)
def test_refresher_failure_does_not_raise(self, monkeypatch):
"""A malformed DB / write error must never break startup."""
from tui_gateway import server
def _boom():
raise RuntimeError("disk full")
monkeypatch.setattr(server, "_refresh_backend_heartbeat", _boom)
monkeypatch.setattr(server, "_HEARTBEAT_REFRESH_S", 0.0)
monkeypatch.setattr(server, "_heartbeat_refresher_started", False)
# Must not raise.
server._start_backend_heartbeat_refresher()
def test_backend_id_is_stable_for_this_process(self):
"""backend_id is per-process and includes a nonce for PID-reuse safety."""
from tui_gateway import server
a = server._backend_id_for_this_process()
b = server._backend_id_for_this_process()
assert a == b # idempotent within a process
assert ":nonce" not in a # we use a hex suffix
assert str(os.getpid()) in a
class TestEntryAndWsWiring:
"""The orphan-sweep wiring pattern (entry.main + handle_ws) extends
to the heartbeat refresher: both sites must call the start helper."""
def test_entry_main_starts_heartbeat_refresher(self, monkeypatch):
from tui_gateway import entry, server
started = {"n": 0}
def _start():
started["n"] += 1
monkeypatch.setattr(server, "_start_backend_heartbeat_refresher", _start)
monkeypatch.setattr(entry, "_install_sidecar_publisher", lambda: None)
monkeypatch.setattr(entry, "ensure_mcp_discovery_started", lambda: None)
monkeypatch.setattr(entry, "resolve_skin", lambda: "default")
monkeypatch.setattr(entry.server, "_ensure_skin_watcher", lambda: None)
monkeypatch.setattr(entry, "_log_exit", lambda reason: None)
monkeypatch.setattr(entry, "handle_spurious_eof", lambda *a: False)
monkeypatch.setattr(entry, "write_json", lambda _payload: True)
monkeypatch.setattr(entry.sys, "stdin", io.StringIO(""))
import hermes_cli.model_switch as ms
monkeypatch.setattr(ms, "prewarm_picker_cache_async", lambda: None)
entry.main()
assert started["n"] == 1
def test_handle_ws_starts_heartbeat_refresher(self, monkeypatch):
import asyncio
from tui_gateway import server, ws as ws_mod
started = {"n": 0}
monkeypatch.setattr(
server, "_start_backend_heartbeat_refresher",
lambda: started.__setitem__("n", started["n"] + 1),
)
monkeypatch.setattr(server, "resolve_skin", lambda: "default")
monkeypatch.setattr(server, "_ensure_skin_watcher", lambda: None)
monkeypatch.setattr(server, "register_live_transport", lambda *_a, **_k: None)
monkeypatch.setattr(server, "_WS_ORPHAN_REAP_GRACE_S", 0)
class FakeWS:
async def accept(self):
pass
async def send_text(self, line):
pass
async def receive_text(self):
raise ws_mod._WebSocketDisconnect()
async def close(self):
pass
asyncio.run(ws_mod.handle_ws(FakeWS()))
assert started["n"] == 1