Skip to content
Closed
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
196 changes: 181 additions & 15 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -8339,7 +8339,27 @@ def _queue_retryable_fatal_platform(self, adapter: BasePlatformAdapter) -> bool:
if not adapter.fatal_error_retryable:
return False
platform_config = self.config.platforms.get(adapter.platform)
if not platform_config or adapter.platform in self._failed_platforms:
if not platform_config:
return False
if adapter.platform in self._failed_platforms:
# Nothing to enqueue -- but "already queued" is precisely the state
# in which the watcher has had time to die, and the enqueue branch
# below holds the ONLY call to _ensure_reconnect_watcher_running().
#
# _spawn_supervised auto-restarts the watcher after a crash (#71758),
# but only _MAX_SUPERVISED_RESTARTS times in rapid succession; past
# that it logs "giving up restarts" and the watcher stays dead
# forever. _ensure_reconnect_watcher_running is the documented
# backstop for exactly that budget exhaustion (#70344) -- and it was
# unreachable for a platform already in the queue, which is the only
# kind of platform the watcher can have been retrying long enough to
# exhaust it on.
#
# The result is a silent permanent outage: nothing retries, and the
# stranded check in _handle_adapter_fatal_error_detached deliberately
# treats a queued platform as safe, so the process never restarts
# either (#90386).
self._ensure_reconnect_watcher_running()
return False
self._failed_platforms[adapter.platform] = {
"config": platform_config,
Expand Down Expand Up @@ -13303,11 +13323,7 @@ async def _connect_one_startup(p, p_cfg, adp):
# live task even when _spawn_supervised's own backoff respawns it β€” so
# _ensure_reconnect_watcher_running never mistakes a superseded handle
# for a dead watcher and spawns a duplicate.
self._reconnect_watcher_task = self._spawn_supervised(
self._platform_reconnect_watcher,
"platform_reconnect_watcher",
on_spawn=lambda t: setattr(self, "_reconnect_watcher_task", t),
)
self._spawn_reconnect_watcher()

# 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 @@ -13372,7 +13388,22 @@ async def _connect_one_startup(p, p_cfg, adp):
# handoff for the rest of the process life).
_SUPERVISED_HEALTHY_SECS = 300

def _spawn_supervised(self, coro_factory, name, *, restart=True, _attempt=0, on_spawn=None):
@staticmethod
def _supervised_backoff(attempt: int) -> float:
"""Delay before the supervisor's next respawn, in seconds.

Capped exponential. A method rather than an inline expression so the
schedule has one name, and so a test can collapse it -- the ordering
of crash / give-up / slow-tier is what the exhaustion tests assert,
and sleeping through the real curve to observe it would make them
take minutes.
"""
return min(60, 2 ** min(attempt, 6))

def _spawn_supervised(
self, coro_factory, name, *, restart=True, _attempt=0, on_spawn=None,
on_give_up=None,
):
"""Launch a long-lived background task with task-level supervision.

Complements upstream's per-iteration inner-loop try/except (which only
Expand All @@ -13395,6 +13426,17 @@ def _spawn_supervised(self, coro_factory, name, *, restart=True, _attempt=0, on_
supervisor's own respawn creates a new task without updating that
external handle, so ``_ensure_...`` later sees the stale/done handle
and spawns a SECOND concurrent watcher (double reconnect attempts).

``on_give_up`` (optional) is invoked with ``name`` when supervision is
abandoned β€” the restart budget is spent and this task will never be
respawned by the supervisor again. Supervision being finite is correct;
having no owner of the invariant afterwards is not. A task that still
has queued work depending on it needs somewhere to hand that fact to,
and before this hook existed the only thing standing between budget
exhaustion and a permanent silent outage was a *later, unrelated
event* happening to call ``_ensure_...`` (#90386). This is the
supervisor telling its caller "I am done; the invariant is yours now",
which is a thing only the supervisor knows.
"""
if getattr(self, "_background_tasks", None) is None:
self._background_tasks = set()
Expand Down Expand Up @@ -13453,8 +13495,16 @@ def _done(t):
effective_attempt,
self._SUPERVISED_HEALTHY_SECS,
)
if on_give_up is not None:
try:
on_give_up(name)
except Exception: # pragma: no cover - defensive
logger.debug(
"on_give_up callback for %s raised",
name, exc_info=True,
)
return
backoff = min(60, 2 ** min(effective_attempt, 6))
backoff = self._supervised_backoff(effective_attempt)

async def _respawn():
await asyncio.sleep(backoff)
Expand All @@ -13465,6 +13515,12 @@ async def _respawn():
restart=restart,
_attempt=effective_attempt + 1,
on_spawn=on_spawn,
# Must be threaded through the recursion for the
# same reason on_spawn is: the give-up that
# matters is the LAST respawn's, and a callback
# dropped here would leave the exhaustion branch
# with no owner at exactly the moment it needs one.
on_give_up=on_give_up,
)

respawn_task = asyncio.create_task(_respawn())
Expand Down Expand Up @@ -14184,14 +14240,128 @@ 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.

#: Interval of the slow respawn tier that takes over once the reconnect
#: watcher has exhausted its supervised restart budget. Long on purpose:
#: the budget is spent precisely when the watcher is crashing on contact,
#: so the useful cadence is "check back later", not "try again now". A
#: tight loop here would be worse than the outage it is healing.
_RECONNECT_WATCHER_SLOW_RETRY_SECS = 300

#: How many slow-tier respawns to attempt while work is still queued.
#: Bounded, not infinite: if half an hour of five-minute retries cannot
#: keep a watcher alive, the fault is not transient and a louder failure
#: is more useful than a quieter one that never stops.
_MAX_SLOW_WATCHER_RESPAWNS = 6

def _on_reconnect_watcher_gave_up(self, name: str = "") -> None:
"""Own the reconnect invariant once supervision has abandoned it.

The invariant this closes: **while the gateway is running and
``_failed_platforms`` is non-empty, either a reconnect watcher is live
or a bounded respawn is scheduled.**

Before this, the only thing that noticed a dead watcher was a *later
fatal error from some other platform* reaching
``_queue_retryable_fatal_platform``. That is event-coupled recovery: it
needs an event that, by construction, may never come. #81036 moved
queue publication ahead of disconnect and drops the failed adapter from
the live map, so once the watcher's budget is spent there may be no
adapter left that can emit the event recovery was waiting on. The
platform stays queued, nothing retries it, and the stranded check in
``_handle_adapter_fatal_error_detached`` treats a queued platform as
safe β€” so the process is never restarted either.

Deliberately NOT done here: requesting a supervisor/process restart
when the slow tier is also exhausted. That is a policy decision about
blast radius (a gateway serving healthy platforms would be taken down
to heal a sick one) and it belongs to a maintainer, not to this patch.
What happens instead is a single loud error naming the still-queued
platforms, which is the state an operator or an external supervisor can
act on.
"""
if not getattr(self, "_running", False):
return
if not getattr(self, "_failed_platforms", None):
# No queued work depends on the watcher. Letting it stay dead is
# correct -- the enqueue path spawns a fresh one the moment a
# platform is queued again.
logger.warning(
"Reconnect watcher supervision exhausted with an empty retry "
"queue β€” leaving it down until a platform is queued."
)
return
self._schedule_slow_reconnect_watcher_respawn(attempt=0)

def _schedule_slow_reconnect_watcher_respawn(self, *, attempt: int) -> None:
"""Bounded slow-tier respawn of the reconnect watcher."""
if attempt >= self._MAX_SLOW_WATCHER_RESPAWNS:
logger.error(
"Reconnect watcher could not be kept alive after %d slow "
"respawns; %d platform(s) remain queued and unattended: %s. "
"Manual intervention or a gateway restart is required.",
attempt,
len(self._failed_platforms),
", ".join(str(p) for p in self._failed_platforms),
)
return

async def _slow_respawn() -> None:
await asyncio.sleep(self._RECONNECT_WATCHER_SLOW_RETRY_SECS)
if not getattr(self, "_running", False):
return
if not getattr(self, "_failed_platforms", None):
# The queue drained while we waited -- something else healed
# it. Nothing to own any more.
return
task = getattr(self, "_reconnect_watcher_task", None)
if task is not None and not task.done():
return # a watcher came back on its own; stand down
logger.warning(
"Reconnect watcher still down with %d platform(s) queued β€” "
"slow respawn %d/%d",
len(self._failed_platforms),
attempt + 1,
self._MAX_SLOW_WATCHER_RESPAWNS,
)
self._spawn_reconnect_watcher(
on_give_up=lambda _name: self._schedule_slow_reconnect_watcher_respawn(
attempt=attempt + 1
)
)

respawn_task = asyncio.create_task(_slow_respawn())
if getattr(self, "_background_tasks", None) is None:
self._background_tasks = set()
self._background_tasks.add(respawn_task)
respawn_task.add_done_callback(self._background_tasks.discard)

def _spawn_reconnect_watcher(self, *, on_give_up=None):
"""Single place that knows how to launch the reconnect watcher.

Three call sites used to repeat this triple (factory, name, on_spawn),
and the ``on_spawn`` half of it is load-bearing: without it the
supervisor's own respawn leaves ``_reconnect_watcher_task`` pointing at
a dead handle and ``_ensure_...`` spawns a second concurrent watcher.
"""
self._reconnect_watcher_task = self._spawn_supervised(
self._platform_reconnect_watcher,
"platform_reconnect_watcher",
on_spawn=lambda t: setattr(self, "_reconnect_watcher_task", t),
on_give_up=on_give_up or self._on_reconnect_watcher_gave_up,
)
return self._reconnect_watcher_task

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).
are not permanently stranded. Called from
_queue_retryable_fatal_platform on BOTH paths (#70344, #90386): after a
new enqueue, and after a re-fatal for a platform that is already queued
-- the latter being the only case in which the watcher can have been
retrying long enough to exhaust its supervised restart budget.
"""
if not getattr(self, "_running", False):
return
Expand All @@ -14202,11 +14372,7 @@ def _ensure_reconnect_watcher_running(self) -> None:
"Reconnect watcher task is dead (done=%s) β€” respawning",
task.done() if task is not None else "N/A",
)
self._reconnect_watcher_task = self._spawn_supervised(
self._platform_reconnect_watcher,
"platform_reconnect_watcher",
on_spawn=lambda t: setattr(self, "_reconnect_watcher_task", t),
)
self._spawn_reconnect_watcher()

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