| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229 |
- """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
- class TestOlderAiosqlite:
- def test_a_version_without_stop_is_left_alone(self, monkeypatch):
- # Before 0.22.1 aiosqlite has no stop(), and SQLAlchemy calls none. The
- # in-app update keeps such a version, so it must not fail the import.
- class Connection:
- pass
- monkeypatch.setattr("aiosqlite.core.Connection", Connection)
- database._make_aiosqlite_stop_idempotent()
- assert not hasattr(Connection, "stop")
- class TestALeavingViewerDuringASlowReCheck:
- async def test_the_retry_does_not_keep_the_stream(self, monkeypatch):
- # Every database connection busy: the shielded re-check waits for one
- # and times out. A viewer who left meanwhile must not be held through
- # the immediate retry's own shielded wait as well.
- from backend.app.api.routes import camera
- monkeypatch.setattr(camera, "STREAM_RECHECK_SECONDS", 0.0)
- monkeypatch.setattr(camera, "STREAM_RECHECK_RETRY_SECONDS", 0.0)
- calls = 0
- async def slow_failure(_opener, _printer_id):
- nonlocal calls
- calls += 1
- with anyio.CancelScope(shield=True):
- await asyncio.sleep(0.2)
- raise TimeoutError("pool")
- monkeypatch.setattr(camera, "stream_opener_still_allowed", slow_failure)
- async def frames():
- while True:
- yield b"frame"
- await asyncio.sleep(0)
- async def watch():
- async for _ in camera._while_allowed(frames(), auth.StreamOpener(auth_on=False), 1):
- pass
- async with anyio.create_task_group() as group:
- group.start_soon(watch)
- await asyncio.sleep(0.05)
- group.cancel_scope.cancel()
- assert calls == 1
|