test_cancelled_sqlite_close_3515.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. """A read cancelled by a leaving viewer must not hang its connection (#3515).
  2. A camera viewer leaving cancels its stream with AnyIO's level cancellation. A
  3. read cancelled halfway makes SQLAlchemy discard the connection; when that close
  4. is cancelled too, it calls aiosqlite's ``stop()`` while the close still runs.
  5. aiosqlite 0.22.1 then never finished the close (omnilib/aiosqlite#403), the
  6. session's exit waited on it forever, and the cancelled wait spun a CPU core.
  7. """
  8. import asyncio
  9. import os
  10. import subprocess
  11. import sys
  12. import textwrap
  13. import threading
  14. from contextlib import asynccontextmanager
  15. from pathlib import Path
  16. from unittest.mock import AsyncMock, patch
  17. import aiosqlite
  18. import anyio
  19. import pytest
  20. from backend.app.core import auth, database # noqa: F401 - database applies the aiosqlite fix
  21. from backend.app.services import camera_light
  22. pytestmark = pytest.mark.unit
  23. class TestAiosqliteStop:
  24. async def test_a_stop_during_a_close_lets_the_close_finish(self):
  25. # omnilib/aiosqlite#403's reproduction: the worker exits at the stop
  26. # requested from outside, before the close's own stop is served.
  27. gate = threading.Event()
  28. conn = await aiosqlite.connect(":memory:")
  29. await conn.create_function("hold", 0, lambda: gate.wait() and 1)
  30. busy = asyncio.create_task(conn.execute("SELECT hold()"))
  31. await asyncio.sleep(0.05)
  32. closing = asyncio.create_task(conn.close())
  33. await asyncio.sleep(0.05)
  34. conn.stop()
  35. gate.set()
  36. await busy
  37. await asyncio.wait_for(closing, timeout=5)
  38. async def test_a_second_stop_returns_the_first_ones_future(self):
  39. conn = await aiosqlite.connect(":memory:")
  40. first = conn.stop()
  41. assert conn.stop() is first
  42. await asyncio.wait_for(first, timeout=5)
  43. # Many short reads, cancelled at once by an AnyIO task group as a leaving viewer
  44. # cancels its stream, against the engine Bambuddy creates. Run in a process of
  45. # its own: a hung close can't be cancelled, so the test would hang with it.
  46. _BURST = textwrap.dedent(
  47. """
  48. import asyncio, sys
  49. import anyio
  50. from sqlalchemy import text
  51. from sqlalchemy.ext.asyncio import async_sessionmaker
  52. from backend.app.core.database import _create_engine
  53. async def main(url):
  54. engine = _create_engine(url)
  55. sessions = async_sessionmaker(engine, expire_on_commit=False)
  56. async with engine.begin() as conn:
  57. await conn.execute(text("create table settings (key text primary key, value text)"))
  58. await conn.execute(text("insert into settings values ('camera_light_mode', 'off')"))
  59. async def read():
  60. try:
  61. async with sessions() as session:
  62. await session.execute(text("select value from settings"))
  63. await session.execute(text("select value from settings"))
  64. except Exception:
  65. pass
  66. for i in range(40):
  67. async with anyio.create_task_group() as group:
  68. for _ in range(8):
  69. group.start_soon(read)
  70. await asyncio.sleep((i % 10) * 0.0002)
  71. group.cancel_scope.cancel()
  72. await engine.dispose()
  73. await asyncio.sleep(1)
  74. left = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
  75. print("pending", len(left))
  76. anyio.run(main, sys.argv[1], backend="asyncio")
  77. """
  78. )
  79. def test_cancelled_reads_leave_no_connection_behind(tmp_path):
  80. url = f"sqlite+aiosqlite:///{tmp_path / 'burst.db'}"
  81. # Its own DATABASE_URL: never the repo's .env.
  82. env = {**os.environ, "DATABASE_URL": url}
  83. try:
  84. result = subprocess.run(
  85. [sys.executable, "-c", _BURST, url],
  86. cwd=Path(__file__).resolve().parents[3],
  87. env=env,
  88. capture_output=True,
  89. text=True,
  90. timeout=60,
  91. )
  92. except subprocess.TimeoutExpired:
  93. pytest.fail("a cancelled read left its connection's close hanging")
  94. assert result.returncode == 0, result.stderr[-2000:]
  95. assert result.stdout.strip().splitlines()[-1] == "pending 0"
  96. class _Session:
  97. """A session whose every step yields to the loop, so a cancel lands."""
  98. def __init__(self):
  99. self.closed = False
  100. async def __aenter__(self):
  101. await asyncio.sleep(0)
  102. return self
  103. async def __aexit__(self, *exc):
  104. await asyncio.sleep(0)
  105. self.closed = True
  106. class TestTheStreamReadsRunToTheEnd:
  107. """Both run inside a camera stream; its viewer leaving must not cut them off."""
  108. async def test_the_camera_light_settings(self):
  109. session = _Session()
  110. values = {"camera_light_mode": "all", "camera_light_delay": "3"}
  111. async def get_setting(_db, key):
  112. await asyncio.sleep(0)
  113. return values[key]
  114. result = None
  115. with (
  116. patch.object(database, "async_session", lambda: session),
  117. patch("backend.app.api.routes.settings.get_setting", get_setting),
  118. anyio.CancelScope() as viewer_left,
  119. ):
  120. viewer_left.cancel()
  121. result = await camera_light._read_settings()
  122. assert result == ("all", 3.0)
  123. assert session.closed
  124. async def test_the_stream_re_check(self):
  125. session = _Session()
  126. @asynccontextmanager
  127. async def factory():
  128. async with session:
  129. yield session
  130. result = None
  131. with (
  132. patch.object(auth, "async_session", factory),
  133. patch.object(auth, "is_auth_enabled", AsyncMock(return_value=False)),
  134. anyio.CancelScope() as viewer_left,
  135. ):
  136. viewer_left.cancel()
  137. result = await auth.stream_opener_still_allowed(auth.StreamOpener(auth_on=False), printer_id=1)
  138. assert result is True
  139. assert session.closed
  140. class TestOlderAiosqlite:
  141. def test_a_version_without_stop_is_left_alone(self, monkeypatch):
  142. # Before 0.22.1 aiosqlite has no stop(), and SQLAlchemy calls none. The
  143. # in-app update keeps such a version, so it must not fail the import.
  144. class Connection:
  145. pass
  146. monkeypatch.setattr("aiosqlite.core.Connection", Connection)
  147. database._make_aiosqlite_stop_idempotent()
  148. assert not hasattr(Connection, "stop")
  149. class TestALeavingViewerDuringASlowReCheck:
  150. async def test_the_retry_does_not_keep_the_stream(self, monkeypatch):
  151. # Every database connection busy: the shielded re-check waits for one
  152. # and times out. A viewer who left meanwhile must not be held through
  153. # the immediate retry's own shielded wait as well.
  154. from backend.app.api.routes import camera
  155. monkeypatch.setattr(camera, "STREAM_RECHECK_SECONDS", 0.0)
  156. monkeypatch.setattr(camera, "STREAM_RECHECK_RETRY_SECONDS", 0.0)
  157. calls = 0
  158. async def slow_failure(_opener, _printer_id):
  159. nonlocal calls
  160. calls += 1
  161. with anyio.CancelScope(shield=True):
  162. await asyncio.sleep(0.2)
  163. raise TimeoutError("pool")
  164. monkeypatch.setattr(camera, "stream_opener_still_allowed", slow_failure)
  165. async def frames():
  166. while True:
  167. yield b"frame"
  168. await asyncio.sleep(0)
  169. async def watch():
  170. async for _ in camera._while_allowed(frames(), auth.StreamOpener(auth_on=False), 1):
  171. pass
  172. async with anyio.create_task_group() as group:
  173. group.start_soon(watch)
  174. await asyncio.sleep(0.05)
  175. group.cancel_scope.cancel()
  176. assert calls == 1