diff --git a/gateway/run.py b/gateway/run.py index 6f1cba96e3..3175ecdeed 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15794,9 +15794,19 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew async def _await_running_then_schedule() -> None: if self._running: - self._schedule_secondary_profile_reconnect( - profile_name, platform, adapter - ) + try: + self._schedule_secondary_profile_reconnect( + profile_name, platform, adapter + ) + except Exception: + # Same GC-time-exception hazard as the post-poll handoff + # below; surface it in gateway.log instead. + logger.exception( + "secondary-startup-reconnect handoff failed " + "(profile=%s platform=%s)", + profile_name, + platform.value, + ) return # Modest poll interval: startup completion has no dedicated event, # and the reconnect runner's own backoff makes sub-100ms precision @@ -15804,9 +15814,21 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew while not self._running and not self._shutdown_event.is_set(): await asyncio.sleep(0.1) if self._running and not self._shutdown_event.is_set(): - self._schedule_secondary_profile_reconnect( - profile_name, platform, adapter - ) + try: + self._schedule_secondary_profile_reconnect( + profile_name, platform, adapter + ) + except Exception: + # The handoff touches live registries; if it raises, the + # parked task would otherwise die as an unretrieved-task + # exception logged only at GC time. Surface it where + # operators look. + logger.exception( + "secondary-startup-reconnect handoff failed " + "(profile=%s platform=%s)", + profile_name, + platform.value, + ) task = asyncio.create_task( _await_running_then_schedule(), diff --git a/tests/gateway/test_multiplex_adapter_registry.py b/tests/gateway/test_multiplex_adapter_registry.py index 8a0799873c..d048d9a0ea 100644 --- a/tests/gateway/test_multiplex_adapter_registry.py +++ b/tests/gateway/test_multiplex_adapter_registry.py @@ -420,6 +420,53 @@ class TestSecondaryStartupFailureRecovery: assert runner._background_tasks == set() assert runner._profile_failed_platforms == {} + @pytest.mark.asyncio + async def test_handoff_failure_is_logged_not_raised(self, monkeypatch, caplog): + """If the scheduler raises at bridge handoff, the parked task must not + die as an unretrieved-task exception — the failure surfaces in the log.""" + runner = _secondary_recovery_runner() + failed = _SecondaryRecoveryAdapter() + _install_secondary_reconnect_context( + monkeypatch, runner, _SecondaryRecoveryAdapter() + ) + monkeypatch.setattr(runner, "_create_adapter", lambda platform, config: failed) + + async def fail_initial_connect(adapter, platform): + return False + + monkeypatch.setattr( + runner, "_connect_initial_adapter_with_timeout", fail_initial_connect + ) + + def explode_at_handoff(profile_name, platform, adapter): + raise RuntimeError("scheduler exploded during handoff") + + monkeypatch.setattr( + runner, "_schedule_secondary_profile_reconnect", explode_at_handoff + ) + + with caplog.at_level(logging.ERROR, logger="gateway.run"): + connected = await runner._start_one_profile_adapters( + "reviewer", "/tmp/reviewer", {} + ) + bridge = list(runner._background_tasks) + assert len(bridge) == 1 + # Awaiting completes cleanly: the guard swallows the handoff + # failure instead of letting it escape as an unretrieved-task + # exception at GC time. + await asyncio.wait_for(bridge[0], timeout=0.5) + + assert connected == 0 + assert failed.disconnected is True + assert any( + record.levelno == logging.ERROR + and "secondary-startup-reconnect handoff failed" in record.getMessage() + for record in caplog.records + ) + # Nothing was scheduled and no slot leaked behind the failed handoff. + assert Platform.DISCORD not in runner._profile_adapters.get("reviewer", {}) + assert runner._profile_failed_platforms == {} + class TestSecondaryProfileConfigHandling: """Secondary config errors degrade only when the profile is safe to skip."""