Files
nousresearch--hermes-agent/tests/gateway/test_telegram_start_polling_timeout.py
wehub-resource-sync b4fbd6fe9f
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:56:03 +08:00

158 lines
6.0 KiB
Python

"""Regression tests for #59614: start_polling() must be time-bounded.
When both the primary Telegram API server and all fallback IPs are unreachable,
``await app.updater.start_polling(...)`` can block forever inside an exhausted
httpx connection pool — it neither returns nor raises. Unbounded, that wedges:
1. the network-error reconnect ladder (stuck inside attempt 1, never advances),
2. the heartbeat loop (sees the recovery task as alive-but-wedged and skips),
3. the fatal-error escalation (never reached).
The fix wraps every ``start_polling()`` await in ``asyncio.wait_for`` with
``_UPDATER_START_TIMEOUT`` so a hung call raises and feeds the existing retry
ladder. These tests patch the timeout down to keep the suite fast.
"""
import asyncio
import sys
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
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"
telegram_mod.error.NetworkError = type("NetworkError", (OSError,), {})
telegram_mod.error.TimedOut = type("TimedOut", (OSError,), {})
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
sys.modules.setdefault(name, telegram_mod)
sys.modules.setdefault("telegram.error", telegram_mod.error)
_ensure_telegram_mock()
from plugins.platforms.telegram import adapter as tg_adapter # noqa: E402
from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402
async def _hang_forever(**kwargs):
await asyncio.sleep(1000)
def _bare_adapter():
a = TelegramAdapter.__new__(TelegramAdapter)
# `name` / `has_fatal_error` are read-only base-class properties; set the
# backing fields they derive from instead.
from gateway.config import Platform
a.platform = Platform.TELEGRAM
a._fatal_error_code = None
a._fatal_error_message = None
a._fatal_error_retryable = True
a._polling_network_error_count = 0
a._polling_conflict_count = 0
a._polling_error_callback_ref = None
a._background_tasks = set()
a._send_path_degraded = False
return a
@pytest.mark.asyncio
async def test_network_ladder_start_polling_hang_does_not_wedge(monkeypatch):
"""A hung start_polling() in _handle_polling_network_error must time out
and advance the ladder instead of blocking forever (#59614 core repro)."""
monkeypatch.setattr(tg_adapter, "_UPDATER_START_TIMEOUT", 0.2)
a = _bare_adapter()
a._polling_network_error_count = 0 # attempt 1 → 5s backoff before start_polling
app = MagicMock()
app.updater = AsyncMock()
app.updater.start_polling = _hang_forever
app.updater.running = False
a._app = app
with patch.object(a, "_drain_polling_connections", new=AsyncMock()), \
patch.object(
tg_adapter.asyncio, "ensure_future",
side_effect=lambda coro: (coro.close(), asyncio.get_event_loop().create_future())[1],
):
# Unbounded, this await hangs past the 30s wait_for and fails the
# test; bounded, the handler waits its 5s backoff, times out the hung
# start_polling() in 0.2s, schedules the chained retry (captured by
# the ensure_future patch), and returns.
await asyncio.wait_for(
a._handle_polling_network_error(Exception("net down")), timeout=30
)
@pytest.mark.asyncio
async def test_bootstrap_start_polling_hang_schedules_recovery(monkeypatch):
"""_start_polling_resilient: a hung bootstrap start_polling() must raise
TimeoutError (an OSError → classified as network error) and schedule
background recovery instead of blocking connect() forever."""
monkeypatch.setattr(tg_adapter, "_UPDATER_START_TIMEOUT", 0.2)
a = _bare_adapter()
app = MagicMock()
app.updater = AsyncMock()
app.updater.start_polling = _hang_forever
a._app = app
scheduled = []
monkeypatch.setattr(
a, "_schedule_polling_recovery",
lambda err, reason: scheduled.append((err, reason)),
raising=False,
)
ok = await asyncio.wait_for(
a._start_polling_resilient(drop_pending_updates=False, error_callback=None),
timeout=10,
)
assert ok is False
assert len(scheduled) == 1
assert isinstance(scheduled[0][0], (TimeoutError, asyncio.TimeoutError))
@pytest.mark.asyncio
async def test_start_polling_success_path_unaffected(monkeypatch):
"""Sanity: a fast start_polling() still returns True through the wrapper."""
monkeypatch.setattr(tg_adapter, "_UPDATER_START_TIMEOUT", 5.0)
a = _bare_adapter()
app = MagicMock()
app.updater = AsyncMock()
app.updater.start_polling = AsyncMock(return_value=None)
a._app = app
ok = await a._start_polling_resilient(drop_pending_updates=False, error_callback=None)
assert ok is True
app.updater.start_polling.assert_awaited_once()
def test_every_start_polling_call_site_is_time_bounded():
"""Bug-class contract: every `updater.start_polling(` await in the adapter
must be wrapped in asyncio.wait_for. A new unbounded call site reintroduces
the #59614 wedge."""
import inspect
import re
src = inspect.getsource(tg_adapter)
# Find each start_polling( call and check an enclosing wait_for within the
# preceding 6 lines (the wrapper always sits directly above).
lines = src.splitlines()
unbounded = []
for i, line in enumerate(lines):
if re.search(r"updater\.start_polling\(", line) and "def " not in line:
window = "\n".join(lines[max(0, i - 6):i + 1])
if "wait_for" not in window:
unbounded.append((i + 1, line.strip()))
assert not unbounded, f"unbounded start_polling() call sites: {unbounded}"