From d4e0294734afc631cf1caff89ff992d46af4b941 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 15 Jul 2026 21:44:09 +0800 Subject: [PATCH] fix(gateway): stop channels before draining tasks --- nanobot/cli/commands.py | 4 +++- tests/cli/test_commands.py | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index ae0714bf..5d4dc4f9 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -2119,6 +2119,9 @@ def _run_gateway( await shutdown_task cron.stop() agent.stop() + # Some SDKs swallow task cancellation while attempting to reconnect. + # Close channel transports before waiting for their runners to exit. + await channels.stop_all() for task in tasks: if not task.done(): task.cancel() @@ -2127,7 +2130,6 @@ def _run_gateway( if runtime_tasks is not None and not runtime_tasks_drained: with suppress(asyncio.CancelledError, Exception): await runtime_tasks - await channels.stop_all() # Flush all cached sessions to durable storage before exit. # This prevents data loss on filesystems with write-back # caching (rclone VFS, NFS, FUSE mounts, etc.). diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index 0808dd93..3497df73 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -3235,6 +3235,7 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks( config = Config() config.gateway.port = 18791 seen: dict[str, object] = {} + shutdown_order: list[str] = [] class _FakeSessionManager: def flush_all(self) -> int: @@ -3274,9 +3275,11 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks( await asyncio.Event().wait() finally: seen["channel_task_cleaned_up"] = True + shutdown_order.append("channel_task_cleaned_up") async def stop_all(self) -> None: seen["channels_stopped"] = True + shutdown_order.append("channels_stopped") class _FakeCronService: def __init__(self, _store_path: Path) -> None: @@ -3343,6 +3346,9 @@ def test_gateway_shutdown_event_exits_forever_runtime_tasks( assert seen["channels_stopped"] is True assert seen["cron_stopped"] is True assert seen["shutdown_handlers_restored"] is True + # Channel cleanup must run before cancellation drains the manager task. + # DingTalk's stream SDK can otherwise swallow cancellation and reconnect. + assert shutdown_order == ["channels_stopped", "channel_task_cleaned_up"] def test_serve_uses_api_config_defaults_and_workspace_override(