tools/terminal_tool.py: drop 30 pure re-export names (lifecycle/config/backends/
sudo/guards/result/interrupt/utils/_DockerEnvironment/is_managed_tool_gateway_ready)
and the noqa-F401 comments on the 25 names the facade itself uses. Sibling modules
(terminal_tool_backends/_result/_sudo/_lifecycle, environments/base, process_registry)
that read removed names through the facade now import from the defining module.
tools/environments/base.py: drop 11 re-exports (base_output/base_session_env/
path_utils) and the BaseEnvironment.stop() compat alias (no in-tree caller; the
lifecycle hasattr(env, 'stop') fallback stays for third-party envs).
tools/environments/docker.py: drop 1 re-export + the re-export comment.
Callers/tests repointed to tools.terminal_tool_{lifecycle,backends,sudo,config,
guards,result}, tools.interrupt, tools.environments.{base_output,base_session_env,
path_utils}.
69 lines
2.6 KiB
Python
69 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():
|
|
from tools import mcp_tool_loop as _mcp_loop
|
|
|
|
_mcp_loop._ensure_mcp_loop()
|
|
yield _mcp_loop
|
|
_mcp_loop._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}
|