Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contributors/emails/mannnrachman@users.noreply.github.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
mannnrachman
10 changes: 8 additions & 2 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@
_AGENT_CACHE_MAX_SIZE = 128
_AGENT_CACHE_IDLE_TTL_SECS = 3600.0 # evict agents idle for >1h
_PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT = 30.0
# Telegram cold polling now proves one real getUpdates round trip before connect
# returns. Leave enough outer budget for initialize/deleteWebhook/start_polling
# wall deadlines plus readiness; other platforms retain the 30s isolation bound.
_TELEGRAM_CONNECT_TIMEOUT_SECS_DEFAULT = 180.0
_ADAPTER_DISCONNECT_TIMEOUT_SECS_DEFAULT = 5.0
_GATEWAY_PROXY_SSE_BUFFER_MAX_CHARS = 16 * 1024 * 1024
_TELEGRAM_COMMAND_MENTION_RE = re.compile(r"(?<![\w:/])/([A-Za-z0-9][A-Za-z0-9_-]*)")
Expand Down Expand Up @@ -4026,7 +4030,7 @@ def _adapter_disconnect_timeout_secs(self) -> float:
return max(0.0, timeout)
return _ADAPTER_DISCONNECT_TIMEOUT_SECS_DEFAULT

def _platform_connect_timeout_secs(self) -> float:
def _platform_connect_timeout_secs(self, platform=None) -> float:
"""Return the per-platform connect timeout used during startup/retry."""
raw = os.getenv("HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT", "").strip()
if raw:
Expand All @@ -4039,6 +4043,8 @@ def _platform_connect_timeout_secs(self) -> float:
)
else:
return max(0.0, timeout)
if platform == Platform.TELEGRAM:
return _TELEGRAM_CONNECT_TIMEOUT_SECS_DEFAULT
return _PLATFORM_CONNECT_TIMEOUT_SECS_DEFAULT

async def _connect_adapter_with_timeout(
Expand All @@ -4052,7 +4058,7 @@ async def _connect_adapter_with_timeout(
(preserve the queue so messages sent during the outage are delivered
rather than silently dropped — #46621).
"""
timeout = self._platform_connect_timeout_secs()
timeout = self._platform_connect_timeout_secs(platform)
if timeout <= 0:
return await adapter.connect(is_reconnect=is_reconnect)
# Use the detach-on-timeout pattern instead of plain asyncio.wait_for:
Expand Down
170 changes: 152 additions & 18 deletions plugins/platforms/telegram/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,16 @@ def _watchdog_check() -> None:
loop_processed_expiry.set()


async def _first_completed(*futures: "asyncio.Future") -> None:
"""Return when the first of ``futures`` completes.

Used by the strict cold-start readiness gate to wait on "progress OR
polling error", whichever fires first (#67498). Does not cancel the
losers — the caller owns their lifecycle.
"""
await asyncio.wait(set(futures), return_when=asyncio.FIRST_COMPLETED)


async def _run_abandon_cleanup(on_abandon) -> None:
"""Run the abandonment cleanup coroutine, swallowing any failure.

Expand Down Expand Up @@ -545,6 +555,10 @@ def _rich_normalize_linebreaks(text: str) -> str:
# reconnect ladder from stalling indefinitely and allows the heartbeat loop to
# trigger its own recovery path. Refs: NousResearch/hermes-agent#59614
_UPDATER_START_TIMEOUT = 30.0
# Initial connect is not healthy until the dedicated getUpdates request completes
# one successful round trip. Unlike reconnect, initial bootstrap must fail closed
# so GatewayRunner disposes the partial adapter and retries with a fresh PTB app.
_INITIAL_POLLING_PROGRESS_TIMEOUT = 60.0
# shutdown()/initialize() on the getUpdates httpx request close and rebuild the
# connection pool. When a connection is wedged on a stale CLOSE-WAIT socket that
# close can block forever, hanging _drain_polling_connections() and freezing the
Expand Down Expand Up @@ -2129,8 +2143,17 @@ async def _start_polling_once(
*,
drop_pending_updates: bool,
error_callback,
) -> None:
"""Start one generation and verify real getUpdates progress."""
abandon_app_on_timeout: bool = False,
schedule_verifier: bool = True,
) -> tuple[int, asyncio.Event]:
"""Start one generation and verify real getUpdates progress.

Returns the ``(generation, progress_event)`` pair created for this
polling generation so callers that must gate on readiness (strict
cold start, #67498) can bind to exactly this generation instead of
re-reading ``self._polling_progress_event`` — which a concurrent
recovery task may have replaced with a newer generation's event.
"""
if getattr(self, "_polling_teardown_started", False):
raise _PollingLifecycleAbort("Telegram polling teardown started")
generation, progress = self._begin_polling_generation()
Expand All @@ -2151,21 +2174,32 @@ def _generation_error_callback(error: Exception) -> None:

context_token = _POLLING_GENERATION_CONTEXT.set(generation)
try:
await asyncio.wait_for(
# asyncio.wait_for can wait forever for cancellation to escape
# httpcore/AnyIO shielded scopes (#58236/#67498). Reuse the
# proven wall-deadline helper and abandon the partial updater;
# caller recovery will dispose/rebuild the whole adapter.
await _await_with_thread_deadline(
app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=drop_pending_updates,
error_callback=_generation_error_callback,
),
timeout=_UPDATER_START_TIMEOUT,
on_abandon=(
(lambda app=app: _shutdown_abandoned_app(app))
if abandon_app_on_timeout
else None
),
)
finally:
_POLLING_GENERATION_CONTEXT.reset(context_token)
if getattr(self, "_polling_teardown_started", False):
self._polling_progress_accepting = False
self._send_path_degraded = True
raise _PollingLifecycleAbort("Telegram polling teardown started")
self._schedule_polling_progress_verifier(generation, progress)
if schedule_verifier:
self._schedule_polling_progress_verifier(generation, progress)
return generation, progress

def _schedule_polling_progress_verifier(
self, generation: int, progress: asyncio.Event
Expand Down Expand Up @@ -2264,23 +2298,34 @@ def _schedule_polling_recovery(self, error: Exception, *, reason: str) -> None:
self._background_tasks.add(self._polling_error_task)
self._polling_error_task.add_done_callback(self._background_tasks.discard)

async def _delete_webhook_best_effort(self) -> bool:
"""Clear any stale webhook, but never fail polling on a network error.
async def _delete_webhook_best_effort(
self, *, require_success: bool = False
) -> bool:
"""Clear stale webhook, optionally failing closed on initial connect.

Returns True when the webhook was cleared (or there was nothing to do)
and False when a transient network error was swallowed so bootstrap can
continue to polling; the reconnect ladder recovers from there.
Reconnect can recover a transient error in background. Cold startup uses
``require_success`` so GatewayRunner disposes the partial adapter and
retries with a fresh PTB Application instead of publishing degraded state.
"""
if not self._bot:
return False
delete_webhook = getattr(self._bot, "delete_webhook", None)
if not callable(delete_webhook):
return True
try:
await delete_webhook(drop_pending_updates=False)
# Same shielded-cancellation class as initialize/start_polling:
# never let a wedged duplicate deleteWebhook pin initial connect.
await _await_with_thread_deadline(
delete_webhook(drop_pending_updates=False),
timeout=_UPDATER_START_TIMEOUT,
)
return True
except Exception as err:
if self._looks_like_network_error(err):
if require_success:
raise OSError(
"Telegram deleteWebhook did not complete during initial connect"
) from err
logger.warning(
"[%s] deleteWebhook failed with a recoverable network error; "
"continuing to polling so getUpdates/retry can recover: %s",
Expand All @@ -2290,35 +2335,121 @@ async def _delete_webhook_best_effort(self) -> bool:
return False
raise

async def _start_polling_resilient(self, *, drop_pending_updates: bool, error_callback) -> bool:
"""Start PTB polling; on a transient bootstrap failure, recover in background.
async def _start_polling_resilient(
self,
*,
drop_pending_updates: bool,
error_callback,
require_progress: bool = False,
) -> bool:
"""Start PTB polling and optionally require real getUpdates readiness.

Returns True when polling started, False when a transient conflict or
network error was scheduled for background recovery instead of raising
(keeping the gateway process alive).
Reconnects may recover in background. Initial connect sets
``require_progress`` so a bootstrap failure or missing first successful
getUpdates response raises; GatewayRunner then disposes this partial
adapter and retries with a fresh PTB Application.
"""
if getattr(self, "_polling_teardown_started", False):
return False
if not (self._app and self._app.updater):
raise RuntimeError("Telegram application/updater not initialized")

# Strict cold start (#67498): background recovery must not run while
# the readiness gate is waiting. A G1 polling error would otherwise
# schedule _handle_polling_network_error(), which starts generation
# G2 on the same partial application while this coroutine still waits
# on G1's event — the cold connect then either times out on G1 despite
# G2 succeeding, or G2 "heals" the partial app so GatewayRunner never
# disposes it and retries fresh. Instead, capture the first polling
# error and fail the cold attempt immediately; GatewayRunner owns
# disposal and retry with a fresh adapter.
strict_error: list[BaseException] = []
strict_error_event = asyncio.Event()
strict_gate_open = True
effective_callback = error_callback
if require_progress:
loop = asyncio.get_running_loop()

def _strict_error_callback(error: Exception) -> None:
# PTB registers this callback for the whole polling
# generation. After the readiness gate closes (success),
# delegate to the real callback so ongoing polling errors
# keep flowing into background recovery.
if not strict_gate_open:
if error_callback is not None:
error_callback(error)
return
if not strict_error:
strict_error.append(error)
# PTB invokes error callbacks from the polling task; the
# event must be set on the loop to wake the strict waiter.
loop.call_soon_threadsafe(strict_error_event.set)

effective_callback = _strict_error_callback
try:
# Same watchdog bound as the reconnect ladders: a wedged httpx
# connection pool can hang start_polling() forever at bootstrap
# too (#59614). A propagating TimeoutError is a builtins
# TimeoutError (OSError subclass), so the except below classifies
# it via _looks_like_network_error and schedules background
# recovery instead of blocking connect() indefinitely.
await self._start_polling_once(
generation, progress = await self._start_polling_once(
self._app,
drop_pending_updates=drop_pending_updates,
error_callback=error_callback,
error_callback=effective_callback,
abandon_app_on_timeout=require_progress,
# The strict gate below IS the cold-start verifier; the
# background verifier would only race it on the partial app.
schedule_verifier=not require_progress,
)
if require_progress:
# Bind to THIS generation's progress event (returned above),
# not self._polling_progress_event — a concurrent task could
# have replaced it with a later generation's event.
progress_wait = asyncio.ensure_future(progress.wait())
error_wait = asyncio.ensure_future(strict_error_event.wait())
try:
await _await_with_thread_deadline(
_first_completed(progress_wait, error_wait),
timeout=_INITIAL_POLLING_PROGRESS_TIMEOUT,
)
except asyncio.TimeoutError as exc:
raise OSError(
"Telegram getUpdates made no progress within "
f"{_INITIAL_POLLING_PROGRESS_TIMEOUT:.0f}s during initial "
"connect — failing startup so the gateway retries with a "
"fresh adapter (#67498)"
) from exc
finally:
for fut in (progress_wait, error_wait):
if not fut.done():
fut.cancel()
await asyncio.gather(
progress_wait, error_wait, return_exceptions=True
)
if strict_error and not progress.is_set():
raise OSError(
"Telegram polling errored before first getUpdates "
"success during initial connect: "
f"{_redact_telegram_error_text(strict_error[0])}"
) from strict_error[0]
if not progress.is_set():
raise OSError(
"Telegram getUpdates did not become ready during initial connect"
)
# Readiness proven — close the strict gate so any later
# polling error flows to the real background-recovery
# callback instead of the (now finished) cold-start gate.
strict_gate_open = False
self._polling_error_callback_ref = error_callback
return True
except _PollingLifecycleAbort:
return False
except Exception as err:
if getattr(self, "_polling_teardown_started", False):
return False
if require_progress:
raise
if self._looks_like_polling_conflict(err):
logger.warning(
"[%s] Telegram polling bootstrap conflict; gateway stays alive "
Expand Down Expand Up @@ -3815,7 +3946,9 @@ def _with_limits(httpx_kwargs: Optional[dict] = None) -> dict:
# updates. Best-effort: a transient Bot API network error here
# must not fail gateway startup — degrade to background polling
# recovery instead.
await self._delete_webhook_best_effort()
await self._delete_webhook_best_effort(
require_success=not is_reconnect
)

loop = asyncio.get_running_loop()

Expand Down Expand Up @@ -3853,6 +3986,7 @@ def _polling_error_callback(error: Exception) -> None:
# sent while the bot was offline are delivered (#46621).
drop_pending_updates=not is_reconnect,
error_callback=_polling_error_callback,
require_progress=not is_reconnect,
)
if not polling_started:
logger.warning(
Expand Down
19 changes: 19 additions & 0 deletions tests/gateway/test_platform_reconnect.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,25 @@ def fake_create_task(coro):
assert Platform.TELEGRAM not in runner.adapters
assert runner._create_adapter.call_count == 2

def test_default_connect_timeout_allows_telegram_polling_readiness(
self, monkeypatch
):
"""Telegram gets a larger default; other platforms stay isolated at 30s."""
runner = _make_runner()
monkeypatch.delenv("HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT", raising=False)

assert runner._platform_connect_timeout_secs(Platform.TELEGRAM) == 180
assert runner._platform_connect_timeout_secs(Platform.FEISHU) == 30

def test_explicit_connect_timeout_still_applies_to_every_platform(
self, monkeypatch
):
runner = _make_runner()
monkeypatch.setenv("HERMES_GATEWAY_PLATFORM_CONNECT_TIMEOUT", "90")

assert runner._platform_connect_timeout_secs(Platform.TELEGRAM) == 90
assert runner._platform_connect_timeout_secs(Platform.FEISHU) == 90

@pytest.mark.asyncio
async def test_connect_adapter_timeout_raises_retryable_exception(self, monkeypatch):
"""The timeout helper turns a hanging connect into a caught startup error."""
Expand Down
Loading
Loading