mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
fix(telegram): add cause-agnostic wedged-recovery watchdog so the reconnect ladder can't freeze silently (#66377)
The Telegram gateway could go silently deaf for hours: the reconnect ladder stalled mid-way (e.g. "attempt 4/10, reconnecting in 40s" then nothing) while the process stayed active(running), so Restart=always never fired. Root class: every recovery path — the ladder's re-entry (_schedule_polling_recovery), the pending-update probe (_probe_pending_updates), and PTB's error callback — gates new recovery on _polling_error_task.done(). If that single task wedges on any hung await, all recovery returns early forever and nothing retries. The heartbeat loop is a separate task, so make it an independent, cause-agnostic watchdog: if the same recovery task stays in-flight past _POLLING_ERROR_TASK_STUCK_TIMEOUT (300s — well beyond a healthy ladder attempt's bounded stop+drain+start+backoff), force a retryable-fatal so the background reconnector rebuilds the adapter instead of relying on the frozen ladder. This guarantees progress regardless of *where* the stall is (issue direction #1), tracked locally so no task-assignment site needs to change. Also salvages @koduri-mahesh-bhushan-chowdary's #66492 (drain-await timeout), which closes the one concrete wedge vector documented in the incident (_drain_polling_connections' unbounded shutdown()/initialize() on a wedged CLOSE-WAIT pool). The watchdog covers the rest of the class. Co-authored-by: Koduri Mahesh Bhushan Chowdary <mkoduri73@gmail.com>
This commit is contained in:
parent
3391e639f6
commit
c2cb37532c
2 changed files with 107 additions and 0 deletions
|
|
@ -17,6 +17,7 @@ import os
|
|||
import html as _html
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from contextvars import ContextVar
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Set, Any
|
||||
|
|
@ -551,6 +552,16 @@ _UPDATER_START_TIMEOUT = 30.0
|
|||
# so the ladder always advances toward the fatal-restart escalation. Matches
|
||||
# _UPDATER_STOP_TIMEOUT. Refs: NousResearch/hermes-agent#66377
|
||||
_DRAIN_TIMEOUT = 15.0
|
||||
# Cause-agnostic wedged-recovery watchdog (#66377). Every recovery path (the
|
||||
# reconnect ladder's re-entry, the pending-update probe, PTB's error callback)
|
||||
# gates new recovery on ``_polling_error_task.done()``; if that task ever wedges
|
||||
# on a hung await that no local bound covers, the whole gateway goes silently
|
||||
# deaf with nothing retrying. The heartbeat loop force-escalates a recovery task
|
||||
# that stays in-flight far longer than any healthy ladder attempt could take —
|
||||
# stop (_UPDATER_STOP_TIMEOUT) + drain (2x_DRAIN_TIMEOUT) + start
|
||||
# (_UPDATER_START_TIMEOUT) + max backoff (60s) is ~135s, so 300s is
|
||||
# unambiguously stuck.
|
||||
_POLLING_ERROR_TASK_STUCK_TIMEOUT = 300.0
|
||||
# A generation is not healthy until the dedicated getUpdates request returns
|
||||
# successfully. This exceeds a normal long-poll cycle for healthy idle bots.
|
||||
_POLLING_PROGRESS_TIMEOUT = 60.0
|
||||
|
|
@ -2464,6 +2475,16 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
HEARTBEAT_INTERVAL = 90 # seconds between probes
|
||||
PROBE_TIMEOUT = 15 # seconds before declaring the path dead
|
||||
|
||||
# Wedged-recovery watchdog state (#66377). Tracked locally so no
|
||||
# _polling_error_task assignment site needs to stamp a timestamp: the
|
||||
# heartbeat notes when it first observes a given recovery task still
|
||||
# in-flight, and force-escalates if the *same* task object is still
|
||||
# running after _POLLING_ERROR_TASK_STUCK_TIMEOUT. A healthy ladder
|
||||
# attempt completes (task done) or chains to a new task well before
|
||||
# then, so a single long-lived task is unambiguously wedged.
|
||||
stuck_task_ref: Optional[asyncio.Task] = None
|
||||
stuck_task_since = 0.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
||||
|
|
@ -2471,6 +2492,42 @@ class TelegramAdapter(BasePlatformAdapter):
|
|||
return
|
||||
if self.has_fatal_error:
|
||||
return
|
||||
|
||||
# Independent wedged-recovery watchdog (#66377): if the tracked
|
||||
# recovery task has hung (any await no local bound covers), every
|
||||
# other recovery path is gated behind it and returns early
|
||||
# forever — the gateway stays alive but deaf. Force a
|
||||
# retryable-fatal so the background reconnector rebuilds the
|
||||
# adapter instead of relying on the frozen ladder.
|
||||
recovery_task = self._polling_error_task
|
||||
if recovery_task is not None and not recovery_task.done():
|
||||
now = time.monotonic()
|
||||
if recovery_task is not stuck_task_ref:
|
||||
stuck_task_ref = recovery_task
|
||||
stuck_task_since = now
|
||||
elif now - stuck_task_since > _POLLING_ERROR_TASK_STUCK_TIMEOUT:
|
||||
stuck_for = now - stuck_task_since
|
||||
logger.error(
|
||||
"[%s] Telegram reconnect task wedged for %.0fs with no "
|
||||
"ladder progress; forcing retryable-fatal so the gateway "
|
||||
"reconnects instead of staying silently deaf.",
|
||||
self.name, stuck_for,
|
||||
)
|
||||
try:
|
||||
recovery_task.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
self._set_fatal_error(
|
||||
"telegram_network_error",
|
||||
"Telegram reconnect task wedged for %.0fs; forcing "
|
||||
"gateway reconnect." % stuck_for,
|
||||
retryable=True,
|
||||
)
|
||||
await self._notify_fatal_error()
|
||||
return
|
||||
else:
|
||||
stuck_task_ref = None
|
||||
|
||||
bot = self._app.bot if self._app else None
|
||||
if bot is None:
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -373,6 +373,56 @@ async def test_reconnect_continues_if_drain_hangs(monkeypatch):
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_force_escalates_wedged_recovery_task(monkeypatch):
|
||||
"""#66377: the heartbeat is an independent, cause-agnostic watchdog.
|
||||
|
||||
Every recovery path (ladder re-entry, pending-update probe, PTB error
|
||||
callback) gates new recovery on ``_polling_error_task.done()``. If that task
|
||||
wedges on ANY hung await — not just the drain closed by #66492 — the gateway
|
||||
stays alive but deaf with nothing retrying. The heartbeat must detect a
|
||||
recovery task that stays in-flight past ``_POLLING_ERROR_TASK_STUCK_TIMEOUT``
|
||||
and force a retryable-fatal so the background reconnector rebuilds the
|
||||
adapter.
|
||||
"""
|
||||
adapter = _make_adapter()
|
||||
|
||||
async def _wedged():
|
||||
await asyncio.Event().wait() # never completes — simulates the hang
|
||||
|
||||
wedged_task = asyncio.ensure_future(_wedged())
|
||||
adapter._polling_error_task = wedged_task
|
||||
|
||||
mock_bot = MagicMock()
|
||||
mock_bot.get_me = AsyncMock()
|
||||
mock_app = MagicMock()
|
||||
mock_app.bot = mock_bot
|
||||
adapter._app = mock_app
|
||||
adapter._probe_pending_updates = AsyncMock()
|
||||
adapter._notify_fatal_error = AsyncMock()
|
||||
|
||||
# Controllable monotonic clock advanced by each (mocked) heartbeat sleep so
|
||||
# the same wedged task is observed across the stuck threshold deterministically.
|
||||
clock = [1000.0]
|
||||
|
||||
async def _fake_sleep(*_a, **_k):
|
||||
clock[0] += 200.0
|
||||
|
||||
monkeypatch.setattr(tg_adapter.time, "monotonic", lambda: clock[0])
|
||||
|
||||
with patch("asyncio.sleep", new=AsyncMock(side_effect=_fake_sleep)):
|
||||
await asyncio.wait_for(adapter._polling_heartbeat_loop(), timeout=5)
|
||||
|
||||
assert adapter.has_fatal_error, "wedged recovery task must force a fatal escalation"
|
||||
adapter._notify_fatal_error.assert_awaited()
|
||||
|
||||
wedged_task.cancel()
|
||||
try:
|
||||
await wedged_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conflict_retry_also_drains_polling_connections():
|
||||
"""_handle_polling_conflict must also drain the polling pool on retry."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue