From fe4748ede88da3143c08657233c6242125fe5fcf Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 7 May 2026 05:29:20 -0700 Subject: [PATCH] test(kanban): regression for CancelledError swallow in stream_events Drives stream_events directly and cancels the task while it is sleeping in the poll loop, asserting the coroutine returns cleanly instead of letting CancelledError bubble. Regression coverage for the Uvicorn application traceback on dashboard Ctrl-C fixed by the preceding commit. --- tests/plugins/test_kanban_dashboard_plugin.py | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index b266f0914e..fae035b266 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -553,6 +553,67 @@ def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch): assert ws is not None # handshake succeeded +def test_ws_events_swallows_cancellation_on_shutdown(tmp_path, monkeypatch): + """``asyncio.CancelledError`` while sleeping in the poll loop is the + normal uvicorn-shutdown path (``BaseException``, so the bare + ``except Exception:`` does NOT catch it). Without the explicit + clause the cancellation surfaces as an application traceback. + + Regression test for #20790 (fix in #20938). Drives the coroutine + directly (rather than through FastAPI TestClient) so we can observe + the cancellation outcome deterministically. + """ + import asyncio + import types + import sys as _sys + + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(Path, "home", lambda: tmp_path) + kb.init_db() + + # Short-circuit the token check — this test is about the cancellation + # path, not auth. + import plugins.kanban.dashboard.plugin_api as pa + monkeypatch.setattr(pa, "_check_ws_token", lambda t: True) + + class _FakeWS: + def __init__(self): + self.query_params = {"token": "x", "since": "0"} + self.accepted = False + self.closed = False + + async def accept(self): + self.accepted = True + + async def send_json(self, data): + pass + + async def close(self, code=None): + self.closed = True + + async def _run(): + ws = _FakeWS() + task = asyncio.create_task(pa.stream_events(ws)) + # Give the handler a tick to accept + start polling. + await asyncio.sleep(0.05) + assert ws.accepted is True + task.cancel() + # stream_events should swallow CancelledError and return cleanly. + # If it doesn't, this await re-raises the CancelledError. + result = await task + return result, ws + + result, ws = asyncio.run(_run()) + assert result is None, ( + f"stream_events should return cleanly after cancellation, got {result!r}" + ) + # The bug symptom was a traceback; we don't assert on stderr because + # capturing asyncio's internal "exception was never retrieved" logging + # is flaky. The assertion that matters is: no CancelledError escaped. + + # --------------------------------------------------------------------------- # Bulk actions # ---------------------------------------------------------------------------