Author SHA1 Message Date
woggioni 031d933204 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.
2026-09-19 03:20:24 +00:00
10 changed files with 57 additions and 14 deletions
+16
View File
@@ -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:
+2 -1
View File
@@ -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"]
+32
View File
@@ -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
+1 -1
View File
@@ -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"
+1 -2
View File
@@ -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")
+1 -2
View File
@@ -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):
+1 -2
View File
@@ -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):
+1 -2
View File
@@ -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():
+1 -2
View File
@@ -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):
+1 -2
View File
@@ -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")