Files
hermes-agent/tests/gateway/test_telegram_polling_stall_watchdog.py
kshitijk4poor bb42672178 refactor(telegram): a confirmed stall logs one hand-off line, not a retry promise
_schedule_polling_recovery promised the gateway 'stays alive and will retry' for every error, but a _PollingStallError goes straight to _go_fatal_network (supervisor rebuild). Branch the wording on the error type, drop the watchdog's own pre-log so a stall yields exactly one error-level line (from _go_fatal_network), and carry stalled_for/generation in the stall error text instead. Test module docstring and test name updated to match the hand-off semantics.
2026-09-17 21:17:59 +05:30

230 lines
9.7 KiB
Python

"""TelegramAdapter polling-stall watchdog (#92991).
A wedged getUpdates long-poll can be invisible to every other probe: the
TCP connection dies mid-read (CLOSE-WAIT behind a TUN/proxy route flip),
``updater.running`` stays True, ``get_me()`` on the general request path
stays healthy, and — while no messages are queued server-side —
``pending_update_count`` stays 0. The gateway then goes silently deaf and
only a full restart recovers it.
``_check_polling_stall`` closes that hole: Telegram answers a long-poll
within ~50s, so a poller with no successful getUpdates round-trip for
``_POLLING_STALL_TIMEOUT`` seconds is unambiguously wedged, and the check
raises a ``_PollingStallError`` through the recovery path, which skips the
reconnect ladder and hands the adapter to the supervisor for a rebuild
(#113618). ``_polling_heartbeat_loop`` runs the check every probe, so
steady-state wedges are caught without any Bot API call.
"""
import asyncio
import time as _time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import PlatformConfig
from plugins.platforms.telegram.adapter import TelegramAdapter
def _make_adapter(*, stalled_seconds: float) -> TelegramAdapter:
"""Build a polling-mode adapter whose long-poll last succeeded
``stalled_seconds`` ago, with a healthy general request path and an
EMPTY server-side queue (so the pending-count probe stays blind)."""
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
adapter._webhook_mode = False
adapter._app = MagicMock()
adapter._app.updater.running = True
bot = MagicMock()
bot.get_me = AsyncMock()
bot.get_webhook_info = AsyncMock(
return_value=MagicMock(pending_update_count=0)
)
adapter._app.bot = bot
adapter._bot = bot
adapter._polling_generation = 2
now = _time.monotonic()
adapter._polling_generation_started_monotonic = now - 500
adapter._polling_last_progress_monotonic = now - stalled_seconds
return adapter
@pytest.mark.asyncio
async def test_recent_progress_does_not_escalate():
"""A healthy poller (fresh round-trip) must never trip the watchdog."""
adapter = _make_adapter(stalled_seconds=1)
with patch.object(adapter, "_handle_polling_network_error", new=AsyncMock()) as rec:
await adapter._check_polling_stall()
assert adapter._polling_error_task is None
rec.assert_not_called()
@pytest.mark.asyncio
async def test_stalled_long_poll_hands_off_to_supervisor():
"""#92991: with an empty queue and healthy get_me(), only the stall
timestamp can detect the wedged consumer — and it must hand off."""
adapter = _make_adapter(stalled_seconds=400)
recovery = AsyncMock()
with patch.object(adapter, "_handle_polling_network_error", new=recovery):
await adapter._check_polling_stall()
task = adapter._polling_error_task
assert task is not None
await task
recovery.assert_awaited_once()
@pytest.mark.asyncio
@pytest.mark.parametrize("via_verifier", [False, True], ids=["watchdog", "verifier"])
async def test_confirmed_stall_hands_off_before_reusing_updater(monkeypatch, via_verifier):
"""#113618: a confirmed stall (watchdog or post-reconnect verifier) must escalate to the
retryable fatal and mark the adapter degraded instead of restarting the same Updater."""
from plugins.platforms.telegram import adapter as tg_adapter
adapter = _make_adapter(stalled_seconds=0 if via_verifier else 400)
adapter._running = True
adapter._mark_degraded = MagicMock()
updater = adapter._app.updater
updater.stop = AsyncMock(return_value=None)
updater.start_polling = AsyncMock()
adapter._drain_polling_connections = AsyncMock()
adapter._notify_fatal_error = AsyncMock()
error_count_before = adapter._polling_network_error_count
try:
with patch("asyncio.sleep", new=AsyncMock()) as sleep:
if via_verifier:
generation, progress = adapter._begin_polling_generation()
monkeypatch.setattr(tg_adapter, "_POLLING_PROGRESS_TIMEOUT", 0)
await adapter._verify_polling_after_reconnect(generation, progress)
else:
await adapter._check_polling_stall()
task = adapter._polling_error_task
assert task is not None
await task
assert adapter.has_fatal_error
assert adapter.fatal_error_retryable is True
adapter._notify_fatal_error.assert_awaited_once()
adapter._mark_degraded.assert_called_once()
updater.start_polling.assert_not_awaited()
# A confirmed stall is a handoff, not a retry: no backoff, no retry-counter bump,
# and no in-place stop/drain (the supervisor's disconnect() does the bounded stop).
sleep.assert_not_awaited()
assert adapter._polling_network_error_count == error_count_before
updater.stop.assert_not_awaited()
adapter._drain_polling_connections.assert_not_awaited()
finally:
background_tasks = tuple(adapter._background_tasks)
for background_task in background_tasks:
background_task.cancel()
await asyncio.gather(*background_tasks, return_exceptions=True)
@pytest.mark.asyncio
async def test_generic_network_error_reconnects_without_fatal():
"""A generic network error (e.g. Bad Gateway / timeout) must reconnect in-place, not fatal."""
adapter = _make_adapter(stalled_seconds=0)
adapter._running = True
updater = adapter._app.updater
updater.stop = AsyncMock(return_value=None)
updater.start_polling = AsyncMock()
adapter._drain_polling_connections = AsyncMock()
adapter._notify_fatal_error = AsyncMock()
try:
with patch("asyncio.sleep", new=AsyncMock()):
task = asyncio.create_task(
adapter._handle_polling_network_error(Exception("Bad Gateway 502"))
)
adapter._polling_error_task = task
await task
assert not adapter.has_fatal_error
adapter._notify_fatal_error.assert_not_called()
updater.start_polling.assert_awaited_once()
finally:
background_tasks = tuple(adapter._background_tasks)
for background_task in background_tasks:
background_task.cancel()
await asyncio.gather(*background_tasks, return_exceptions=True)
@pytest.mark.asyncio
async def test_generation_with_no_progress_ever_uses_generation_age():
"""A generation that never completes one round-trip still trips the
watchdog once its age passes the stall threshold (verifier fallback)."""
adapter = _make_adapter(stalled_seconds=0)
adapter._polling_last_progress_monotonic = None
recovery = AsyncMock()
with patch.object(adapter, "_handle_polling_network_error", new=recovery):
await adapter._check_polling_stall()
task = adapter._polling_error_task
assert task is not None
await task
recovery.assert_awaited_once()
@pytest.mark.asyncio
async def test_stall_ignored_while_recovery_in_flight():
"""An in-flight reconnect owns recovery; the watchdog must not pile on."""
adapter = _make_adapter(stalled_seconds=400)
inflight = MagicMock()
inflight.done.return_value = False
adapter._polling_error_task = inflight
with patch.object(adapter, "_handle_polling_network_error", new=AsyncMock()) as rec:
await adapter._check_polling_stall()
rec.assert_not_called()
assert adapter._polling_error_task is inflight
@pytest.mark.asyncio
async def test_stall_check_skipped_in_webhook_mode():
"""Webhook mode has no long-poll socket to wedge."""
adapter = _make_adapter(stalled_seconds=400)
adapter._webhook_mode = True
with patch.object(adapter, "_handle_polling_network_error", new=AsyncMock()) as rec:
await adapter._check_polling_stall()
rec.assert_not_called()
assert adapter._polling_error_task is None
@pytest.mark.asyncio
async def test_heartbeat_detects_wedged_long_poll_with_empty_queue():
"""End-to-end (#92991): drive the lifetime heartbeat loop against a
wedged poller with a healthy general path and an empty queue. Before the
stall watchdog, this setup produces total silence — no probe fires and
no recovery is ever scheduled. After it, the first stall observation
escalates through the reconnect ladder."""
adapter = _make_adapter(stalled_seconds=400)
real_sleep = asyncio.sleep
async def fast_sleep(delay, *args, **kwargs):
await real_sleep(0)
with patch("asyncio.sleep", new=fast_sleep):
with patch.object(adapter, "_handle_polling_network_error", new=AsyncMock()) as rec:
loop_task = asyncio.ensure_future(adapter._polling_heartbeat_loop())
try:
# Run probe cycles until the stall watchdog escalates (the
# fix) — bounded so the pre-fix state fails cleanly instead
# of hanging.
for _ in range(50):
await real_sleep(0)
if adapter._polling_error_task is not None:
break
# Let the loop observe the teardown flag and exit on its own.
# Never cancel(): CPython 3.11's wait_for can swallow task
# cancellation while its inner future is already done, which
# leaves the busy loop un-cancellable and the test spinning
# forever.
adapter._polling_teardown_started = True
await asyncio.wait_for(loop_task, 10)
finally:
if not loop_task.done():
loop_task.cancel()
task = adapter._polling_error_task
assert task is not None, (
"heartbeat probes saw a wedged long-poll (no getUpdates progress for "
"400s) but scheduled no recovery"
)
await task
rec.assert_awaited_once()