mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
200 lines
6.6 KiB
Python
200 lines
6.6 KiB
Python
"""Gateway event-loop freeze backstops for issue #69089."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import threading
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from gateway.shutdown_watchdog import (
|
|
_arm_loop_floor_timer,
|
|
start_loop_liveness_watchdog,
|
|
)
|
|
|
|
|
|
def _immediate_loop() -> MagicMock:
|
|
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
|
loop.call_soon_threadsafe.side_effect = lambda callback: callback()
|
|
return loop
|
|
|
|
|
|
def test_loop_liveness_watchdog_stop_during_dump_disarms_hard_exit():
|
|
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
|
handle_ready = threading.Event()
|
|
handle_ref = {}
|
|
exit_codes = []
|
|
|
|
def stop_during_dump(*_args, **_kwargs) -> None:
|
|
assert handle_ready.wait(timeout=2.0)
|
|
handle_ref["handle"].stop()
|
|
|
|
with (
|
|
patch("gateway.shutdown_watchdog.logger.critical") as critical,
|
|
patch(
|
|
"gateway.shutdown_watchdog.faulthandler.dump_traceback",
|
|
side_effect=stop_during_dump,
|
|
) as dump,
|
|
patch("gateway.shutdown_watchdog.os._exit", side_effect=exit_codes.append),
|
|
):
|
|
handle = start_loop_liveness_watchdog(
|
|
loop, probe_interval=0.01, probe_timeout=0.01, max_strikes=1
|
|
)
|
|
assert handle is not None
|
|
handle_ref["handle"] = handle
|
|
handle_ready.set()
|
|
handle.join(timeout=2.0)
|
|
|
|
assert not handle.is_alive()
|
|
critical.assert_called_once()
|
|
dump.assert_called_once_with(all_threads=True)
|
|
assert exit_codes == []
|
|
|
|
|
|
def test_loop_liveness_watchdog_stop_during_final_miss_disarms_hard_exit():
|
|
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
|
probe_scheduled = threading.Event()
|
|
release_probe = threading.Event()
|
|
probe_event_ref = {}
|
|
handle_ref = {}
|
|
exit_codes = []
|
|
|
|
class FinalStrikeLimit:
|
|
def __gt__(self, _strikes: int) -> bool:
|
|
# If strike evaluation is reached, keep recheck #2 from masking a
|
|
# missing post-probe recheck #1 in this boundary test.
|
|
handle_ref["handle"]._stop_event.clear()
|
|
return False
|
|
|
|
def hold_scheduled_probe(callback) -> None:
|
|
probe_event_ref["event"] = callback.__self__
|
|
probe_scheduled.set()
|
|
assert release_probe.wait(timeout=2.0)
|
|
|
|
loop.call_soon_threadsafe.side_effect = hold_scheduled_probe
|
|
with (
|
|
patch("gateway.shutdown_watchdog.logger.critical") as critical,
|
|
patch("gateway.shutdown_watchdog.faulthandler.dump_traceback") as dump,
|
|
patch("gateway.shutdown_watchdog.os._exit", side_effect=exit_codes.append),
|
|
):
|
|
handle = start_loop_liveness_watchdog(
|
|
loop,
|
|
probe_interval=0.01,
|
|
probe_timeout=0.01,
|
|
max_strikes=FinalStrikeLimit(),
|
|
)
|
|
assert handle is not None
|
|
handle_ref["handle"] = handle
|
|
assert probe_scheduled.wait(timeout=2.0), "watchdog did not schedule a probe"
|
|
|
|
def stop_during_miss() -> bool:
|
|
handle.stop()
|
|
return False
|
|
|
|
probe_event_ref["event"].is_set = stop_during_miss
|
|
release_probe.set()
|
|
handle.join(timeout=1.0)
|
|
|
|
assert not handle.is_alive()
|
|
assert exit_codes == []
|
|
critical.assert_not_called()
|
|
dump.assert_not_called()
|
|
|
|
|
|
def test_loop_liveness_watchdog_stop_after_first_recheck_skips_final_actions():
|
|
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
|
probe_scheduled = threading.Event()
|
|
release_probe = threading.Event()
|
|
|
|
def hold_scheduled_probe(callback) -> None:
|
|
probe_scheduled.set()
|
|
assert release_probe.wait(timeout=2.0)
|
|
|
|
loop.call_soon_threadsafe.side_effect = hold_scheduled_probe
|
|
with (
|
|
patch("gateway.shutdown_watchdog.logger.critical") as critical,
|
|
patch("gateway.shutdown_watchdog.faulthandler.dump_traceback") as dump,
|
|
patch("gateway.shutdown_watchdog.os._exit") as hard_exit,
|
|
):
|
|
handle = start_loop_liveness_watchdog(
|
|
loop, probe_interval=0.01, probe_timeout=0.01, max_strikes=1
|
|
)
|
|
assert handle is not None
|
|
assert probe_scheduled.wait(timeout=2.0), "watchdog did not schedule a probe"
|
|
|
|
original_is_set = handle._stop_event.is_set
|
|
is_set_calls = 0
|
|
|
|
def stop_on_final_recheck() -> bool:
|
|
nonlocal is_set_calls
|
|
is_set_calls += 1
|
|
# With the forced immediate timeout: _wait_for_probe is call 1,
|
|
# recheck #1 is call 2, and recheck #2 is call 3.
|
|
if is_set_calls == 3:
|
|
handle.stop()
|
|
return original_is_set()
|
|
|
|
handle._stop_event.is_set = stop_on_final_recheck
|
|
with patch(
|
|
"gateway.shutdown_watchdog.time.monotonic", side_effect=[0.0, 1.0]
|
|
):
|
|
release_probe.set()
|
|
handle.join(timeout=1.0)
|
|
|
|
assert is_set_calls == 3
|
|
assert not handle.is_alive()
|
|
critical.assert_not_called()
|
|
dump.assert_not_called()
|
|
hard_exit.assert_not_called()
|
|
|
|
|
|
def test_gateway_config_loop_watchdog_round_trip():
|
|
"""loop_watchdog is a config.yaml knob: default on, nested-gateway form honored."""
|
|
from gateway.config import GatewayConfig
|
|
|
|
assert GatewayConfig.from_dict({}).loop_watchdog is True
|
|
assert GatewayConfig.from_dict({"loop_watchdog": False}).loop_watchdog is False
|
|
assert (
|
|
GatewayConfig.from_dict(
|
|
{"gateway": {"loop_watchdog": "off"}}
|
|
).loop_watchdog
|
|
is False
|
|
)
|
|
config = GatewayConfig.from_dict({"loop_watchdog": False})
|
|
assert config.to_dict()["loop_watchdog"] is False
|
|
|
|
|
|
def test_gateway_runner_liveness_guards_start_and_stop():
|
|
from gateway.run import GatewayRunner
|
|
|
|
runner = object.__new__(GatewayRunner)
|
|
runner._loop_floor_timer_handle = None
|
|
runner._loop_liveness_watchdog = None
|
|
loop = MagicMock(spec=asyncio.AbstractEventLoop)
|
|
floor_timer = MagicMock()
|
|
watchdog = MagicMock()
|
|
watchdog.is_alive.return_value = True
|
|
|
|
with (
|
|
patch(
|
|
"gateway.run._arm_loop_floor_timer", return_value=floor_timer
|
|
) as arm_floor,
|
|
patch(
|
|
"gateway.run.start_loop_liveness_watchdog", return_value=watchdog
|
|
) as start_watchdog,
|
|
):
|
|
runner._start_loop_liveness_guards(loop)
|
|
|
|
arm_floor.assert_called_once_with(loop)
|
|
start_watchdog.assert_called_once_with(loop)
|
|
assert runner._loop_floor_timer_handle is floor_timer
|
|
assert runner._loop_liveness_watchdog is watchdog
|
|
|
|
runner._stop_loop_liveness_guards()
|
|
|
|
watchdog.stop.assert_called_once_with()
|
|
floor_timer.cancel.assert_called_once_with()
|
|
assert runner._loop_liveness_watchdog is None
|
|
assert runner._loop_floor_timer_handle is None
|