hermes-agent/tests/tools/test_mcp_poll_loop_oom_integration.py
Brooklyn Nicholson 1cec5c69d3 test(mcp): e2e integration coverage for #63892 poll-loop OOM spin
The salvaged unit tests hand-construct completed futures to lock the
_run_on_mcp_loop contract. Add a live-loop test that reproduces the actual
field trigger: an inner asyncio.wait_for expiry stores a real TimeoutError
on a real future scheduled on the MCP loop. Asserts the fixed loop surfaces
it once, promptly -- not spinning to the outer deadline (which both leaked
memory and masked the real error behind the generic wrapper message).
2026-07-18 18:49:56 -04:00

68 lines
2.6 KiB
Python

"""End-to-end coverage for the MCP poll-loop OOM spin (#63892).
The unit tests in ``test_mcp_tool.py`` hand-construct completed futures to lock
the ``_run_on_mcp_loop`` contract deterministically. This complements them by
exercising the actual field trigger through the *live* MCP loop: an inner
``asyncio.wait_for`` expiry produces a real ``TimeoutError`` stored on a real
future scheduled via ``run_coroutine_threadsafe``. On Python >= 3.8 that class
is the builtin ``TimeoutError``, so before the fix the poll loop swallowed the
completed future's stored exception and spun with no sleep -- burning CPU,
growing the exception traceback ~108 MB/s until the gateway OOM'd, and finally
masking the real error behind the generic "MCP call timed out after <full
timeout>" wrapper.
The fixed loop must surface the real exception once, promptly -- long before
the outer deadline.
"""
from __future__ import annotations
import asyncio
import time
import pytest
@pytest.fixture
def mcp_loop():
import tools.mcp_tool as mcp_tool
mcp_tool._ensure_mcp_loop()
yield mcp_tool
mcp_tool._stop_mcp_loop()
def test_inner_wait_for_timeout_surfaces_promptly_without_spinning(mcp_loop):
async def inner():
# Real TimeoutError from wait_for, completing far before the outer
# deadline below -- the exact shape of an MCP call_tool wrapped in the
# server's configured mcp_servers.<srv>.timeout.
await asyncio.wait_for(asyncio.sleep(60), timeout=0.05)
start = time.monotonic()
with pytest.raises(TimeoutError) as exc:
mcp_loop._run_on_mcp_loop(inner, timeout=10)
elapsed = time.monotonic() - start
# Fixed: the real inner TimeoutError surfaces within a couple poll ticks.
# Broken: the loop swallows it and spins with no sleep until the 10s
# deadline, then raises the generic wrapper message instead. A generous
# (>= 2s) bound keeps this stable on a slow CI runner while still failing
# hard on a spin-to-deadline regression.
assert elapsed < 5.0, (
f"poll loop spun for {elapsed:.1f}s instead of resolving the completed "
"future once (#63892 regression)"
)
assert "MCP call timed out after" not in str(exc.value), (
"the real inner TimeoutError must surface, not the outer poll deadline"
)
def test_successful_call_still_returns_through_real_loop(mcp_loop):
"""Guard the done-future path against regressing normal success returns."""
async def inner():
await asyncio.sleep(0.25) # first polls time out while still pending
return {"ok": True}
assert mcp_loop._run_on_mcp_loop(inner, timeout=10) == {"ok": True}