diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 6504d1ab..9eff648e 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -252,6 +252,10 @@ class ChannelManager: try: await channel.stop() logger.info("Stopped {} channel", name) + except asyncio.CancelledError: + if asyncio.current_task() and asyncio.current_task().cancelling(): + raise + logger.debug("Channel {} stop task was already cancelled", name) except Exception: logger.exception("Error stopping {}", name) diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 3e5b6783..1fcf7aa1 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -827,6 +827,10 @@ class WebSocketChannel(BaseChannel): if self._server_task: try: await self._server_task + except asyncio.CancelledError: + if asyncio.current_task() and asyncio.current_task().cancelling(): + raise + self.logger.debug("server task was already cancelled during shutdown") except Exception as e: self.logger.warning("server task error during shutdown: {}", e) self._server_task = None diff --git a/tests/channels/test_channel_plugins.py b/tests/channels/test_channel_plugins.py index f881cebb..7fb4fad1 100644 --- a/tests/channels/test_channel_plugins.py +++ b/tests/channels/test_channel_plugins.py @@ -1222,6 +1222,43 @@ async def test_stop_all_handles_channel_exception(): assert mgr._dispatch_task is None +@pytest.mark.asyncio +async def test_stop_all_handles_channel_stop_cancelled_task(): + """stop_all should treat a channel's already-cancelled internals as stopped.""" + + class _StopCancelledChannel(BaseChannel): + name = "stopcancelled" + display_name = "Stop Cancelled" + + async def start(self) -> None: + pass + + async def stop(self) -> None: + raise asyncio.CancelledError("server task cancelled") + + async def send(self, msg: OutboundMessage) -> None: + pass + + fake_config = SimpleNamespace( + channels=ChannelsConfig(), + providers=SimpleNamespace(groq=SimpleNamespace(api_key="")), + ) + + mgr = ChannelManager.__new__(ChannelManager) + mgr.config = fake_config + mgr.bus = MessageBus() + next_channel = _StartableChannel(fake_config, mgr.bus) + mgr.channels = { + "stopcancelled": _StopCancelledChannel(fake_config, mgr.bus), + "next": next_channel, + } + mgr._dispatch_task = None + + await mgr.stop_all() + + assert next_channel.stopped is True + + @pytest.mark.asyncio async def test_start_all_no_channels_logs_warning(): """start_all should log warning when no channels are enabled.""" diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index c69561ea..9ca0a334 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -96,6 +96,27 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices: ) +@pytest.mark.asyncio +async def test_stop_treats_cancelled_server_task_as_shutdown() -> None: + channel = _ch(MessageBus()) + channel._running = True + channel._stop_event = asyncio.Event() + + async def _server_task() -> None: + await asyncio.Event().wait() + + task = asyncio.create_task(_server_task()) + await asyncio.sleep(0) + task.cancel() + await asyncio.sleep(0) + channel._server_task = task + + await channel.stop() + + assert channel._server_task is None + assert task.cancelled() + + @pytest.fixture() def bus() -> MagicMock: b = MagicMock()