Upgrade kaya to 0.0.5
CI / Build and push docker image (push) Successful in 1m20s

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.
This commit is contained in:
2026-09-30 06:38:02 +08:00
parent ed850aa73c
commit 723b3b11cf
5 changed files with 101 additions and 19 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ dev = [
"httpx-ws", "httpx-ws",
] ]
otel = [ otel = [
"kaya-otel>=0.0.4", "kaya-otel>=0.0.5",
"opentelemetry-instrumentation-asyncpg>=0.66b0", "opentelemetry-instrumentation-asyncpg>=0.66b0",
"opentelemetry-instrumentation-redis>=0.66b0", "opentelemetry-instrumentation-redis>=0.66b0",
"opentelemetry-instrumentation-system-metrics>=0.66b0", "opentelemetry-instrumentation-system-metrics>=0.66b0",
@@ -9,7 +9,7 @@ description = "Game-independent multiplayer game platform (lobby, live play, mat
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"kaya-core", "kaya-core>=0.0.5",
"kaya-session", "kaya-session",
"kaya-oidc", "kaya-oidc",
"kaya-openapi", "kaya-openapi",
@@ -50,7 +50,7 @@ from contextlib import suppress
from logging import getLogger from logging import getLogger
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict 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 . import auth
from .engine import GameSession from .engine import GameSession
@@ -124,7 +124,13 @@ def register(app: KayaApp, platform: "Platform") -> None:
async def send(payload: Dict[str, Any]) -> None: async def send(payload: Dict[str, Any]) -> None:
async with send_lock: async with send_lock:
try:
await ws.send_text(json.dumps(payload)) 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)) await send(_state_message(platform, session, user.sub))
# Backstop: make sure the current deadline is queued even if its # Backstop: make sure the current deadline is queued even if its
@@ -1,18 +1,62 @@
"""WebSocket live-play tests via kaya's ASGI websocket transport.""" """WebSocket live-play tests via kaya's ASGI websocket transport."""
from __future__ import annotations from __future__ import annotations
import json
import unittest import unittest
from typing import Any, Mapping, Sequence from typing import Any, Dict, Mapping, Sequence
from httpx import ASGITransport, AsyncClient 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 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 helpers import async_test, make_platform, make_user, oidc_user, ws_users
from tavolo.platform import GameSession, Seat 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): class RecordingSocket(WebSocket):
"""A socket that only records pre-accept rejections.""" """A socket that only records pre-accept rejections."""
@@ -121,6 +165,38 @@ class WebSocketTest(unittest.TestCase):
resent = await bob_ws.receive_json() resent = await bob_ws.receive_json()
self.assertEqual("state", resent["type"]) 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_test
async def test_game_over_broadcast(self) -> None: async def test_game_over_broadcast(self) -> None:
app, platform, _ = make_platform() app, platform, _ = make_platform()
@@ -155,7 +231,7 @@ class WebSocketTest(unittest.TestCase):
ws_transport = ASGIWebSocketTransport(app=app) ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("alice")]): 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): async with aconnect_ws("/ws/games/no-such-game", ws_client):
pass pass
self.assertEqual(404, caught.exception.code) self.assertEqual(404, caught.exception.code)
@@ -171,7 +247,7 @@ class WebSocketTest(unittest.TestCase):
ws_transport = ASGIWebSocketTransport(app=app) ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([make_user("mallory")]): 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): async with aconnect_ws(f"/ws/games/{game_id}", ws_client):
pass pass
self.assertEqual(403, caught.exception.code) self.assertEqual(403, caught.exception.code)
@@ -182,7 +258,7 @@ class WebSocketTest(unittest.TestCase):
ws_transport = ASGIWebSocketTransport(app=app) ws_transport = ASGIWebSocketTransport(app=app)
async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client: async with AsyncClient(transport=ws_transport, base_url="http://testserver") as ws_client:
with ws_users([]): with ws_users([]):
with self.assertRaises(WebSocketDisconnect) as caught: with self.assertRaises(HttpxWebSocketDisconnect) as caught:
async with aconnect_ws("/ws/games/whatever", ws_client): async with aconnect_ws("/ws/games/whatever", ws_client):
pass pass
self.assertEqual(401, caught.exception.code) self.assertEqual(401, caught.exception.code)
+9 -9
View File
@@ -63,7 +63,7 @@ idna==3.20
# httpx # httpx
iso8601==2.1.0 iso8601==2.1.0
# via tortoise-orm # via tortoise-orm
kaya-core==0.0.4 kaya-core==0.0.5
# via # via
# kaya-cors # kaya-cors
# kaya-oidc # kaya-oidc
@@ -72,22 +72,22 @@ kaya-core==0.0.4
# kaya-rsgi # kaya-rsgi
# kaya-session # kaya-session
# tavolo-app (pyproject.toml) # tavolo-app (pyproject.toml)
kaya-cors==0.0.4 kaya-cors==0.0.5
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-oidc==0.0.4 kaya-oidc==0.0.5
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-openapi==0.0.4 kaya-openapi==0.0.5
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-otel==0.0.4 kaya-otel==0.0.5
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-rsgi==0.0.4 kaya-rsgi==0.0.5
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
kaya-session==0.0.4 kaya-session==0.0.5
# via # via
# kaya-oidc # kaya-oidc
# kaya-session-redis # kaya-session-redis
# tavolo-app (pyproject.toml) # tavolo-app (pyproject.toml)
kaya-session-redis==0.0.4 kaya-session-redis==0.0.5
# via tavolo-app (pyproject.toml) # via tavolo-app (pyproject.toml)
opentelemetry-api==1.45.0 opentelemetry-api==1.45.0
# via # via
@@ -150,7 +150,7 @@ pwo==0.1.2
# tavolo-app (pyproject.toml) # tavolo-app (pyproject.toml)
pycparser==3.0 pycparser==3.0
# via cffi # via cffi
pyjwt[crypto]==2.14.0 pyjwt[crypto]==2.15.1
# via # via
# kaya-oidc # kaya-oidc
# tavolo-app (pyproject.toml) # tavolo-app (pyproject.toml)