Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions plugins/kanban/dashboard/plugin_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1521,6 +1521,13 @@ def _fetch_new(cursor_val: int) -> tuple[int, list[dict]]:
await asyncio.sleep(_EVENT_POLL_SECONDS)
except WebSocketDisconnect:
return
except asyncio.CancelledError:
# Normal shutdown path: dashboard process exit (Ctrl-C) cancels the
# websocket task while it is sleeping in the poll loop.
# CancelledError is a BaseException in 3.8+ so the bare Exception
# handler below would not catch it; without this clause Uvicorn
# surfaces the cancellation as an application traceback. Quiet it.
return
except Exception as exc: # defensive: never crash the dashboard worker
log.warning("Kanban event stream error: %s", exc)
try:
Expand Down
2 changes: 2 additions & 0 deletions scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"51599529+stephen0110@users.noreply.github.com": "stephen0110",
"265632032+sonic-netizen@users.noreply.github.com": "sonic-netizen",
"82531659+mwnickerson@users.noreply.github.com": "mwnickerson",
"sandrohub013@gmail.com": "SandroHub013",
"154585401+LeonSGP43@users.noreply.github.com": "LeonSGP43",
"zjtan1@gmail.com": "zeejaytan",
"asslaenn5@gmail.com": "Aslaaen",
Expand Down Expand Up @@ -455,6 +456,7 @@
"51599529+stephen0110@users.noreply.github.com": "stephen0110",
"265632032+sonic-netizen@users.noreply.github.com": "sonic-netizen",
"82531659+mwnickerson@users.noreply.github.com": "mwnickerson",
"sandrohub013@gmail.com": "SandroHub013",
"h3057183414@gmail.com": "CoreyNoDream",
"franksong2702@gmail.com": "franksong2702",
"673088860@qq.com": "ambition0802",
Expand Down
61 changes: 61 additions & 0 deletions tests/plugins/test_kanban_dashboard_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading