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
118 changes: 109 additions & 9 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import asyncio
import concurrent.futures
import dataclasses
import faulthandler
import inspect
import json
import logging
Expand Down Expand Up @@ -3292,6 +3293,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_gateway_started_at: float = 0.0
_shutdown_watchdog_done: Optional["threading.Event"] = None
_platform_lock_takeover_on_start: bool = False
_reconnect_watcher_task: Optional["asyncio.Task"] = None

def __init__(self, config: Optional[GatewayConfig] = None):
global _gateway_runner_ref
Expand Down Expand Up @@ -4041,14 +4043,29 @@ async def _connect_adapter_with_timeout(
timeout = self._platform_connect_timeout_secs()
if timeout <= 0:
return await adapter.connect(is_reconnect=is_reconnect)
# Use the detach-on-timeout pattern instead of plain asyncio.wait_for:
# asyncio.wait_for cancels the overdue task but then waits for it to
# exit. An adapter connect() that catches CancelledError can therefore
# block recovery forever (the watcher never reaches the next retry).
# Keep ownership of the old task through its done callback, but
# release the runner at the deadline (#70344).
task = asyncio.ensure_future(
adapter.connect(is_reconnect=is_reconnect)
)
try:
return await asyncio.wait_for(
adapter.connect(is_reconnect=is_reconnect), timeout=timeout
)
except asyncio.TimeoutError as exc:
raise TimeoutError(
f"{platform.value} connect timed out after {timeout:g}s"
) from exc
done, _pending = await asyncio.wait({task}, timeout=timeout)
except asyncio.CancelledError:
task.cancel()
task.add_done_callback(consume_detached_task_result)
raise
if task in done:
result = await task
return bool(result)
task.cancel()
task.add_done_callback(consume_detached_task_result)
raise TimeoutError(
f"{platform.value} connect timed out after {timeout:g}s"
)

async def _connect_initial_adapter_with_timeout(self, adapter, platform) -> bool:
"""Connect one cold-start adapter with tightly scoped replace intent.
Expand Down Expand Up @@ -4725,6 +4742,10 @@ async def _handle_adapter_fatal_error_impl(self, adapter: BasePlatformAdapter) -
"%s queued for background reconnection",
adapter.platform.value,
)
# Ensure the reconnect watcher is alive — if it died (e.g. from
# exhausting its restart budget), respawn it so queued platforms
# are not permanently stranded (#70344).
self._ensure_reconnect_watcher_running()

if not self.adapters and not self._failed_platforms:
self._exit_reason = adapter.fatal_error_message or "All messaging adapters disconnected"
Expand Down Expand Up @@ -7736,6 +7757,35 @@ async def start(self) -> bool:
Returns True if at least one adapter connected successfully.
"""
logger.info("Starting Hermes Gateway...")
# Enable faulthandler at gateway start so that SIGUSR2 (or an
# internal watchdog) can dump all thread and task stacks to stderr
# for post-mortem diagnosis of event-loop freezes (#70344).
faulthandler.enable()
# Also dump stacks to a rotating file for off-line analysis when
# the gateway is running under a service manager that doesn't
# capture stderr.
# faulthandler.register() and SIGUSR2 are POSIX-only; skip the
# signal-triggered file dump on Windows (faulthandler.enable()
# above still covers fatal-error dumps there).
_sigusr2 = getattr(signal, "SIGUSR2", None)
if _sigusr2 is not None and hasattr(faulthandler, "register"):
try:
_log_dir = getattr(self.config, "log_dir", None) or os.path.join(
os.environ.get("HERMES_HOME", str(Path.home() / ".hermes")),
"logs",
)
_faulthandler_path = os.path.join(_log_dir, "gateway_faulthandler.log")
os.makedirs(_log_dir, exist_ok=True)
_fh = open(_faulthandler_path, "a", encoding="utf-8")
faulthandler.register(
_sigusr2,
file=_fh,
all_threads=True,
chain=True,
)
except Exception:
logger.debug("Could not set up faulthandler file logging", exc_info=True)

try:
self._gateway_loop = asyncio.get_running_loop()
except RuntimeError:
Expand Down Expand Up @@ -8251,7 +8301,7 @@ async def start(self) -> bool:
)

if connected_count == 0:
if startup_nonretryable_errors:
if startup_nonretryable_errors and not startup_retryable_errors:
reason = "; ".join(startup_nonretryable_errors)
logger.error("Gateway hit a non-retryable startup conflict: %s", reason)
try:
Expand All @@ -8263,6 +8313,27 @@ async def start(self) -> bool:
self._request_clean_exit(reason)
self._startup_restore_in_progress = False
return True
if startup_nonretryable_errors:
# Mixed failure mode (NS-609): some platforms are fatally
# misconfigured (e.g. WhatsApp enabled but never paired) while
# others hit merely transient errors (e.g. Telegram TimedOut
# during polling startup). Exiting with
# GATEWAY_FATAL_CONFIG_EXIT_CODE here is wrong in both
# supervision worlds: under supervisors that honor the
# exit-78 contract (systemd RestartPreventExitStatus, s6
# finish→125 since #51228) the gateway goes PERMANENTLY down
# over a network blip; under anything else it crash-loops.
# Either way the retryable platforms never get their retry.
# Log the fatal side loudly, then fall through to the
# degraded/retry path below: the reconnect watcher recovers
# the retryable platforms; the non-retryable ones remain
# fatal-parked and visible in runtime status.
logger.error(
"%d platform(s) fatally misconfigured and parked: %s. "
"Staying alive so retryable platforms can recover.",
len(startup_nonretryable_errors),
"; ".join(startup_nonretryable_errors),
)
if enabled_platform_count > 0:
if startup_retryable_errors:
# All enabled platforms hit retryable failures (network
Expand Down Expand Up @@ -8455,7 +8526,12 @@ async def start(self) -> bool:
len(self._failed_platforms),
", ".join(p.value for p in self._failed_platforms),
)
self._spawn_supervised(self._platform_reconnect_watcher, "platform_reconnect_watcher")
# Track the reconnect watcher task so _ensure_reconnect_watcher_running
# can detect if it dies and respawn it (#70344).
self._reconnect_watcher_task = asyncio.create_task(
self._platform_reconnect_watcher()
)
self._background_tasks.add(self._reconnect_watcher_task)

# Start background handoff watcher — picks up CLI sessions marked
# handoff_state='pending' in state.db and re-binds them to the
Expand Down Expand Up @@ -9011,6 +9087,30 @@ def _active_profile_name(self) -> str:
# self state, so inheriting the mixin keeps every self._kanban_* call site
# working unchanged while lifting ~1,000 LOC out of this file.

def _ensure_reconnect_watcher_running(self) -> None:
"""Ensure the platform reconnect watcher background task is alive.

If the tracked reconnect watcher task has died (e.g. from exhausting
its restart budget, or a terminal exception that _spawn_supervised
could not recover), respawns it so platforms queued for reconnection
are not permanently stranded. Called after queueing a retryable fatal
error in _handle_adapter_fatal_error (#70344).
"""
if not getattr(self, "_running", False):
return
task = getattr(self, "_reconnect_watcher_task", None)
if task is not None and not task.done():
return # already alive
logger.warning(
"Reconnect watcher task is dead (done=%s) — respawning",
task.done() if task is not None else "N/A",
)
self._reconnect_watcher_task = asyncio.create_task(
self._platform_reconnect_watcher()
)
if getattr(self, "_background_tasks", None) is not None:
self._background_tasks.add(self._reconnect_watcher_task)

async def _platform_reconnect_watcher(self) -> None:
"""Background task that periodically retries connecting failed platforms.

Expand Down
Loading
Loading