hermes-agent/tests/gateway/test_telegram_closewait_limits_31599.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

162 lines
6.4 KiB
Python

"""Regression test for #31599 — Telegram general-pool CLOSE_WAIT fd leak.
Background
----------
PTB's ``telegram.request.HTTPXRequest`` builds the underlying
``httpx.AsyncClient`` with ``limits = httpx.Limits(max_connections=...)``
and *no* keepalive tuning, so httpx's default ``keepalive_expiry=5.0``
applies. Behind an HTTP proxy (Cloudflare Warp etc.) a peer-initiated
FIN can sit in ``CLOSE_WAIT`` longer than that, leaking fds in the
general request pool (``_request[1]`` — the pool that routes
``bot.send_message`` / ``set_my_commands``), which
``_drain_polling_connections`` never resets.
The fix wires the shared ``gateway.platforms._http_client_limits``
``platform_httpx_limits()`` helper into *every* HTTPXRequest the adapter
builds — the fallback-transport branch, the proxy branch, and the plain
branch — so idle keepalive sockets drain aggressively.
Contract asserted here (mutation-survivable)
---------------------------------------------
Every ``HTTPXRequest`` constructed by ``TelegramAdapter.connect()`` must
receive ``httpx_kwargs["limits"]`` that is an ``httpx.Limits`` with a
``keepalive_expiry`` strictly below httpx's 5.0 default and a positive,
bounded ``max_keepalive_connections``. Reverting the limits wiring (so
HTTPXRequest falls back to PTB's default 5.0s keepalive) fails this test.
"""
import asyncio
import sys
from unittest.mock import MagicMock, patch
import httpx
import pytest
from gateway.config import PlatformConfig
def _ensure_telegram_mock():
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
return
telegram_mod = MagicMock()
telegram_mod.ext.ContextTypes.DEFAULT_TYPE = type(None)
telegram_mod.constants.ParseMode.MARKDOWN_V2 = "MarkdownV2"
telegram_mod.constants.ChatType.GROUP = "group"
telegram_mod.constants.ChatType.SUPERGROUP = "supergroup"
telegram_mod.constants.ChatType.CHANNEL = "channel"
telegram_mod.constants.ChatType.PRIVATE = "private"
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
sys.modules.setdefault(name, telegram_mod)
_ensure_telegram_mock()
from plugins.platforms.telegram import adapter as tg_adapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
class _StopConnect(Exception):
"""Sentinel raised to abort connect() once requests are built."""
class _RecordingHTTPXRequest:
"""Stand-in for PTB's HTTPXRequest that records constructor kwargs."""
instances: list = []
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
_RecordingHTTPXRequest.instances.append(self)
def _make_adapter() -> TelegramAdapter:
return TelegramAdapter(PlatformConfig(enabled=True, token="test-token"))
def _drive_connect(monkeypatch, *, proxy_url):
"""Run connect() far enough to build the HTTPXRequests, then abort.
Returns the list of recorded _RecordingHTTPXRequest instances.
"""
_RecordingHTTPXRequest.instances = []
# No DoH auto-discovery → exercise the proxy / plain branches, not fallback.
async def _no_fallback():
return []
monkeypatch.setattr(tg_adapter, "discover_fallback_ips", _no_fallback)
monkeypatch.setattr(
tg_adapter, "resolve_proxy_url", lambda *a, **k: proxy_url
)
# Replace the real HTTPXRequest with our recorder.
monkeypatch.setattr(tg_adapter, "HTTPXRequest", _RecordingHTTPXRequest)
adapter = _make_adapter()
# Skip the cross-process token lock.
monkeypatch.setattr(adapter, "_acquire_platform_lock", lambda *a, **k: True)
# Ensure the adapter reports no statically-configured fallback IPs.
monkeypatch.setattr(adapter, "_fallback_ips", lambda: [])
# builder.request(...).get_updates_request(...).build() must be harmless;
# make build() raise our sentinel so connect() stops right after the
# HTTPXRequests are constructed (before any real network/init).
fake_built_app = MagicMock()
fake_built_app.initialize = MagicMock(side_effect=_StopConnect)
chainable = MagicMock()
chainable.token.return_value = chainable
chainable.base_url.return_value = chainable
chainable.base_file_url.return_value = chainable
chainable.local_mode.return_value = chainable
chainable.request.return_value = chainable
chainable.get_updates_request.return_value = chainable
chainable.build.side_effect = _StopConnect
builder_root = MagicMock()
builder_root.builder.return_value = chainable
monkeypatch.setattr(tg_adapter, "Application", builder_root)
try:
asyncio.run(adapter.connect())
except _StopConnect:
pass
except Exception:
# connect() wraps work in a try; if it swallows the sentinel and
# continues to real init, the recorded instances are still valid.
pass
return list(_RecordingHTTPXRequest.instances)
def _assert_keepalive_tight(instances):
assert instances, "connect() built no HTTPXRequest — test setup is wrong"
for inst in instances:
limits = inst.kwargs.get("httpx_kwargs", {}).get("limits")
assert isinstance(limits, httpx.Limits), (
"HTTPXRequest must receive httpx_kwargs['limits'] = httpx.Limits "
"wired from platform_httpx_limits() (#31599). Missing → PTB falls "
"back to default keepalive_expiry=5.0 and leaks CLOSE_WAIT fds."
)
# The whole point: keepalive must be tighter than httpx's 5.0 default.
assert limits.keepalive_expiry is not None
assert limits.keepalive_expiry < 5.0, (
"keepalive_expiry must be < httpx default 5.0 so idle/CLOSE_WAIT "
"sockets drain promptly behind a proxy (#31599)."
)
assert limits.max_keepalive_connections is not None
assert 1 <= limits.max_keepalive_connections <= 50
# PTB's connection_pool_size (max_connections) must be preserved.
assert limits.max_connections is not None and limits.max_connections > 0
def test_proxy_branch_general_pool_has_tight_keepalive(monkeypatch):
"""The proxy path the #31599 reporter hit must wire tuned limits."""
instances = _drive_connect(monkeypatch, proxy_url="http://127.0.0.1:9/")
# Both the general request pool and the get_updates pool are built here.
assert len(instances) >= 2
_assert_keepalive_tight(instances)
# Sanity: the proxy was actually threaded through (we're on the proxy branch).
assert any(inst.kwargs.get("proxy") == "http://127.0.0.1:9/" for inst in instances)