From 723b3b11cf250ad18d70ba845abc6c4e2df2db20 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Tue, 29 Sep 2026 14:34:26 +0000 Subject: [PATCH] Upgrade kaya to 0.0.5 kaya 0.0.5 surfaces a vanished websocket peer as kaya.core.WebSocketDisconnect instead of leaking granian's RSGIProtocolClosed: receive() reports a close message so iteration ends normally, sends raise the new exception, and KayaApp.handle_websocket swallows it as normal termination. Adopt it: drop the granian-specific workaround from the platform websocket handler, catch WebSocketDisconnect in the send helper, and model the new semantics in the abrupt-disconnect test (with coverage that a send on a vanished peer is ignored). Regenerate the lock: the eight kaya pins move to 0.0.5 (declared metadata is unchanged) and pip-compile also bumps PyJWT to 2.15.1, a security release with no breaking changes for kaya-oidc's jwt.decode usage. tavolo-platform now floors kaya-core>=0.0.5 (it imports WebSocketDisconnect) and tavolo-app floors kaya-otel>=0.0.5. --- server/packages/tavolo-app/pyproject.toml | 2 +- .../packages/tavolo-platform/pyproject.toml | 2 +- .../tavolo-platform/src/tavolo/platform/ws.py | 10 ++- .../packages/tavolo-platform/tests/test_ws.py | 88 +++++++++++++++++-- server/requirements.txt | 18 ++-- 5 files changed, 101 insertions(+), 19 deletions(-) diff --git a/server/packages/tavolo-app/pyproject.toml b/server/packages/tavolo-app/pyproject.toml index 9febbc4..d093db8 100644 --- a/server/packages/tavolo-app/pyproject.toml +++ b/server/packages/tavolo-app/pyproject.toml @@ -34,7 +34,7 @@ dev = [ "httpx-ws", ] otel = [ - "kaya-otel>=0.0.4", + "kaya-otel>=0.0.5", "opentelemetry-instrumentation-asyncpg>=0.66b0", "opentelemetry-instrumentation-redis>=0.66b0", "opentelemetry-instrumentation-system-metrics>=0.66b0", diff --git a/server/packages/tavolo-platform/pyproject.toml b/server/packages/tavolo-platform/pyproject.toml index f600060..1d6fd56 100644 --- a/server/packages/tavolo-platform/pyproject.toml +++ b/server/packages/tavolo-platform/pyproject.toml @@ -9,7 +9,7 @@ description = "Game-independent multiplayer game platform (lobby, live play, mat readme = "README.md" requires-python = ">=3.10" dependencies = [ - "kaya-core", + "kaya-core>=0.0.5", "kaya-session", "kaya-oidc", "kaya-openapi", diff --git a/server/packages/tavolo-platform/src/tavolo/platform/ws.py b/server/packages/tavolo-platform/src/tavolo/platform/ws.py index 0b0f5ff..6841ad0 100644 --- a/server/packages/tavolo-platform/src/tavolo/platform/ws.py +++ b/server/packages/tavolo-platform/src/tavolo/platform/ws.py @@ -50,7 +50,7 @@ from contextlib import suppress from logging import getLogger from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict -from kaya.core import KayaApp, WebSocket +from kaya.core import KayaApp, WebSocket, WebSocketDisconnect from . import auth from .engine import GameSession @@ -124,7 +124,13 @@ def register(app: KayaApp, platform: "Platform") -> None: async def send(payload: Dict[str, Any]) -> None: async with send_lock: - await ws.send_text(json.dumps(payload)) + try: + await ws.send_text(json.dumps(payload)) + except WebSocketDisconnect: + # The peer went away without a close handshake: kaya + # raises this from the transport, and the receive loop + # ends momentarily. + pass await send(_state_message(platform, session, user.sub)) # Backstop: make sure the current deadline is queued even if its diff --git a/server/packages/tavolo-platform/tests/test_ws.py b/server/packages/tavolo-platform/tests/test_ws.py index 7006d13..c6f8c65 100644 --- a/server/packages/tavolo-platform/tests/test_ws.py +++ b/server/packages/tavolo-platform/tests/test_ws.py @@ -1,18 +1,62 @@ """WebSocket live-play tests via kaya's ASGI websocket transport.""" from __future__ import annotations +import json import unittest -from typing import Any, Mapping, Sequence +from typing import Any, Dict, Mapping, Sequence from httpx import ASGITransport, AsyncClient -from httpx_ws import WebSocketDisconnect, aconnect_ws +from httpx_ws import WebSocketDisconnect as HttpxWebSocketDisconnect, aconnect_ws from httpx_ws.transport import ASGIWebSocketTransport -from kaya.core import WebSocket, WebSocketMessage +from kaya.core import WebSocket, WebSocketDisconnect, WebSocketMessage from helpers import async_test, make_platform, make_user, oidc_user, ws_users from tavolo.platform import GameSession, Seat +class AbruptCloseSocket(WebSocket): + """A socket whose peer vanished without a close handshake. + + kaya surfaces this from the transport: ``receive()`` reports a + ``close`` message so iteration ends normally, while sends raise + ``WebSocketDisconnect``. + """ + + def __init__(self, path: str, gone: bool = False) -> None: + self.path = path + self.query_string = "" + self.scheme = "ws" + self.headers: Mapping[str, Sequence[str]] = {} + self.client = ("127.0.0.1", 12345) + self.server = ("127.0.0.1", 80) + self.sent: list[Dict[str, Any]] = [] + self.close_codes: list[int] = [] + self.gone = gone + + async def accept(self, headers: Any = None) -> None: + pass + + async def receive(self) -> WebSocketMessage: + self.gone = True + return WebSocketMessage(kind="close") + + async def send_text(self, data: str) -> None: + if self.gone: + raise WebSocketDisconnect(code=1006) + self.sent.append(json.loads(data)) + + async def send_bytes(self, data: bytes) -> None: + if self.gone: + raise WebSocketDisconnect(code=1006) + + async def close(self, code: int = 1000) -> None: + self.close_codes.append(code) + + async def __anext__(self) -> WebSocketMessage: + await self.receive() + raise StopAsyncIteration + + class RecordingSocket(WebSocket): """A socket that only records pre-accept rejections.""" @@ -121,6 +165,38 @@ class WebSocketTest(unittest.TestCase): resent = await bob_ws.receive_json() self.assertEqual("state", resent["type"]) + @async_test + async def test_abrupt_disconnect_is_not_an_error(self) -> None: + app, platform, _ = make_platform() + api_transport = ASGITransport(app=app) + async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: + state = await self._started_game(client, platform.oidc) + game_id = state["id"] + + ws = AbruptCloseSocket(f"/ws/games/{game_id}") + with ws_users([make_user("bob")]): + # kaya reports a peer that vanished without a close handshake as + # a close message; the handler must exit cleanly. + await app.handle_websocket(ws) + self.assertEqual("state", ws.sent[0]["type"]) + self.assertEqual([], ws.close_codes) + + @async_test + async def test_send_on_vanished_peer_is_ignored(self) -> None: + app, platform, _ = make_platform() + api_transport = ASGITransport(app=app) + async with AsyncClient(transport=api_transport, base_url="http://127.0.0.1") as client: + state = await self._started_game(client, platform.oidc) + game_id = state["id"] + + ws = AbruptCloseSocket(f"/ws/games/{game_id}", gone=True) + with ws_users([make_user("bob")]): + # Sending raises WebSocketDisconnect; the handler swallows it + # rather than letting it escape the forward task. + await app.handle_websocket(ws) + self.assertEqual([], ws.sent) + self.assertEqual([], ws.close_codes) + @async_test async def test_game_over_broadcast(self) -> None: app, platform, _ = make_platform() @@ -155,7 +231,7 @@ class WebSocketTest(unittest.TestCase): ws_transport = ASGIWebSocketTransport(app=app) async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: with ws_users([make_user("alice")]): - with self.assertRaises(WebSocketDisconnect) as caught: + with self.assertRaises(HttpxWebSocketDisconnect) as caught: async with aconnect_ws("/ws/games/no-such-game", ws_client): pass self.assertEqual(404, caught.exception.code) @@ -171,7 +247,7 @@ class WebSocketTest(unittest.TestCase): ws_transport = ASGIWebSocketTransport(app=app) async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: with ws_users([make_user("mallory")]): - with self.assertRaises(WebSocketDisconnect) as caught: + with self.assertRaises(HttpxWebSocketDisconnect) as caught: async with aconnect_ws(f"/ws/games/{game_id}", ws_client): pass self.assertEqual(403, caught.exception.code) @@ -182,7 +258,7 @@ class WebSocketTest(unittest.TestCase): ws_transport = ASGIWebSocketTransport(app=app) async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: with ws_users([]): - with self.assertRaises(WebSocketDisconnect) as caught: + with self.assertRaises(HttpxWebSocketDisconnect) as caught: async with aconnect_ws("/ws/games/whatever", ws_client): pass self.assertEqual(401, caught.exception.code) diff --git a/server/requirements.txt b/server/requirements.txt index 4f0de87..55dc21a 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -63,7 +63,7 @@ idna==3.20 # httpx iso8601==2.1.0 # via tortoise-orm -kaya-core==0.0.4 +kaya-core==0.0.5 # via # kaya-cors # kaya-oidc @@ -72,22 +72,22 @@ kaya-core==0.0.4 # kaya-rsgi # kaya-session # tavolo-app (pyproject.toml) -kaya-cors==0.0.4 +kaya-cors==0.0.5 # via tavolo-app (pyproject.toml) -kaya-oidc==0.0.4 +kaya-oidc==0.0.5 # via tavolo-app (pyproject.toml) -kaya-openapi==0.0.4 +kaya-openapi==0.0.5 # via tavolo-app (pyproject.toml) -kaya-otel==0.0.4 +kaya-otel==0.0.5 # via tavolo-app (pyproject.toml) -kaya-rsgi==0.0.4 +kaya-rsgi==0.0.5 # via tavolo-app (pyproject.toml) -kaya-session==0.0.4 +kaya-session==0.0.5 # via # kaya-oidc # kaya-session-redis # tavolo-app (pyproject.toml) -kaya-session-redis==0.0.4 +kaya-session-redis==0.0.5 # via tavolo-app (pyproject.toml) opentelemetry-api==1.45.0 # via @@ -150,7 +150,7 @@ pwo==0.1.2 # tavolo-app (pyproject.toml) pycparser==3.0 # via cffi -pyjwt[crypto]==2.14.0 +pyjwt[crypto]==2.15.1 # via # kaya-oidc # tavolo-app (pyproject.toml)