test_tls_proxy_teardown_2968.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. """The RTSPS proxy must not leave a handler running past its server (#2968).
  2. The reporter's log carries three of these, one per camera snapshot, at ERROR
  3. with a traceback pointing into ``camera.py``:
  4. ERROR [asyncio] Task was destroyed but it is pending!
  5. task: <Task pending name='Task-1889625'
  6. coro=<create_tls_proxy.<locals>._handle() done, defined at camera.py:243>
  7. wait_for=<_GatheringFuture pending ...>>
  8. ``asyncio.start_server`` wraps the connection callback in a task and keeps only
  9. a weak reference to it, so a handler still awaiting its two forwarders can be
  10. collected while pending -- which is exactly what that message is. Nothing was
  11. broken by it (the snapshot on either side of each one succeeded), but it reads
  12. like a camera fault in a log people attach to bug reports, and the shape behind
  13. it is real: teardown closed the listener and then waited on handlers that only
  14. finish when the *peer* drops the socket.
  15. Two things fix it. The handlers are strongly referenced for as long as they run,
  16. and ``close_tls_proxy`` cancels them rather than hoping ffmpeg has already gone.
  17. The upstream here is a real TLS listener rather than a bare socket, because the
  18. proxy spends its first ten seconds inside ``open_connection``: a stand-in that
  19. never completes a handshake never reaches the forwarding state these tests are
  20. about. The proxy sets ``CERT_NONE`` (Bambu printers are self-signed), so a
  21. throwaway certificate is all it takes.
  22. """
  23. from __future__ import annotations
  24. import asyncio
  25. import datetime
  26. import gc
  27. import logging
  28. import ssl
  29. import pytest
  30. from backend.app.services.camera import _proxy_handlers, close_tls_proxy, create_tls_proxy
  31. @pytest.fixture(scope="module")
  32. def self_signed_cert(tmp_path_factory):
  33. """Certificate and key for the stand-in printer, generated once."""
  34. from cryptography import x509
  35. from cryptography.hazmat.primitives import hashes, serialization
  36. from cryptography.hazmat.primitives.asymmetric import rsa
  37. from cryptography.x509.oid import NameOID
  38. key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  39. name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")])
  40. now = datetime.datetime.now(datetime.timezone.utc)
  41. cert = (
  42. x509.CertificateBuilder()
  43. .subject_name(name)
  44. .issuer_name(name)
  45. .public_key(key.public_key())
  46. .serial_number(x509.random_serial_number())
  47. .not_valid_before(now - datetime.timedelta(days=1))
  48. .not_valid_after(now + datetime.timedelta(days=1))
  49. .sign(key, hashes.SHA256())
  50. )
  51. directory = tmp_path_factory.mktemp("tls")
  52. cert_file = directory / "cert.pem"
  53. key_file = directory / "key.pem"
  54. cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
  55. key_file.write_bytes(
  56. key.private_bytes(
  57. encoding=serialization.Encoding.PEM,
  58. format=serialization.PrivateFormat.TraditionalOpenSSL,
  59. encryption_algorithm=serialization.NoEncryption(),
  60. )
  61. )
  62. return cert_file, key_file
  63. async def _printer(self_signed_cert, on_data=None) -> tuple[asyncio.Server, int]:
  64. """A TLS listener standing in for the printer's RTSPS port.
  65. Accepts, hands anything it receives to *on_data*, and otherwise waits --
  66. which is the state the upstream is in while ffmpeg is being reaped.
  67. """
  68. async def _accept(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
  69. try:
  70. while True:
  71. data = await reader.read(4096)
  72. if not data:
  73. break
  74. if on_data is not None:
  75. on_data(data)
  76. except (ConnectionError, OSError, asyncio.CancelledError):
  77. pass
  78. finally:
  79. if not writer.is_closing():
  80. writer.close()
  81. cert_file, key_file = self_signed_cert
  82. context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
  83. context.load_cert_chain(str(cert_file), str(key_file))
  84. server = await asyncio.start_server(_accept, "127.0.0.1", 0, ssl=context)
  85. return server, server.sockets[0].getsockname()[1]
  86. async def _close(proxy) -> None:
  87. """Teardown, bounded.
  88. Every close in this file goes through the timeout, including the ones in
  89. ``finally`` blocks that are only there to tidy up. Losing the cancellation
  90. or the handler tracking makes ``close_tls_proxy`` wait on a peer that is
  91. not going to drop, and an unbounded await turns that regression into a
  92. hung suite instead of a failing test.
  93. """
  94. await asyncio.wait_for(close_tls_proxy(proxy), timeout=5.0)
  95. async def _shutdown(server: asyncio.Server) -> None:
  96. """Bounded teardown for the stand-in printer.
  97. ``wait_closed`` waits for the listener's own handlers, and one of those is
  98. reading a socket the proxy still holds. Left unbounded it inherits any
  99. regression in the proxy's teardown and hangs the suite in a second place.
  100. """
  101. server.close()
  102. try:
  103. await asyncio.wait_for(server.wait_closed(), timeout=5.0)
  104. except asyncio.TimeoutError:
  105. pass
  106. async def _wait_for(predicate, timeout: float = 5.0) -> bool:
  107. """Poll rather than sleep a fixed amount: these are real sockets."""
  108. deadline = asyncio.get_running_loop().time() + timeout
  109. while asyncio.get_running_loop().time() < deadline:
  110. if predicate():
  111. return True
  112. await asyncio.sleep(0.02)
  113. return predicate()
  114. class TestTheHandlerIsHeldWhileItRuns:
  115. @pytest.mark.asyncio
  116. async def test_an_open_connection_is_tracked(self, self_signed_cert):
  117. """The set is the strong reference asyncio does not keep."""
  118. upstream, upstream_port = await _printer(self_signed_cert)
  119. try:
  120. port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
  121. try:
  122. _, writer = await asyncio.open_connection("127.0.0.1", port)
  123. assert await _wait_for(lambda: len(_proxy_handlers[proxy]) == 1)
  124. assert not next(iter(_proxy_handlers[proxy])).done()
  125. writer.close()
  126. finally:
  127. await _close(proxy)
  128. finally:
  129. await _shutdown(upstream)
  130. @pytest.mark.asyncio
  131. async def test_a_finished_handler_is_released(self, self_signed_cert):
  132. """Tracked for the connection's life, not the process's -- a long
  133. stream must not accumulate one entry per reconnect."""
  134. upstream, upstream_port = await _printer(self_signed_cert)
  135. try:
  136. port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
  137. try:
  138. _, writer = await asyncio.open_connection("127.0.0.1", port)
  139. assert await _wait_for(lambda: len(_proxy_handlers[proxy]) == 1)
  140. writer.close()
  141. assert await _wait_for(lambda: _proxy_handlers[proxy] == set())
  142. finally:
  143. await _close(proxy)
  144. finally:
  145. await _shutdown(upstream)
  146. class TestCloseDoesNotDependOnThePeer:
  147. @pytest.mark.asyncio
  148. async def test_a_live_connection_does_not_stall_the_close(self, self_signed_cert):
  149. """``server.close()`` leaves established connections running, so the
  150. old close/wait pair finished only when the client happened to drop.
  151. Here the client is still attached and close still returns."""
  152. upstream, upstream_port = await _printer(self_signed_cert)
  153. try:
  154. port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
  155. _, writer = await asyncio.open_connection("127.0.0.1", port)
  156. assert await _wait_for(lambda: len(_proxy_handlers[proxy]) == 1)
  157. await _close(proxy)
  158. # Stronger than "the set is empty": since #3001 the handler set
  159. # lives in a module-level registry rather than on the server, and
  160. # close_tls_proxy retires the whole entry.
  161. assert proxy not in _proxy_handlers
  162. writer.close()
  163. finally:
  164. await _shutdown(upstream)
  165. @pytest.mark.asyncio
  166. async def test_no_handler_survives_the_close(self, self_signed_cert, caplog):
  167. """The actual complaint: nothing is left pending for the garbage
  168. collector to shout about afterwards."""
  169. upstream, upstream_port = await _printer(self_signed_cert)
  170. try:
  171. port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
  172. _, writer = await asyncio.open_connection("127.0.0.1", port)
  173. assert await _wait_for(lambda: len(_proxy_handlers[proxy]) == 1)
  174. handler = next(iter(_proxy_handlers[proxy]))
  175. with caplog.at_level(logging.ERROR, logger="asyncio"):
  176. await _close(proxy)
  177. writer.close()
  178. await asyncio.sleep(0.05)
  179. gc.collect()
  180. await asyncio.sleep(0.05)
  181. assert handler.done()
  182. assert not [r for r in caplog.records if "Task was destroyed" in r.getMessage()]
  183. finally:
  184. await _shutdown(upstream)
  185. @pytest.mark.asyncio
  186. async def test_closing_twice_is_harmless(self, self_signed_cert):
  187. """Both callers reach their finally block on the error paths too."""
  188. upstream, upstream_port = await _printer(self_signed_cert)
  189. try:
  190. _, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
  191. await _close(proxy)
  192. await _close(proxy)
  193. finally:
  194. await _shutdown(upstream)
  195. @pytest.mark.asyncio
  196. async def test_it_works_on_a_server_it_did_not_create(self):
  197. """Degrades to the close/wait it replaces rather than raising."""
  198. plain = await asyncio.start_server(lambda r, w: None, "127.0.0.1", 0)
  199. await _close(plain)
  200. assert not plain.is_serving()
  201. @pytest.mark.asyncio
  202. async def test_the_proxy_still_forwards(self_signed_cert):
  203. """The teardown changes must not cost the proxy its job: plain TCP in one
  204. end, TLS to the printer out the other."""
  205. received: list[bytes] = []
  206. upstream, upstream_port = await _printer(self_signed_cert, on_data=received.append)
  207. try:
  208. port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
  209. try:
  210. _, writer = await asyncio.open_connection("127.0.0.1", port)
  211. writer.write(b"OPTIONS rtsp://127.0.0.1/streaming/live/1 RTSP/1.0\r\n\r\n")
  212. await writer.drain()
  213. assert await _wait_for(lambda: bool(received))
  214. assert b"OPTIONS" in received[0]
  215. writer.close()
  216. finally:
  217. await _close(proxy)
  218. finally:
  219. await _shutdown(upstream)