178 lines
7.2 KiB
Python
178 lines
7.2 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)
|
|
|
|
|
|
def test_plain_branch_general_pool_has_tight_keepalive(monkeypatch):
|
|
"""No proxy / no fallback IPs → plain branch must also wire tuned limits."""
|
|
instances = _drive_connect(monkeypatch, proxy_url=None)
|
|
assert len(instances) >= 2
|
|
_assert_keepalive_tight(instances)
|
|
|
|
|
|
def test_limits_keepalive_below_ptb_default_is_the_contract():
|
|
"""Document the invariant independent of adapter wiring: the shared
|
|
helper itself must tighten keepalive below httpx's 5.0 default."""
|
|
from gateway.platforms._http_client_limits import platform_httpx_limits
|
|
|
|
limits = platform_httpx_limits()
|
|
assert isinstance(limits, httpx.Limits)
|
|
assert limits.keepalive_expiry is not None and limits.keepalive_expiry < 5.0
|