{"repo":"NousResearch/hermes-agent","public_id":"3173","display_label":"Telegram polling reconnect hang and wedge fixes","summary":"These issues and PRs all concern the Telegram polling adapter freezing, hanging, or silently dying during reconnect attempts, particularly due to CLOSE-WAIT sockets, wedged updaters, and unbounded awaits in the reconnect ladder. The work spans detection, timeouts, heartbeats, and drain logic to prevent the reconnect process from stalling indefinitely.","subsystem":"gateway","counts":{"members":16,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":5,"merged_prs":5,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":3268},"resolution":{"state":"resolved","by":"pr-3268,pr-18751,pr-56200,pr-58342,pr-67241"},"freshness":{"state":"verified","last_verified_at":"2026-07-19T01:48:20+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.10966666666666663},"fix_edges":[{"issue_number":3173,"pr_number":3268,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":18086,"pr_number":18751,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":55992,"pr_number":56200,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":58270,"pr_number":58342,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":66377,"pr_number":67241,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":3173,"title":"Gateway crashes on Telegram Bad Gateway (502) — reconnect loop fails","url":null,"state":"closed"},{"kind":"issue","number":18086,"title":"Telegram Updater goes silent forever after a single network blip; reconnect ladder swallows the cleanup TimedOut and never re-fires","url":null,"state":"closed"},{"kind":"issue","number":55992,"title":"Telegram polling silently dies after network error + self-restart","url":null,"state":"closed"},{"kind":"issue","number":58270,"title":"fix(telegram): updater.stop() in reconnect ladder hangs on CLOSE-WAIT sockets — heartbeat loop stuck for hours","url":null,"state":"closed"},{"kind":"issue","number":66377,"title":"Telegram polling reconnect ladder stalls mid-way — gateway alive but silently dead, Restart=always never fires","url":null,"state":"closed"},{"kind":"pr","number":3177,"title":"fix(telegram): self-reschedule reconnect when start_polling fails after network error","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3268","number":3268}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":3268,"title":"fix(telegram): self-reschedule reconnect when start_polling fails after 502","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/telegram.py","tests/gateway/test_telegram_network_reconnect.py"]},{"kind":"pr","number":18088,"title":"fix(telegram): probe polling liveness after reconnect to detect wedged Updater","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18751","number":18751}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":18751,"title":"fix(telegram): probe polling liveness after reconnect to detect wedged Updater (salvage #18088)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/telegram.py","tests/gateway/test_telegram_network_reconnect.py"],"fix_hunks":[{"file":"gateway/platforms/telegram.py","hunks":["@@ -512,6 +512,17 @@ async def _handle_polling_network_error(self, error: Exception) -> None:\n                 self.name, attempt,\n             )\n             self._polling_network_error_count = 0\n+            # start_polling() returning is necessary but not sufficient:\n+            # PTB's Updater can be left in a state where `running` is True\n+            # but the underlying long-poll task is wedged on a stale httpx\n+            # connection and never makes progress. No error_callback fires\n+            # in that state, so the reconnect ladder won't advance on its\n+            # own. Schedule a deferred probe to detect the wedge and\n+            # re-enter the ladder if needed.\n+            if not self.has_fatal_error:\n+                probe = asyncio.ensure_future(self._verify_polling_after_reconnect())\n+                self._background_tasks.add(probe)\n+                probe.add_done_callback(self._background_tasks.discard)\n         except Exception as retry_err:\n             logger.warning(\"[%s] Telegram polling reconnect failed: %s\", self.name, retry_err)\n             # start_polling failed — polling is dead and no further error","@@ -523,6 +534,50 @@ async def _handle_polling_network_error(self, error: Exception) -> None:\n                 self._background_tasks.add(task)\n                 task.add_done_callback(self._background_tasks.discard)\n \n+    async def _verify_polling_after_reconnect(self) -> None:\n+        \"\"\"Heartbeat probe scheduled after a successful reconnect.\n+\n+        PTB's Updater can survive a botched stop()+start_polling() cycle\n+        with `running=True` but a wedged consumer task. No error callback\n+        fires, so the reconnect ladder doesn't advance on its own. This\n+        probe detects the wedge by:\n+\n+        1. Sleeping HEARTBEAT_PROBE_DELAY so a healthy long-poll has time\n+           to complete at least one cycle.\n+        2. Verifying `Updater.running` is still True.\n+        3. Probing the bot endpoint with a tight asyncio timeout. A\n+           wedged httpx pool fails this probe; a healthy one returns\n+           well under the timeout.\n+\n+        On any failure, re-enter the reconnect ladder so the existing\n+        MAX_NETWORK_RETRIES path can ultimately escalate to fatal-error.\n+        \"\"\"\n+        HEARTBEAT_PROBE_DELAY = 60\n+        PROBE_TIMEOUT = 10\n+\n+        await asyncio.sleep(HEARTBEAT_PROBE_DELAY)\n+\n+        if self.has_fatal_error:\n+            return\n+        if not (self._app and self._app.updater and self._app.updater.running):\n+            logger.warning(\n+                \"[%s] Updater not running %ds after reconnect — treating as wedged\",\n+                self.name, HEARTBEAT_PROBE_DELAY,\n+            )\n+            await self._handle_polling_network_error(\n+                RuntimeError(\"Updater not running after reconnect heartbeat\")\n+            )\n+            return\n+\n+        try:\n+            await asyncio.wait_for(self._app.bot.get_me(), PROBE_TIMEOUT)\n+        except Exception as probe_err:\n+            logger.warning(\n+                \"[%s] Polling heartbeat probe failed %ds after reconnect: %s\",\n+                self.name, HEARTBEAT_PROBE_DELAY, probe_err,\n+            )\n+            await self._handle_polling_network_error(probe_err)\n+\n     async def _handle_polling_conflict(self, error: Exception) -> None:\n         if self.has_fatal_error and self.fatal_error_code == \"telegram_polling_conflict\":\n             return"]},{"file":"tests/gateway/test_telegram_network_reconnect.py","hunks":["@@ -132,6 +132,7 @@ async def test_reconnect_success_resets_error_count():\n \n     mock_app = MagicMock()\n     mock_app.updater = mock_updater\n+    mock_app.bot.get_me = AsyncMock(return_value=MagicMock())  # heartbeat probe path\n     adapter._app = mock_app\n \n     with patch(\"asyncio.sleep\", new_callable=AsyncMock):","@@ -139,6 +140,15 @@ async def test_reconnect_success_resets_error_count():\n \n     assert adapter._polling_network_error_count == 0\n \n+    # Clean up the heartbeat-probe task scheduled after a successful reconnect.\n+    pending = [t for t in adapter._background_tasks if not t.done()]\n+    for t in pending:\n+        t.cancel()\n+        try:\n+            await t\n+        except (asyncio.CancelledError, Exception):\n+            pass\n+\n \n @pytest.mark.asyncio\n async def test_reconnect_triggers_fatal_after_max_retries():","@@ -284,3 +294,182 @@ async def test_drain_helper_noop_without_app():\n     adapter._app = None\n     # Should not raise\n     await adapter._drain_polling_connections()\n+\n+\n+# ── Heartbeat probe ──────────────────────────────────────────────────────\n+\n+\n[redacted:email]\n+async def test_heartbeat_probe_no_op_when_polling_healthy():\n+    \"\"\"\n+    Probe scheduled after a successful reconnect: Updater.running=True and\n+    bot.get_me() returns quickly → recovery confirmed, no further action.\n+    \"\"\"\n+    adapter = _make_adapter()\n+\n+    mock_updater = MagicMock()\n+    mock_updater.running = True\n+\n+    mock_app = MagicMock()\n+    mock_app.updater = mock_updater\n+    mock_app.bot.get_me = AsyncMock(return_value=MagicMock())\n+    adapter._app = mock_app\n+\n+    adapter._handle_polling_network_error = AsyncMock()\n+\n+    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n+        await adapter._verify_polling_after_reconnect()\n+\n+    mock_app.bot.get_me.assert_awaited_once()\n+    adapter._handle_polling_network_error.assert_not_awaited()\n+\n+\n[redacted:email]\n+async def test_heartbeat_probe_reenters_ladder_when_updater_not_running():\n+    \"\"\"\n+    If Updater.running has flipped to False by the heartbeat delay, treat\n+    as wedged: re-enter the reconnect ladder.\n+    \"\"\"\n+    adapter = _make_adapter()\n+\n+    mock_updater = MagicMock()\n+    mock_updater.running = False\n+\n+    mock_app = MagicMock()\n+    mock_app.updater = mock_updater\n+    mock_app.bot.get_me = AsyncMock()\n+    adapter._app = mock_app\n+\n+    adapter._handle_polling_network_error = AsyncMock()\n+\n+    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n+        await adapter._verify_polling_after_reconnect()\n+\n+    mock_app.bot.get_me.assert_not_called()\n+    adapter._handle_polling_network_error.assert_awaited_once()\n+    err = adapter._handle_polling_network_error.await_args.args[0]\n+    assert isinstance(err, RuntimeError)\n+    assert \"not running\" in str(err).lower()\n+\n+\n[redacted:email]\n+async def test_heartbeat_probe_reenters_ladder_when_get_me_times_out():\n+    \"\"\"\n+    If bot.get_me() hangs longer than PROBE_TIMEOUT, treat as wedged.\n+    Simulates the connection-pool wedge that motivated this fix.\n+    \"\"\"\n+    adapter = _make_adapter()\n+\n+    mock_updater = MagicMock()\n+    mock_updater.running = True\n+\n+    async def hang_forever(*args, **kwargs):\n+        await asyncio.sleep(3600)\n+\n+    mock_app = MagicMock()\n+    mock_app.updater = mock_updater\n+    mock_app.bot.get_me = AsyncMock(side_effect=hang_forever)\n+    adapter._app = mock_app\n+\n+    adapter._handle_polling_network_error = AsyncMock()\n+\n+    async def fast_wait_for(coro, timeout):\n+        if asyncio.iscoroutine(coro):\n+            coro.close()\n+        raise asyncio.TimeoutError()\n+\n+    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n+        with patch(\"gateway.platforms.telegram.asyncio.wait_for\", new=fast_wait_for):\n+            await adapter._verify_polling_after_reconnect()\n+\n+    adapter._handle_polling_network_error.assert_awaited_once()\n+\n+\n[redacted:email]\n+async def test_heartbeat_probe_reenters_ladder_on_get_me_network_error():\n+    \"\"\"\n+    Any exception raised by bot.get_me() (NetworkError, ConnectionError, etc.)\n+    should re-enter the reconnect ladder with the original exception.\n+    \"\"\"\n+    adapter = _make_adapter()\n+\n+    mock_updater = MagicMock()\n+    mock_updater.running = True\n+\n+    mock_app = MagicMock()\n+    mock_app.updater = mock_updater\n+    mock_app.bot.get_me = AsyncMock(side_effect=ConnectionError(\"pool wedged\"))\n+    adapter._app = mock_app\n+\n+    adapter._handle_polling_network_error = AsyncMock()\n+\n+    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n+        await adapter._verify_polling_after_reconnect()\n+\n+    adapter._handle_polling_network_error.assert_awaited_once()\n+    assert isinstance(\n+        adapter._handle_polling_network_error.await_args.args[0], ConnectionError\n+    )\n+\n+\n[redacted:email]\n+async def test_heartbeat_probe_skips_when_already_fatal():\n+    \"\"\"\n+    If the adapter is already in fatal-error state by the time the probe\n+    delay elapses, the probe should bail without further action.\n+    \"\"\"\n+    adapter = _make_adapter()\n+    adapter._set_fatal_error(\"telegram_polling_conflict\", \"already fatal\", retryable=False)\n+\n+    mock_app = MagicMock()\n+    mock_app.bot.get_me = AsyncMock()\n+    adapter._app = mock_app\n+\n+    adapter._handle_polling_network_error = AsyncMock()\n+\n+    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n+        await adapter._verify_polling_after_reconnect()\n+\n+    mock_app.bot.get_me.assert_not_called()\n+    adapter._handle_polling_network_error.assert_not_awaited()\n+\n+\n[redacted:email]\n+async def test_reconnect_schedules_heartbeat_probe_on_success():\n+    \"\"\"\n+    After a successful start_polling() in the reconnect path, a probe task\n+    must be added to _background_tasks. Without it, a wedged Updater would\n+    sit silent indefinitely with no further error_callback to advance the\n+    reconnect ladder.\n+    \"\"\"\n+    adapter = _make_adapter()\n+    adapter._polling_network_error_count = 1\n+\n+    mock_updater = MagicMock()\n+    mock_updater.running = True\n+    mock_updater.stop = AsyncMock()\n+    mock_updater.start_polling = AsyncMock()  # succeeds\n+\n+    mock_app = MagicMock()\n+    mock_app.updater = mock_updater\n+    mock_app.bot.get_me = AsyncMock(return_value=MagicMock())\n+    adapter._app = mock_app\n+\n+    initial_count = len(adapter._background_tasks)\n+\n+    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n+        await adapter._handle_polling_network_error(Exception(\"Bad Gateway\"))\n+\n+    assert len(adapter._background_tasks) > initial_count, (\n+        \"Expected a heartbeat probe task to be scheduled after a successful \"\n+        \"reconnect's start_polling()\"\n+    )\n+\n+    # Clean up.\n+    pending = [t for t in adapter._background_tasks if not t.done()]\n+    for t in pending:\n+        t.cancel()\n+        try:\n+            await t\n+        except (asyncio.CancelledError, Exception):\n+            pass\n"]}]},{"kind":"pr","number":56028,"title":"fix(gateway): guard NoneType in Telegram reconnect after network error (#55992)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56224","number":56224},{"kind":"pr","value":"#56036","number":56036},{"kind":"pr","value":"#55992","number":55992},{"kind":"pr","value":"#56224","number":56224},{"kind":"pr","value":"#55992","number":55992}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":56036,"title":"fix(telegram): close reconnect races that leave adapter half-destroyed","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#56224","number":56224},{"kind":"pr","value":"#56224","number":56224}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":56200,"title":"fix(telegram): close reconnect races that leave the adapter half-destroyed (#55992)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","plugins/platforms/telegram/adapter.py","tests/gateway/test_runner_fatal_adapter.py","tests/gateway/test_telegram_network_reconnect.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -3654,6 +3654,23 @@ async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> Non\n         If the error is retryable (e.g. network blip, DNS failure), queue the\n         platform for background reconnection instead of giving up permanently.\n         \"\"\"\n+        # Snapshot the current owner of this platform slot before doing\n+        # anything else. If it's neither this adapter nor empty, a different\n+        # adapter has already taken over (e.g. this is a delayed notification\n+        # from a background retry chain that raced with, and lost to, a\n+        # reconnect that already succeeded). Acting on a stale notification\n+        # would overwrite an already-healthy platform's runtime status and\n+        # incorrectly re-queue it for reconnection, so bail out before any of\n+        # that happens.\n+        existing = self.adapters.get(adapter.platform)\n+        if existing is not None and existing is not adapter:\n+            logger.debug(\n+                \"Ignoring stale fatal error from a superseded %s adapter instance: %s\",\n+                adapter.platform.value,\n+                adapter.fatal_error_code or \"unknown\",\n+            )\n+            return\n+\n         logger.error(\n             \"Fatal %s adapter error (%s): %s\",\n             adapter.platform.value,","@@ -3677,13 +3694,15 @@ async def _handle_adapter_fatal_error(self, adapter: BasePlatformAdapter) -> Non\n             error_message=adapter.fatal_error_message,\n         )\n \n-        existing = self.adapters.get(adapter.platform)\n         if existing is adapter:\n-            try:\n-                await adapter.disconnect()\n-            finally:\n-                self.adapters.pop(adapter.platform, None)\n-                self.delivery_router.adapters = self.adapters\n+            # Claim this adapter for teardown before awaiting disconnect() —\n+            # a second fatal-error notification for the same adapter (e.g.\n+            # from a concurrent recovery path) would otherwise still see\n+            # itself as \"existing\" during the await below and disconnect()\n+            # the same object twice.\n+            self.adapters.pop(adapter.platform, None)\n+            self.delivery_router.adapters = self.adapters\n+            await adapter.disconnect()\n \n         # Queue retryable failures for background reconnection\n         if adapter.fatal_error_retryable:"]},{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -1773,16 +1773,24 @@ async def _handle_polling_network_error(self, error: Exception) -> None:\n         )\n         await asyncio.sleep(delay)\n \n+        # Capture a stable local reference: self._app can be reassigned to None\n+        # by a concurrent disconnect() while we're suspended across the awaits\n+        # below, and re-reading self._app after that point would silently swap\n+        # in None mid-sequence instead of failing fast in one place.\n+        app = self._app\n+\n         try:\n-            if self._app and self._app.updater and self._app.updater.running:\n-                await self._app.updater.stop()\n+            if app and app.updater and app.updater.running:\n+                await app.updater.stop()\n         except Exception:\n             pass\n \n         await self._drain_polling_connections()\n \n         try:\n-            await self._app.updater.start_polling(\n+            if not app:\n+                raise RuntimeError(\"Telegram application was torn down during reconnect\")\n+            await app.updater.start_polling(\n                 allowed_updates=Update.ALL_TYPES,\n                 drop_pending_updates=False,\n                 error_callback=self._polling_error_callback_ref,","@@ -1824,6 +1832,12 @@ async def _handle_polling_network_error(self, error: Exception) -> None:\n                 )\n                 self._background_tasks.add(task)\n                 task.add_done_callback(self._background_tasks.discard)\n+                # This chained retry IS the in-flight recovery attempt — it\n+                # must replace the reentrancy guard, otherwise the heartbeat\n+                # loop, the pending-updates probe, and the PTB error callback\n+                # all see _polling_error_task as \"done\" and can each start a\n+                # second, concurrent recovery for the same outage.\n+                self._polling_error_task = task\n \n     async def _polling_heartbeat_loop(self) -> None:\n         \"\"\"Detect dead Telegram TCP sockets (CLOSE-WAIT) by periodic probing.","@@ -2151,8 +2165,17 @@ async def _handle_polling_conflict(self, error: Exception) -> None:\n             await asyncio.sleep(RETRY_DELAY)\n             await self._drain_polling_connections()\n \n+            # Capture a stable local reference: self._app can be reassigned to\n+            # None by a concurrent disconnect() while we're suspended across\n+            # the awaits above (same race #55992 fixed on the network path).\n+            # Re-reading self._app after that point would raise\n+            # AttributeError deep inside start_polling instead of failing fast\n+            # here, where the except below reschedules or escalates to fatal.\n+            app = self._app\n             try:\n-                await self._app.updater.start_polling(\n+                if not app:\n+                    raise RuntimeError(\"Telegram application was torn down during conflict reconnect\")\n+                await app.updater.start_polling(\n                     allowed_updates=Update.ALL_TYPES,\n                     drop_pending_updates=False,\n                     error_callback=self._polling_error_callback_ref,"]},{"file":"tests/gateway/test_runner_fatal_adapter.py","hunks":["@@ -1,3 +1,4 @@\n+import asyncio\n from unittest.mock import AsyncMock\n \n import pytest","@@ -98,3 +99,96 @@ async def test_runner_queues_retryable_runtime_fatal_for_reconnection(monkeypatc\n     assert runner._exit_with_failure is False\n     assert Platform.WHATSAPP in runner._failed_platforms\n     assert runner._failed_platforms[Platform.WHATSAPP][\"attempts\"] == 0\n+\n+\n[redacted:email]\n+async def test_concurrent_fatal_notifications_disconnect_same_adapter_once(monkeypatch, tmp_path):\n+    \"\"\"\n+    Two fatal-error notifications for the same still-installed adapter (e.g.\n+    from two concurrent recovery paths racing on the same underlying outage)\n+    must result in exactly one disconnect() call.\n+\n+    Regression test for the TOCTOU race in _handle_adapter_fatal_error: the\n+    old code only removed the adapter from self.adapters in a `finally` block\n+    *after* awaiting disconnect(), so a second concurrent call could still see\n+    itself as \"existing\" and disconnect() the same object twice — the\n+    concrete origin of the \"'NoneType' object has no attribute 'updater'\"\n+    crash when the adapter's own teardown code re-reads self._app afterwards.\n+    \"\"\"\n+    config = GatewayConfig(\n+        platforms={\n+            Platform.WHATSAPP: PlatformConfig(enabled=True, token=\"token\")\n+        },\n+        sessions_dir=tmp_path / \"sessions\",\n+    )\n+    runner = GatewayRunner(config)\n+    adapter = _RuntimeRetryableAdapter()\n+    adapter._set_fatal_error(\n+        \"whatsapp_bridge_exited\",\n+        \"WhatsApp bridge process exited unexpectedly (code 1).\",\n+        retryable=True,\n+    )\n+\n+    runner.adapters = {Platform.WHATSAPP: adapter}\n+    runner.delivery_router.adapters = runner.adapters\n+    runner.stop = AsyncMock()\n+\n+    disconnect_calls = 0\n+    release_second_call = asyncio.Event()\n+\n+    async def slow_disconnect():\n+        nonlocal disconnect_calls\n+        disconnect_calls += 1\n+        # Yield control so the second concurrent notification can run its\n+        # \"existing is adapter\" check before this call finishes tearing down.\n+        release_second_call.set()\n+        await asyncio.sleep(0)\n+        adapter._mark_disconnected()\n+\n+    monkeypatch.setattr(adapter, \"disconnect\", slow_disconnect)\n+\n+    await asyncio.gather(\n+        runner._handle_adapter_fatal_error(adapter),\n+        runner._handle_adapter_fatal_error(adapter),\n+    )\n+\n+    assert disconnect_calls == 1\n+\n+\n[redacted:email]\n+async def test_stale_fatal_notification_from_superseded_adapter_is_ignored(monkeypatch, tmp_path):\n+    \"\"\"\n+    A delayed fatal-error notification from an adapter instance that has\n+    since been replaced by a different, already-installed adapter (e.g. a\n+    background retry chain on the old instance finally giving up after a\n+    reconnect on a new instance already succeeded) must be ignored: it must\n+    not disconnect the new adapter, must not re-queue an already-healthy\n+    platform for reconnection, and must not shut the gateway down.\n+    \"\"\"\n+    config = GatewayConfig(\n+        platforms={\n+            Platform.WHATSAPP: PlatformConfig(enabled=True, token=\"token\")\n+        },\n+        sessions_dir=tmp_path / \"sessions\",\n+    )\n+    runner = GatewayRunner(config)\n+\n+    old_adapter = _RuntimeRetryableAdapter()\n+    old_adapter._set_fatal_error(\n+        \"whatsapp_bridge_exited\",\n+        \"stale failure from a superseded adapter instance\",\n+        retryable=True,\n+    )\n+\n+    new_adapter = _RuntimeRetryableAdapter()\n+    new_adapter.disconnect = AsyncMock()\n+    runner.adapters = {Platform.WHATSAPP: new_adapter}\n+    runner.delivery_router.adapters = runner.adapters\n+    runner.stop = AsyncMock()\n+\n+    await runner._handle_adapter_fatal_error(old_adapter)\n+\n+    new_adapter.disconnect.assert_not_awaited()\n+    assert runner.adapters[Platform.WHATSAPP] is new_adapter\n+    assert Platform.WHATSAPP not in runner._failed_platforms\n+    runner.stop.assert_not_awaited()"]},{"file":"tests/gateway/test_telegram_network_reconnect.py","hunks":["@@ -117,6 +117,43 @@ async def test_reconnect_does_not_self_schedule_when_fatal_error_set():\n     )\n \n \n[redacted:email]\n+async def test_reconnect_chained_retry_updates_polling_error_task():\n+    \"\"\"\n+    When start_polling() fails and the handler self-schedules a retry, that\n+    retry task must become the new `_polling_error_task` — otherwise the\n+    reentrancy guard used by the heartbeat loop, the pending-updates probe,\n+    and the PTB error callback goes stale while a recovery is still in\n+    flight, letting a second concurrent recovery start for the same outage.\n+\n+    Regression test for the race behind the \"half-destroyed adapter\" bug\n+    (gateway reports connected but silently stops processing messages).\n+    \"\"\"\n+    adapter = _make_adapter()\n+    adapter._polling_network_error_count = 1\n+\n+    mock_updater = MagicMock()\n+    mock_updater.running = True\n+    mock_updater.stop = AsyncMock()\n+    mock_updater.start_polling = AsyncMock(side_effect=Exception(\"Timed out\"))\n+\n+    mock_app = MagicMock()\n+    mock_app.updater = mock_updater\n+    adapter._app = mock_app\n+\n+    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n+        await adapter._handle_polling_network_error(Exception(\"Bad Gateway\"))\n+\n+    assert adapter._polling_error_task is not None\n+    assert not adapter._polling_error_task.done()\n+\n+    adapter._polling_error_task.cancel()\n+    try:\n+        await adapter._polling_error_task\n+    except (asyncio.CancelledError, Exception):\n+        pass\n+\n+\n @pytest.mark.asyncio\n async def test_reconnect_success_resets_error_count():\n     \"\"\"\n"]}]},{"kind":"pr","number":58272,"title":"fix(telegram): bound updater.stop() with timeout to prevent CLOSE-WAIT reconnect hang","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8645b3430","number":0},{"kind":"commit","value":"b1c7b9654","number":0},{"kind":"pr","value":"#58342","number":58342},{"kind":"pr","value":"#58270","number":58270}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":58342,"title":"fix(telegram): bound all updater.stop() calls to prevent CLOSE-WAIT reconnect hang (salvage #58272, closes #58270)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/telegram/adapter.py","tests/gateway/test_telegram_conflict.py","tests/gateway/test_telegram_network_reconnect.py"],"fix_hunks":[{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -397,6 +397,14 @@ def _rich_normalize_linebreaks(text: str) -> str:\n     return ''.join(out)\n \n \n+# Watchdog bound for `await updater.stop()`. When the underlying TCP socket is\n+# in CLOSE-WAIT the PTB polling task is blocked on epoll on the dead socket and\n+# never wakes, so an unguarded stop() hangs indefinitely and wedges the whole\n+# reconnect/teardown ladder. This is an internal safety bound (not a user knob),\n+# applied identically at every stop() site so no path can hang on a dead socket.\n+_UPDATER_STOP_TIMEOUT = 15.0\n+\n+\n class TelegramAdapter(BasePlatformAdapter):\n     \"\"\"\n     Telegram bot adapter.","@@ -1990,7 +1998,25 @@ async def _handle_polling_network_error(self, error: Exception) -> None:\n \n         try:\n             if app and app.updater and app.updater.running:\n-                await app.updater.stop()\n+                try:\n+                    # Guard stop() with a timeout: when the underlying TCP\n+                    # connection is in CLOSE-WAIT the PTB polling task is\n+                    # blocked on epoll on the dead socket and never wakes up,\n+                    # so an unguarded stop() hangs indefinitely.  The result\n+                    # is that _polling_error_task stays alive-but-blocked\n+                    # forever, every subsequent heartbeat probe sees it as\n+                    # \"in-flight\" and skips triggering a new reconnect, and\n+                    # the gateway silently drops messages for hours.\n+                    # Bounding stop() lets the reconnect ladder always advance.\n+                    # Refs: NousResearch/hermes-agent#58270\n+                    await asyncio.wait_for(app.updater.stop(), timeout=_UPDATER_STOP_TIMEOUT)\n+                except asyncio.TimeoutError:\n+                    logger.warning(\n+                        \"[%s] updater.stop() timed out during network-error \"\n+                        \"reconnect (likely CLOSE-WAIT socket); forcing drain \"\n+                        \"and restart without clean stop\",\n+                        self.name,\n+                    )\n         except Exception:\n             pass\n ","@@ -2364,10 +2390,19 @@ async def _handle_polling_conflict(self, error: Exception) -> None:\n             )\n             # Stop the local updater cleanly before sleeping.  If it's already\n             # stopped (e.g. PTB raised before updater.running was set) this is\n-            # a no-op.\n+            # a no-op.  Bounded with a timeout for the same reason as the\n+            # network-error path: a CLOSE-WAIT socket can wedge stop() on epoll\n+            # forever, which would stall the conflict-retry ladder.\n             try:\n                 if self._app and self._app.updater and self._app.updater.running:\n-                    await self._app.updater.stop()\n+                    try:\n+                        await asyncio.wait_for(self._app.updater.stop(), timeout=_UPDATER_STOP_TIMEOUT)\n+                    except asyncio.TimeoutError:\n+                        logger.warning(\n+                            \"[%s] updater.stop() timed out during conflict \"\n+                            \"retry (likely CLOSE-WAIT socket); continuing\",\n+                            self.name,\n+                        )\n             except Exception:\n                 pass\n ","@@ -2435,16 +2470,33 @@ async def _handle_polling_conflict(self, error: Exception) -> None:\n             \"[%s] %s Original error: %s\",\n             self.name, message, error,\n         )\n+        # Snapshot whether we are the call that actually transitions to fatal.\n+        # A concurrent retry task scheduled by an earlier conflict may already\n+        # be suspended past the entry guard; once _set_fatal_error flips the\n+        # flag, adding an await below (the bounded stop()) yields the loop and\n+        # lets that task reach this branch too — double-notifying the fatal\n+        # handler.  Only the first transition notifies.\n+        _already_fatal = (\n+            self.has_fatal_error\n+            and self.fatal_error_code == \"telegram_polling_conflict\"\n+        )\n         self._set_fatal_error(\"telegram_polling_conflict\", message, retryable=False)\n         try:\n             if self._app and self._app.updater:\n-                await self._app.updater.stop()\n+                await asyncio.wait_for(self._app.updater.stop(), timeout=_UPDATER_STOP_TIMEOUT)\n+        except asyncio.TimeoutError:\n+            logger.warning(\n+                \"[%s] updater.stop() timed out after exhausting conflict \"\n+                \"retries (likely CLOSE-WAIT socket); proceeding to fatal notify\",\n+                self.name,\n+            )\n         except Exception as stop_error:\n             logger.warning(\n                 \"[%s] Failed stopping Telegram updater after exhausting conflict retries: %s\",\n                 self.name, stop_error, exc_info=True,\n             )\n-        await self._notify_fatal_error()\n+        if not _already_fatal:\n+            await self._notify_fatal_error()\n \n     async def _create_dm_topic(\n         self,","@@ -3334,9 +3386,20 @@ async def disconnect(self) -> None:\n \n         if self._app:\n             try:\n-                # Only stop the updater if it's running\n+                # Only stop the updater if it's running.  Bounded with a\n+                # timeout: a CLOSE-WAIT socket can wedge stop() on epoll\n+                # indefinitely, which would hang disconnect() (and any\n+                # gateway shutdown/restart waiting on it) forever.  On timeout\n+                # we fall through to app.stop()/shutdown() to force teardown.\n                 if self._app.updater and self._app.updater.running:\n-                    await self._app.updater.stop()\n+                    try:\n+                        await asyncio.wait_for(self._app.updater.stop(), timeout=_UPDATER_STOP_TIMEOUT)\n+                    except asyncio.TimeoutError:\n+                        logger.warning(\n+                            \"[%s] updater.stop() timed out during disconnect \"\n+                            \"(likely CLOSE-WAIT socket); forcing app shutdown\",\n+                            self.name,\n+                        )\n                 if self._app.running:\n                     await self._app.stop()\n                 await self._app.shutdown()"]},{"file":"tests/gateway/test_telegram_conflict.py","hunks":["@@ -220,6 +220,20 @@ async def failing_start_polling(**kwargs):\n             conflict(\"Conflict: terminated by other getUpdates request\")\n         )\n \n+    # Retries 1-4 each schedule a background recovery task via\n+    # loop.create_task(self._handle_polling_conflict(...)) that this test\n+    # never awaits.  Cancel the last one so a leaked task can't get a\n+    # scheduler turn under load and re-drive the counter into the fatal\n+    # branch a second time — which would fire _notify_fatal_error twice and\n+    # break assert_awaited_once() non-deterministically.\n+    leaked = adapter._polling_error_task\n+    if leaked is not None and not leaked.done():\n+        leaked.cancel()\n+        try:\n+            await leaked\n+        except (asyncio.CancelledError, Exception):\n+            pass\n+\n     # After 5 failed retries (count 1-5 each enter the retry branch but\n     # start_polling raises), the 6th conflict pushes count to 6 which\n     # exceeds MAX_CONFLICT_RETRIES (5), entering the fatal branch."]},{"file":"tests/gateway/test_telegram_network_reconnect.py","hunks":["@@ -812,3 +812,59 @@ async def test_schedule_polling_recovery_tracks_background_task():\n     await adapter._polling_error_task\n     adapter._handle_polling_network_error.assert_awaited_once()\n \n+\n[redacted:email]\n+async def test_handle_polling_network_error_updater_stop_timeout():\n+    \"\"\"updater.stop() hanging (CLOSE-WAIT) must not block the reconnect ladder.\n+\n+    When the underlying TCP connection is in CLOSE-WAIT, PTB's polling task is\n+    blocked on epoll on the dead socket.  updater.stop() awaits that task and\n+    therefore hangs indefinitely.  The fix wraps stop() in asyncio.wait_for()\n+    with a 15-second timeout so the reconnect always advances.\n+\n+    This test simulates the hang by making stop() sleep forever and verifies\n+    that _drain_polling_connections() and start_polling() are still called\n+    after the timeout fires.\n+    Refs: NousResearch/hermes-agent#58270\n+    \"\"\"\n+    adapter = _make_adapter()\n+    adapter._polling_network_error_count = 0\n+\n+    # Build a fake app whose updater.stop() hangs forever.\n+    app = MagicMock()\n+    app.updater = MagicMock()\n+    app.updater.running = True\n+\n+    async def _hanging_stop():\n+        await asyncio.sleep(9999)  # simulate CLOSE-WAIT block\n+\n+    app.updater.stop = _hanging_stop\n+    app.updater.start_polling = AsyncMock()\n+    adapter._app = app\n+\n+    drain_called = []\n+\n+    async def _fake_drain():\n+        drain_called.append(True)\n+\n+    adapter._drain_polling_connections = _fake_drain\n+\n+    start_polling_called = []\n+\n+    async def _fake_start_polling(**kwargs):\n+        start_polling_called.append(True)\n+\n+    app.updater.start_polling = AsyncMock(side_effect=_fake_start_polling)\n+\n+    # Shrink the stop() watchdog bound so the test completes fast instead of\n+    # waiting the full _UPDATER_STOP_TIMEOUT. Patching the named constant is\n+    # cleaner than monkeypatching asyncio.wait_for process-wide.\n+    import plugins.platforms.telegram.adapter as _mod\n+\n+    with patch.object(_mod, \"_UPDATER_STOP_TIMEOUT\", 0.05):\n+        await adapter._handle_polling_network_error(OSError(\"CLOSE-WAIT test\"))\n+\n+    # The reconnect ladder must have advanced past the hung stop().\n+    assert drain_called, \"_drain_polling_connections was not called after stop() timeout\"\n+    assert start_polling_called, \"start_polling was not called after stop() timeout\"\n+\n"]}]},{"kind":"pr","number":66492,"title":"fix(telegram): bound drain awaits so a wedged httpx pool cannot freeze the reconnect ladder (#66377)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#67241","number":67241}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":67241,"title":"fix(telegram): cause-agnostic wedged-recovery watchdog + bounded drain so the reconnect ladder can't freeze silently (#66377, supersedes #66492)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["contributors/emails/[redacted:email]","plugins/platforms/telegram/adapter.py","tests/gateway/test_telegram_network_reconnect.py"],"fix_hunks":[{"file":"contributors/emails/[redacted:email]","hunks":["@@ -0,0 +1,2 @@\n+MaheshBhushan\n+# PR #66492 salvage (#66377)"]},{"file":"plugins/platforms/telegram/adapter.py","hunks":["@@ -17,6 +17,7 @@\n import html as _html\n import re\n import threading\n+import time\n from contextvars import ContextVar\n from datetime import datetime, timezone\n from typing import Dict, List, Optional, Set, Any","@@ -543,6 +544,24 @@ def _rich_normalize_linebreaks(text: str) -> str:\n # reconnect ladder from stalling indefinitely and allows the heartbeat loop to\n # trigger its own recovery path. Refs: NousResearch/hermes-agent#59614\n _UPDATER_START_TIMEOUT = 30.0\n+# shutdown()/initialize() on the getUpdates httpx request close and rebuild the\n+# connection pool. When a connection is wedged on a stale CLOSE-WAIT socket that\n+# close can block forever, hanging _drain_polling_connections() and freezing the\n+# whole reconnect ladder (the tracked _polling_error_task never completes, so\n+# every escalation path stays gated behind its in-flight guard). Bound the drain\n+# so the ladder always advances toward the fatal-restart escalation. Matches\n+# _UPDATER_STOP_TIMEOUT. Refs: NousResearch/hermes-agent#66377\n+_DRAIN_TIMEOUT = 15.0\n+# Cause-agnostic wedged-recovery watchdog (#66377). Every recovery path (the\n+# reconnect ladder's re-entry, the pending-update probe, PTB's error callback)\n+# gates new recovery on ``_polling_error_task.done()``; if that task ever wedges\n+# on a hung await that no local bound covers, the whole gateway goes silently\n+# deaf with nothing retrying. The heartbeat loop force-escalates a recovery task\n+# that stays in-flight far longer than any healthy ladder attempt could take —\n+# stop (_UPDATER_STOP_TIMEOUT) + drain (2x_DRAIN_TIMEOUT) + start\n+# (_UPDATER_START_TIMEOUT) + max backoff (60s) is ~135s, so 300s is\n+# unambiguously stuck.\n+_POLLING_ERROR_TASK_STUCK_TIMEOUT = 300.0\n # A generation is not healthy until the dedicated getUpdates request returns\n # successfully. This exceeds a normal long-poll cycle for healthy idle bots.\n _POLLING_PROGRESS_TIMEOUT = 60.0","@@ -1987,20 +2006,22 @@ async def _drain_polling_connections(self) -> None:\n         except Exception:\n             return\n         try:\n-            await polling_req.shutdown()\n+            # Bounded: a wedged CLOSE-WAIT socket can make this close hang\n+            # forever and freeze the reconnect ladder (#66377).\n+            await asyncio.wait_for(polling_req.shutdown(), timeout=_DRAIN_TIMEOUT)\n         except Exception:\n             logger.debug(\n-                \"[%s] Polling request shutdown failed (non-fatal)\",\n+                \"[%s] Polling request shutdown failed/timed out (non-fatal)\",\n                 self.name, exc_info=True,\n             )\n         try:\n-            await polling_req.initialize()\n+            await asyncio.wait_for(polling_req.initialize(), timeout=_DRAIN_TIMEOUT)\n             logger.debug(\n                 \"[%s] Polling request pool drained before reconnect\", self.name\n             )\n         except Exception:\n             logger.debug(\n-                \"[%s] Polling request re-initialize failed (non-fatal)\",\n+                \"[%s] Polling request re-initialize failed/timed out (non-fatal)\",\n                 self.name, exc_info=True,\n             )\n ","@@ -2454,6 +2475,16 @@ async def _polling_heartbeat_loop(self) -> None:\n         HEARTBEAT_INTERVAL = 90   # seconds between probes\n         PROBE_TIMEOUT = 15        # seconds before declaring the path dead\n \n+        # Wedged-recovery watchdog state (#66377). Tracked locally so no\n+        # _polling_error_task assignment site needs to stamp a timestamp: the\n+        # heartbeat notes when it first observes a given recovery task still\n+        # in-flight, and force-escalates if the *same* task object is still\n+        # running after _POLLING_ERROR_TASK_STUCK_TIMEOUT. A healthy ladder\n+        # attempt completes (task done) or chains to a new task well before\n+        # then, so a single long-lived task is unambiguously wedged.\n+        stuck_task_ref: Optional[asyncio.Task] = None\n+        stuck_task_since = 0.0\n+\n         while True:\n             try:\n                 await asyncio.sleep(HEARTBEAT_INTERVAL)","@@ -2461,6 +2492,42 @@ async def _polling_heartbeat_loop(self) -> None:\n                     return\n                 if self.has_fatal_error:\n                     return\n+\n+                # Independent wedged-recovery watchdog (#66377): if the tracked\n+                # recovery task has hung (any await no local bound covers), every\n+                # other recovery path is gated behind it and returns early\n+                # forever — the gateway stays alive but deaf. Force a\n+                # retryable-fatal so the background reconnector rebuilds the\n+                # adapter instead of relying on the frozen ladder.\n+                recovery_task = self._polling_error_task\n+                if recovery_task is not None and not recovery_task.done():\n+                    now = time.monotonic()\n+                    if recovery_task is not stuck_task_ref:\n+                        stuck_task_ref = recovery_task\n+                        stuck_task_since = now\n+                    elif now - stuck_task_since > _POLLING_ERROR_TASK_STUCK_TIMEOUT:\n+                        stuck_for = now - stuck_task_since\n+                        logger.error(\n+                            \"[%s] Telegram reconnect task wedged for %.0fs with no \"\n+                            \"ladder progress; forcing retryable-fatal so the gateway \"\n+                            \"reconnects instead of staying silently deaf.\",\n+                            self.name, stuck_for,\n+                        )\n+                        try:\n+                            recovery_task.cancel()\n+                        except Exception:\n+                            pass\n+                        self._set_fatal_error(\n+                            \"telegram_network_error\",\n+                            \"Telegram reconnect task wedged for %.0fs; forcing \"\n+                            \"gateway reconnect.\" % stuck_for,\n+                            retryable=True,\n+                        )\n+                        await self._notify_fatal_error()\n+                        return\n+                else:\n+                    stuck_task_ref = None\n+\n                 bot = self._app.bot if self._app else None\n                 if bot is None:\n                     continue"]},{"file":"tests/gateway/test_telegram_network_reconnect.py","hunks":["@@ -325,6 +325,104 @@ async def test_initialize_still_runs_when_shutdown_fails():\n     mock_app.updater.start_polling.assert_called_once()\n \n \n[redacted:email]\n+async def test_reconnect_continues_if_drain_hangs(monkeypatch):\n+    \"\"\"If the polling request drain HANGS (wedged httpx pool close on a\n+    CLOSE-WAIT socket), the reconnect ladder must still advance rather than\n+    freezing the tracked _polling_error_task forever.\n+\n+    Regression test for #66377: an unbounded ``shutdown()`` /\n+    ``initialize()`` in ``_drain_polling_connections`` leaves the handler\n+    task pending, which gates every escalation path and silently kills the\n+    gateway. The drain awaits are bounded by ``_DRAIN_TIMEOUT``, so the\n+    handler must complete and reach ``start_polling`` within a hard bound.\n+    \"\"\"\n+    adapter = _make_adapter()\n+    adapter._polling_network_error_count = 1\n+\n+    mock_app, mock_polling_req = _make_mock_app()\n+\n+    async def _hang(*args, **kwargs):\n+        await asyncio.Event().wait()  # never returns\n+\n+    # Both drain awaits wedge indefinitely.\n+    mock_polling_req.shutdown = AsyncMock(side_effect=_hang)\n+    mock_polling_req.initialize = AsyncMock(side_effect=_hang)\n+    adapter._app = mock_app\n+\n+    # Keep the drain timeout tiny so the test stays fast; the real default\n+    # is generous enough not to truncate healthy closes.\n+    monkeypatch.setattr(tg_adapter, \"_DRAIN_TIMEOUT\", 0.01, raising=False)\n+\n+    with patch(\"asyncio.sleep\", new_callable=AsyncMock):\n+        # Hard outer bound: on unfixed code the drain hangs forever and this\n+        # trips; with the fix the inner wait_for releases well before it.\n+        await asyncio.wait_for(\n+            adapter._handle_polling_network_error(Exception(\"Timed out\")),\n+            timeout=5,\n+        )\n+\n+    # Ladder advanced past the wedged drain despite it never returning.\n+    mock_app.updater.start_polling.assert_called_once()\n+    assert adapter._polling_network_error_count == 2\n+    # The tracked task must not be stuck pending — otherwise every\n+    # escalation path stays gated behind an in-flight guard.\n+    assert (\n+        adapter._polling_error_task is None\n+        or adapter._polling_error_task.done()\n+    )\n+\n+\n[redacted:email]\n+async def test_heartbeat_force_escalates_wedged_recovery_task(monkeypatch):\n+    \"\"\"#66377: the heartbeat is an independent, cause-agnostic watchdog.\n+\n+    Every recovery path (ladder re-entry, pending-update probe, PTB error\n+    callback) gates new recovery on ``_polling_error_task.done()``. If that task\n+    wedges on ANY hung await — not just the drain closed by #66492 — the gateway\n+    stays alive but deaf with nothing retrying. The heartbeat must detect a\n+    recovery task that stays in-flight past ``_POLLING_ERROR_TASK_STUCK_TIMEOUT``\n+    and force a retryable-fatal so the background reconnector rebuilds the\n+    adapter.\n+    \"\"\"\n+    adapter = _make_adapter()\n+\n+    async def _wedged():\n+        await asyncio.Event().wait()  # never completes — simulates the hang\n+\n+    wedged_task = asyncio.ensure_future(_wedged())\n+    adapter._polling_error_task = wedged_task\n+\n+    mock_bot = MagicMock()\n+    mock_bot.get_me = AsyncMock()\n+    mock_app = MagicMock()\n+    mock_app.bot = mock_bot\n+    adapter._app = mock_app\n+    adapter._probe_pending_updates = AsyncMock()\n+    adapter._notify_fatal_error = AsyncMock()\n+\n+    # Controllable monotonic clock advanced by each (mocked) heartbeat sleep so\n+    # the same wedged task is observed across the stuck threshold deterministically.\n+    clock = [1000.0]\n+\n+    async def _fake_sleep(*_a, **_k):\n+        clock[0] += 200.0\n+\n+    monkeypatch.setattr(tg_adapter.time, \"monotonic\", lambda: clock[0])\n+\n+    with patch(\"asyncio.sleep\", new=AsyncMock(side_effect=_fake_sleep)):\n+        await asyncio.wait_for(adapter._polling_heartbeat_loop(), timeout=5)\n+\n+    assert adapter.has_fatal_error, \"wedged recovery task must force a fatal escalation\"\n+    adapter._notify_fatal_error.assert_awaited()\n+\n+    wedged_task.cancel()\n+    try:\n+        await wedged_task\n+    except asyncio.CancelledError:\n+        pass\n+\n+\n @pytest.mark.asyncio\n async def test_conflict_retry_also_drains_polling_connections():\n     \"\"\"_handle_polling_conflict must also drain the polling pool on retry.\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}