|
|
@@ -0,0 +1,178 @@
|
|
|
+"""A read cancelled by a leaving viewer must not hang its connection (#3515).
|
|
|
+
|
|
|
+A camera viewer leaving cancels its stream with AnyIO's level cancellation. A
|
|
|
+read cancelled halfway makes SQLAlchemy discard the connection; when that close
|
|
|
+is cancelled too, it calls aiosqlite's ``stop()`` while the close still runs.
|
|
|
+aiosqlite 0.22.1 then never finished the close (omnilib/aiosqlite#403), the
|
|
|
+session's exit waited on it forever, and the cancelled wait spun a CPU core.
|
|
|
+"""
|
|
|
+
|
|
|
+import asyncio
|
|
|
+import os
|
|
|
+import subprocess
|
|
|
+import sys
|
|
|
+import textwrap
|
|
|
+import threading
|
|
|
+from contextlib import asynccontextmanager
|
|
|
+from pathlib import Path
|
|
|
+from unittest.mock import AsyncMock, patch
|
|
|
+
|
|
|
+import aiosqlite
|
|
|
+import anyio
|
|
|
+import pytest
|
|
|
+
|
|
|
+from backend.app.core import auth, database # noqa: F401 - database applies the aiosqlite fix
|
|
|
+from backend.app.services import camera_light
|
|
|
+
|
|
|
+pytestmark = pytest.mark.unit
|
|
|
+
|
|
|
+
|
|
|
+class TestAiosqliteStop:
|
|
|
+ async def test_a_stop_during_a_close_lets_the_close_finish(self):
|
|
|
+ # omnilib/aiosqlite#403's reproduction: the worker exits at the stop
|
|
|
+ # requested from outside, before the close's own stop is served.
|
|
|
+ gate = threading.Event()
|
|
|
+ conn = await aiosqlite.connect(":memory:")
|
|
|
+ await conn.create_function("hold", 0, lambda: gate.wait() and 1)
|
|
|
+ busy = asyncio.create_task(conn.execute("SELECT hold()"))
|
|
|
+ await asyncio.sleep(0.05)
|
|
|
+
|
|
|
+ closing = asyncio.create_task(conn.close())
|
|
|
+ await asyncio.sleep(0.05)
|
|
|
+ conn.stop()
|
|
|
+ gate.set()
|
|
|
+ await busy
|
|
|
+
|
|
|
+ await asyncio.wait_for(closing, timeout=5)
|
|
|
+
|
|
|
+ async def test_a_second_stop_returns_the_first_ones_future(self):
|
|
|
+ conn = await aiosqlite.connect(":memory:")
|
|
|
+ first = conn.stop()
|
|
|
+
|
|
|
+ assert conn.stop() is first
|
|
|
+ await asyncio.wait_for(first, timeout=5)
|
|
|
+
|
|
|
+
|
|
|
+# Many short reads, cancelled at once by an AnyIO task group as a leaving viewer
|
|
|
+# cancels its stream, against the engine Bambuddy creates. Run in a process of
|
|
|
+# its own: a hung close can't be cancelled, so the test would hang with it.
|
|
|
+_BURST = textwrap.dedent(
|
|
|
+ """
|
|
|
+ import asyncio, sys
|
|
|
+
|
|
|
+ import anyio
|
|
|
+ from sqlalchemy import text
|
|
|
+ from sqlalchemy.ext.asyncio import async_sessionmaker
|
|
|
+
|
|
|
+ from backend.app.core.database import _create_engine
|
|
|
+
|
|
|
+
|
|
|
+ async def main(url):
|
|
|
+ engine = _create_engine(url)
|
|
|
+ sessions = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
+ async with engine.begin() as conn:
|
|
|
+ await conn.execute(text("create table settings (key text primary key, value text)"))
|
|
|
+ await conn.execute(text("insert into settings values ('camera_light_mode', 'off')"))
|
|
|
+
|
|
|
+ async def read():
|
|
|
+ try:
|
|
|
+ async with sessions() as session:
|
|
|
+ await session.execute(text("select value from settings"))
|
|
|
+ await session.execute(text("select value from settings"))
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
+ for i in range(40):
|
|
|
+ async with anyio.create_task_group() as group:
|
|
|
+ for _ in range(8):
|
|
|
+ group.start_soon(read)
|
|
|
+ await asyncio.sleep((i % 10) * 0.0002)
|
|
|
+ group.cancel_scope.cancel()
|
|
|
+ await engine.dispose()
|
|
|
+ await asyncio.sleep(1)
|
|
|
+ left = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
|
|
+ print("pending", len(left))
|
|
|
+
|
|
|
+
|
|
|
+ anyio.run(main, sys.argv[1], backend="asyncio")
|
|
|
+ """
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+def test_cancelled_reads_leave_no_connection_behind(tmp_path):
|
|
|
+ url = f"sqlite+aiosqlite:///{tmp_path / 'burst.db'}"
|
|
|
+ # Its own DATABASE_URL: never the repo's .env.
|
|
|
+ env = {**os.environ, "DATABASE_URL": url}
|
|
|
+ try:
|
|
|
+ result = subprocess.run(
|
|
|
+ [sys.executable, "-c", _BURST, url],
|
|
|
+ cwd=Path(__file__).resolve().parents[3],
|
|
|
+ env=env,
|
|
|
+ capture_output=True,
|
|
|
+ text=True,
|
|
|
+ timeout=60,
|
|
|
+ )
|
|
|
+ except subprocess.TimeoutExpired:
|
|
|
+ pytest.fail("a cancelled read left its connection's close hanging")
|
|
|
+
|
|
|
+ assert result.returncode == 0, result.stderr[-2000:]
|
|
|
+ assert result.stdout.strip().splitlines()[-1] == "pending 0"
|
|
|
+
|
|
|
+
|
|
|
+class _Session:
|
|
|
+ """A session whose every step yields to the loop, so a cancel lands."""
|
|
|
+
|
|
|
+ def __init__(self):
|
|
|
+ self.closed = False
|
|
|
+
|
|
|
+ async def __aenter__(self):
|
|
|
+ await asyncio.sleep(0)
|
|
|
+ return self
|
|
|
+
|
|
|
+ async def __aexit__(self, *exc):
|
|
|
+ await asyncio.sleep(0)
|
|
|
+ self.closed = True
|
|
|
+
|
|
|
+
|
|
|
+class TestTheStreamReadsRunToTheEnd:
|
|
|
+ """Both run inside a camera stream; its viewer leaving must not cut them off."""
|
|
|
+
|
|
|
+ async def test_the_camera_light_settings(self):
|
|
|
+ session = _Session()
|
|
|
+ values = {"camera_light_mode": "all", "camera_light_delay": "3"}
|
|
|
+
|
|
|
+ async def get_setting(_db, key):
|
|
|
+ await asyncio.sleep(0)
|
|
|
+ return values[key]
|
|
|
+
|
|
|
+ result = None
|
|
|
+ with (
|
|
|
+ patch.object(database, "async_session", lambda: session),
|
|
|
+ patch("backend.app.api.routes.settings.get_setting", get_setting),
|
|
|
+ anyio.CancelScope() as viewer_left,
|
|
|
+ ):
|
|
|
+ viewer_left.cancel()
|
|
|
+ result = await camera_light._read_settings()
|
|
|
+
|
|
|
+ assert result == ("all", 3.0)
|
|
|
+ assert session.closed
|
|
|
+
|
|
|
+ async def test_the_stream_re_check(self):
|
|
|
+ session = _Session()
|
|
|
+
|
|
|
+ @asynccontextmanager
|
|
|
+ async def factory():
|
|
|
+ async with session:
|
|
|
+ yield session
|
|
|
+
|
|
|
+ result = None
|
|
|
+ with (
|
|
|
+ patch.object(auth, "async_session", factory),
|
|
|
+ patch.object(auth, "is_auth_enabled", AsyncMock(return_value=False)),
|
|
|
+ anyio.CancelScope() as viewer_left,
|
|
|
+ ):
|
|
|
+ viewer_left.cancel()
|
|
|
+ result = await auth.stream_opener_still_allowed(auth.StreamOpener(auth_on=False), printer_id=1)
|
|
|
+
|
|
|
+ assert result is True
|
|
|
+ assert session.closed
|