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:
@@ -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:
|
||||||
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))
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user