hermes-agent/tests/tools/test_mcp_bridge_single_failure.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
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.
2026-07-29 13:39:40 -07:00

155 lines
6.3 KiB
Python

"""Regression test for #50394.
A single failing stdio MCP server must not churn the whole MCP bridge.
Root cause: a server that fails to connect is never recorded in
``_servers`` (``start()`` raises before the ``_servers[name] = server``
line in ``_discover_and_register_server``). Without a post-failure
cooldown, every subsequent ``register_mcp_servers`` pass (one per agent
worker session) re-spawns the failing server from scratch -- a restart
storm that destabilises the healthy co-located servers. The fix arms a
per-server exponential backoff so a chronically failing server is retried
on a schedule, isolated from the rest of the bridge.
"""
from unittest.mock import patch
import pytest
import tools.mcp_tool as mcp_mod
@pytest.fixture(autouse=True)
def _reset_mcp_state():
"""Snapshot and restore the module-level MCP state around each test."""
snapshot = (
dict(mcp_mod._servers),
set(mcp_mod._server_connecting),
dict(mcp_mod._server_connect_errors),
dict(mcp_mod._server_connect_retry_after),
dict(mcp_mod._server_connect_failures),
)
mcp_mod._servers.clear()
mcp_mod._server_connecting.clear()
mcp_mod._server_connect_errors.clear()
mcp_mod._server_connect_retry_after.clear()
mcp_mod._server_connect_failures.clear()
try:
yield
finally:
(servers, connecting, errs, retry_after, failures) = snapshot
mcp_mod._servers.clear(); mcp_mod._servers.update(servers)
mcp_mod._server_connecting.clear(); mcp_mod._server_connecting.update(connecting)
mcp_mod._server_connect_errors.clear(); mcp_mod._server_connect_errors.update(errs)
mcp_mod._server_connect_retry_after.clear(); mcp_mod._server_connect_retry_after.update(retry_after)
mcp_mod._server_connect_failures.clear(); mcp_mod._server_connect_failures.update(failures)
class TestConnectCooldownHelpers:
def test_failure_arms_exponential_backoff(self):
now = 1000.0
with patch("tools.mcp_tool.time.monotonic", return_value=now):
mcp_mod._record_connect_failure("bad")
d1 = mcp_mod._server_connect_retry_after["bad"]
mcp_mod._record_connect_failure("bad")
d2 = mcp_mod._server_connect_retry_after["bad"]
assert d1 == now + mcp_mod._CONNECT_RETRY_BASE_BACKOFF_SEC
assert d2 == now + mcp_mod._CONNECT_RETRY_BASE_BACKOFF_SEC * 2
assert mcp_mod._server_connect_failures["bad"] == 2
def test_unknown_server_not_in_cooldown(self):
assert mcp_mod._connect_cooldown_active("never-seen") is False
@pytest.mark.skipif(not mcp_mod._MCP_AVAILABLE, reason="mcp SDK not installed")
class TestRegisterMcpServersIsolation:
"""register_mcp_servers must not re-spawn a server still in cooldown."""
def _run_with_mocked_connect(self, attempts):
async def fake_connect(name, config):
attempts.append(name)
if name == "bad":
raise ConnectionError("exec: bad: not found")
server = mcp_mod.MCPServerTask(name)
server._registered_tool_names = []
server._tools = []
return server
return patch("tools.mcp_tool._connect_server", side_effect=fake_connect)
def test_failing_server_skipped_on_second_pass(self):
attempts = []
cfg = {
"good": {"command": "good-cmd"},
"bad": {"command": "bad-cmd"},
}
with self._run_with_mocked_connect(attempts), \
patch("tools.mcp_tool._register_server_tools", return_value=[]), \
patch("tools.mcp_tool._filter_suspicious_mcp_servers", side_effect=lambda x: x):
mcp_mod.register_mcp_servers(cfg)
assert "good" in mcp_mod._servers
assert "bad" not in mcp_mod._servers
assert mcp_mod._connect_cooldown_active("bad") is True
assert "bad" in attempts
attempts.clear()
mcp_mod.register_mcp_servers(cfg)
assert "bad" not in attempts, (
"failing server was re-spawned despite active cooldown -- "
"restart storm not isolated (#50394)"
)
def test_cooldown_expiry_allows_retry(self):
attempts = []
cfg = {"bad": {"command": "bad-cmd"}}
with self._run_with_mocked_connect(attempts), \
patch("tools.mcp_tool._register_server_tools", return_value=[]), \
patch("tools.mcp_tool._filter_suspicious_mcp_servers", side_effect=lambda x: x):
mcp_mod.register_mcp_servers(cfg)
assert mcp_mod._connect_cooldown_active("bad") is True
mcp_mod._server_connect_retry_after["bad"] = mcp_mod.time.monotonic() - 1
attempts.clear()
mcp_mod.register_mcp_servers(cfg)
assert "bad" in attempts, "elapsed cooldown should permit a retry"
class TestShutdownClearsCooldownState:
"""shutdown_mcp_servers must drop cooldown state on EVERY path.
A server that failed to connect is never recorded in ``_servers``, so
the empty-``_servers`` fast path is the most common state in which
stale cooldown entries exist. The original #50394 fix only cleared the
maps inside the async ``_shutdown`` coroutine, which the fast path
(and the loop-not-running path) never executes.
"""
def test_fast_path_clears_cooldown_state(self):
mcp_mod._record_connect_failure("bad")
assert mcp_mod._server_connect_retry_after
assert not mcp_mod._servers # precondition: fast path taken
with patch("tools.mcp_tool._stop_mcp_loop"):
mcp_mod.shutdown_mcp_servers()
assert mcp_mod._server_connect_retry_after == {}
assert mcp_mod._server_connect_failures == {}
def test_loop_not_running_path_clears_cooldown_state(self):
mcp_mod._record_connect_failure("bad")
class _DeadServer:
name = "dead"
async def shutdown(self): # pragma: no cover - never awaited
pass
mcp_mod._servers["dead"] = _DeadServer() # type: ignore[assignment]
# _mcp_loop is None in this test process, so the async _shutdown
# coroutine is never scheduled; only the final sweep can clear.
with patch("tools.mcp_tool._stop_mcp_loop"):
mcp_mod.shutdown_mcp_servers()
assert mcp_mod._server_connect_retry_after == {}
assert mcp_mod._server_connect_failures == {}