From 031d933204e0c505bc02ababd97178ceaf990d95 Mon Sep 17 00:00:00 2001 From: Walter Oggioni Date: Sat, 19 Sep 2026 03:20:24 +0000 Subject: [PATCH] Fix hanging test suite by closing per-loop Tortoise contexts pwo.async_test runs every test in a fresh event loop and TortoiseMixin builds one TortoiseContext per loop. Dropping the previous context when the loop changed orphaned its aiosqlite connections; their non-daemon worker threads then blocked threading._shutdown forever, so the suite printed OK but the interpreter never exited. Add TortoiseMixin.aclose() and a tests.helpers.async_test wrapper that closes the context in a finally on the test's own loop, and switch the test modules over to it. --- server/src/tavolo/tortoise_mixin.py | 16 +++++++++++++++ server/tests/helpers/__init__.py | 3 ++- server/tests/helpers/asynctest.py | 32 +++++++++++++++++++++++++++++ server/tests/test_cors.py | 2 +- server/tests/test_deadlines.py | 3 +-- server/tests/test_routes_games.py | 3 +-- server/tests/test_routes_me.py | 3 +-- server/tests/test_stats.py | 3 +-- server/tests/test_store.py | 3 +-- server/tests/test_websocket.py | 3 +-- 10 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 server/tests/helpers/asynctest.py diff --git a/server/src/tavolo/tortoise_mixin.py b/server/src/tavolo/tortoise_mixin.py index f292551..4650c5f 100644 --- a/server/src/tavolo/tortoise_mixin.py +++ b/server/src/tavolo/tortoise_mixin.py @@ -83,6 +83,22 @@ class TortoiseMixin(KayaMixin): self._ctx = None self._init_loop = None + async def aclose(self) -> None: + """Close the current context's connections and forget it. + + Must be called from the event loop that owns the context. When a + loop goes away without this, its aiosqlite connections are orphaned; + their non-daemon worker threads then block interpreter shutdown + forever. The test suite calls this at the end of every test because + each test runs in a fresh event loop. + """ + ctx = self._ctx + self._ctx = None + self._init_loop = None + if ctx is not None: + log.info("closing database connections") + await ctx.close_connections() + async def _build_context(self) -> TortoiseContext: ctx = TortoiseContext() with ctx: diff --git a/server/tests/helpers/__init__.py b/server/tests/helpers/__init__.py index 8f5d941..dbf734d 100644 --- a/server/tests/helpers/__init__.py +++ b/server/tests/helpers/__init__.py @@ -1,4 +1,5 @@ """Test helpers package.""" +from .asynctest import async_test from .oidc import make_user, oidc_user, ws_users -__all__ = ["make_user", "oidc_user", "ws_users"] +__all__ = ["async_test", "make_user", "oidc_user", "ws_users"] diff --git a/server/tests/helpers/asynctest.py b/server/tests/helpers/asynctest.py new file mode 100644 index 0000000..2b16422 --- /dev/null +++ b/server/tests/helpers/asynctest.py @@ -0,0 +1,32 @@ +"""An ``async_test`` that also closes the app's Tortoise context. + +``pwo.async_test`` runs every test in a fresh event loop (``asyncio.Runner``). +The app's :class:`~tavolo.tortoise_mixin.TortoiseMixin` builds one +``TortoiseContext`` per loop, so without an explicit close each test orphans +an aiosqlite connection whose non-daemon worker thread keeps the interpreter +alive after the suite reports "OK". +""" +from __future__ import annotations + +import asyncio +from functools import wraps +from typing import Any, Callable, Coroutine + +from tavolo.app import tortoise_mixin + + +def async_test(coro: Callable[..., Coroutine[Any, Any, None]]) -> Callable[..., None]: + """Like ``pwo.async_test``, but close the Tortoise context afterwards.""" + + @wraps(coro) + def wrapper(*args: Any, **kwargs: Any) -> None: + async def run() -> None: + try: + await coro(*args, **kwargs) + finally: + await tortoise_mixin.aclose() + + with asyncio.Runner() as runner: + runner.run(run()) + + return wrapper diff --git a/server/tests/test_cors.py b/server/tests/test_cors.py index c5618f2..d8f1781 100644 --- a/server/tests/test_cors.py +++ b/server/tests/test_cors.py @@ -14,10 +14,10 @@ from unittest.mock import patch from httpx import ASGITransport, AsyncClient from kaya.core import HttpContext, KayaApp -from pwo import async_test from tavolo.app import cors_mixin_from_settings from tavolo.config import Settings +from tests.helpers import async_test ORIGIN = "https://cards.example" diff --git a/server/tests/test_deadlines.py b/server/tests/test_deadlines.py index ad61e4c..82cd337 100644 --- a/server/tests/test_deadlines.py +++ b/server/tests/test_deadlines.py @@ -11,12 +11,11 @@ import unittest from datetime import datetime, timedelta, timezone from typing import Optional -from pwo import async_test - from tavolo import deadlines from tavolo.app import game_store from tavolo.game import engine from tavolo.game.state import GameState, PlayerState +from tests.helpers import async_test PLAYERS = ("alice", "bob", "carol", "dave") diff --git a/server/tests/test_routes_games.py b/server/tests/test_routes_games.py index 4e33724..995d65d 100644 --- a/server/tests/test_routes_games.py +++ b/server/tests/test_routes_games.py @@ -4,10 +4,9 @@ from __future__ import annotations import unittest from httpx import ASGITransport, AsyncClient -from pwo import async_test from tavolo.app import app -from tests.helpers import oidc_user +from tests.helpers import async_test, oidc_user class GamesRouteTest(unittest.TestCase): diff --git a/server/tests/test_routes_me.py b/server/tests/test_routes_me.py index f590169..0dd808b 100644 --- a/server/tests/test_routes_me.py +++ b/server/tests/test_routes_me.py @@ -8,11 +8,10 @@ from pathlib import Path from unittest import mock from httpx import ASGITransport, AsyncClient -from pwo import async_test from tavolo.app import app from tavolo.config import settings -from tests.helpers import oidc_user +from tests.helpers import async_test, oidc_user class MeRouteTest(unittest.TestCase): diff --git a/server/tests/test_stats.py b/server/tests/test_stats.py index 95ebfb1..45a8de4 100644 --- a/server/tests/test_stats.py +++ b/server/tests/test_stats.py @@ -6,7 +6,6 @@ import uuid from datetime import datetime, timezone from httpx import ASGITransport, AsyncClient -from pwo import async_test from tavolo.app import app, tortoise_mixin from tavolo.elo import INITIAL_RATING @@ -14,7 +13,7 @@ from tavolo.game import engine from tavolo.game.state import GameState from tavolo.models import Match, MatchPlayer, PlayerRating from tavolo.stats import save_match_result -from tests.helpers import oidc_user +from tests.helpers import async_test, oidc_user async def _use_app_db(): diff --git a/server/tests/test_store.py b/server/tests/test_store.py index 35c050e..22d8e32 100644 --- a/server/tests/test_store.py +++ b/server/tests/test_store.py @@ -4,10 +4,9 @@ from __future__ import annotations import asyncio import unittest -from pwo import async_test - from tavolo.game import engine from tavolo.store import InMemoryGameStore +from tests.helpers import async_test class InMemoryGameStoreTest(unittest.TestCase): diff --git a/server/tests/test_websocket.py b/server/tests/test_websocket.py index cbd9819..cb9e758 100644 --- a/server/tests/test_websocket.py +++ b/server/tests/test_websocket.py @@ -7,12 +7,11 @@ import unittest from httpx import ASGITransport, AsyncClient from httpx_ws import WebSocketDisconnect, aconnect_ws from httpx_ws.transport import ASGIWebSocketTransport -from pwo import async_test from tavolo.app import app, game_store from tavolo.game import engine from tavolo.game.state import Card, GameState, PlayerState -from tests.helpers import make_user, oidc_user, ws_users +from tests.helpers import async_test, make_user, oidc_user, ws_users PLAYERS = ("alice", "bob", "carol", "dave")