| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266 |
- """The RTSPS proxy must not leave a handler running past its server (#2968).
- The reporter's log carries three of these, one per camera snapshot, at ERROR
- with a traceback pointing into ``camera.py``:
- ERROR [asyncio] Task was destroyed but it is pending!
- task: <Task pending name='Task-1889625'
- coro=<create_tls_proxy.<locals>._handle() done, defined at camera.py:243>
- wait_for=<_GatheringFuture pending ...>>
- ``asyncio.start_server`` wraps the connection callback in a task and keeps only
- a weak reference to it, so a handler still awaiting its two forwarders can be
- collected while pending -- which is exactly what that message is. Nothing was
- broken by it (the snapshot on either side of each one succeeded), but it reads
- like a camera fault in a log people attach to bug reports, and the shape behind
- it is real: teardown closed the listener and then waited on handlers that only
- finish when the *peer* drops the socket.
- Two things fix it. The handlers are strongly referenced for as long as they run,
- and ``close_tls_proxy`` cancels them rather than hoping ffmpeg has already gone.
- The upstream here is a real TLS listener rather than a bare socket, because the
- proxy spends its first ten seconds inside ``open_connection``: a stand-in that
- never completes a handshake never reaches the forwarding state these tests are
- about. The proxy sets ``CERT_NONE`` (Bambu printers are self-signed), so a
- throwaway certificate is all it takes.
- """
- from __future__ import annotations
- import asyncio
- import datetime
- import gc
- import logging
- import ssl
- import pytest
- from backend.app.services.camera import _proxy_handlers, close_tls_proxy, create_tls_proxy
- @pytest.fixture(scope="module")
- def self_signed_cert(tmp_path_factory):
- """Certificate and key for the stand-in printer, generated once."""
- from cryptography import x509
- from cryptography.hazmat.primitives import hashes, serialization
- from cryptography.hazmat.primitives.asymmetric import rsa
- from cryptography.x509.oid import NameOID
- key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
- name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "127.0.0.1")])
- now = datetime.datetime.now(datetime.timezone.utc)
- cert = (
- x509.CertificateBuilder()
- .subject_name(name)
- .issuer_name(name)
- .public_key(key.public_key())
- .serial_number(x509.random_serial_number())
- .not_valid_before(now - datetime.timedelta(days=1))
- .not_valid_after(now + datetime.timedelta(days=1))
- .sign(key, hashes.SHA256())
- )
- directory = tmp_path_factory.mktemp("tls")
- cert_file = directory / "cert.pem"
- key_file = directory / "key.pem"
- cert_file.write_bytes(cert.public_bytes(serialization.Encoding.PEM))
- key_file.write_bytes(
- key.private_bytes(
- encoding=serialization.Encoding.PEM,
- format=serialization.PrivateFormat.TraditionalOpenSSL,
- encryption_algorithm=serialization.NoEncryption(),
- )
- )
- return cert_file, key_file
- async def _printer(self_signed_cert, on_data=None) -> tuple[asyncio.Server, int]:
- """A TLS listener standing in for the printer's RTSPS port.
- Accepts, hands anything it receives to *on_data*, and otherwise waits --
- which is the state the upstream is in while ffmpeg is being reaped.
- """
- async def _accept(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
- try:
- while True:
- data = await reader.read(4096)
- if not data:
- break
- if on_data is not None:
- on_data(data)
- except (ConnectionError, OSError, asyncio.CancelledError):
- pass
- finally:
- if not writer.is_closing():
- writer.close()
- cert_file, key_file = self_signed_cert
- context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
- context.load_cert_chain(str(cert_file), str(key_file))
- server = await asyncio.start_server(_accept, "127.0.0.1", 0, ssl=context)
- return server, server.sockets[0].getsockname()[1]
- async def _close(proxy) -> None:
- """Teardown, bounded.
- Every close in this file goes through the timeout, including the ones in
- ``finally`` blocks that are only there to tidy up. Losing the cancellation
- or the handler tracking makes ``close_tls_proxy`` wait on a peer that is
- not going to drop, and an unbounded await turns that regression into a
- hung suite instead of a failing test.
- """
- await asyncio.wait_for(close_tls_proxy(proxy), timeout=5.0)
- async def _shutdown(server: asyncio.Server) -> None:
- """Bounded teardown for the stand-in printer.
- ``wait_closed`` waits for the listener's own handlers, and one of those is
- reading a socket the proxy still holds. Left unbounded it inherits any
- regression in the proxy's teardown and hangs the suite in a second place.
- """
- server.close()
- try:
- await asyncio.wait_for(server.wait_closed(), timeout=5.0)
- except asyncio.TimeoutError:
- pass
- async def _wait_for(predicate, timeout: float = 5.0) -> bool:
- """Poll rather than sleep a fixed amount: these are real sockets."""
- deadline = asyncio.get_running_loop().time() + timeout
- while asyncio.get_running_loop().time() < deadline:
- if predicate():
- return True
- await asyncio.sleep(0.02)
- return predicate()
- class TestTheHandlerIsHeldWhileItRuns:
- @pytest.mark.asyncio
- async def test_an_open_connection_is_tracked(self, self_signed_cert):
- """The set is the strong reference asyncio does not keep."""
- upstream, upstream_port = await _printer(self_signed_cert)
- try:
- port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
- try:
- _, writer = await asyncio.open_connection("127.0.0.1", port)
- assert await _wait_for(lambda: len(_proxy_handlers[proxy]) == 1)
- assert not next(iter(_proxy_handlers[proxy])).done()
- writer.close()
- finally:
- await _close(proxy)
- finally:
- await _shutdown(upstream)
- @pytest.mark.asyncio
- async def test_a_finished_handler_is_released(self, self_signed_cert):
- """Tracked for the connection's life, not the process's -- a long
- stream must not accumulate one entry per reconnect."""
- upstream, upstream_port = await _printer(self_signed_cert)
- try:
- port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
- try:
- _, writer = await asyncio.open_connection("127.0.0.1", port)
- assert await _wait_for(lambda: len(_proxy_handlers[proxy]) == 1)
- writer.close()
- assert await _wait_for(lambda: _proxy_handlers[proxy] == set())
- finally:
- await _close(proxy)
- finally:
- await _shutdown(upstream)
- class TestCloseDoesNotDependOnThePeer:
- @pytest.mark.asyncio
- async def test_a_live_connection_does_not_stall_the_close(self, self_signed_cert):
- """``server.close()`` leaves established connections running, so the
- old close/wait pair finished only when the client happened to drop.
- Here the client is still attached and close still returns."""
- upstream, upstream_port = await _printer(self_signed_cert)
- try:
- port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
- _, writer = await asyncio.open_connection("127.0.0.1", port)
- assert await _wait_for(lambda: len(_proxy_handlers[proxy]) == 1)
- await _close(proxy)
- # Stronger than "the set is empty": since #3001 the handler set
- # lives in a module-level registry rather than on the server, and
- # close_tls_proxy retires the whole entry.
- assert proxy not in _proxy_handlers
- writer.close()
- finally:
- await _shutdown(upstream)
- @pytest.mark.asyncio
- async def test_no_handler_survives_the_close(self, self_signed_cert, caplog):
- """The actual complaint: nothing is left pending for the garbage
- collector to shout about afterwards."""
- upstream, upstream_port = await _printer(self_signed_cert)
- try:
- port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
- _, writer = await asyncio.open_connection("127.0.0.1", port)
- assert await _wait_for(lambda: len(_proxy_handlers[proxy]) == 1)
- handler = next(iter(_proxy_handlers[proxy]))
- with caplog.at_level(logging.ERROR, logger="asyncio"):
- await _close(proxy)
- writer.close()
- await asyncio.sleep(0.05)
- gc.collect()
- await asyncio.sleep(0.05)
- assert handler.done()
- assert not [r for r in caplog.records if "Task was destroyed" in r.getMessage()]
- finally:
- await _shutdown(upstream)
- @pytest.mark.asyncio
- async def test_closing_twice_is_harmless(self, self_signed_cert):
- """Both callers reach their finally block on the error paths too."""
- upstream, upstream_port = await _printer(self_signed_cert)
- try:
- _, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
- await _close(proxy)
- await _close(proxy)
- finally:
- await _shutdown(upstream)
- @pytest.mark.asyncio
- async def test_it_works_on_a_server_it_did_not_create(self):
- """Degrades to the close/wait it replaces rather than raising."""
- plain = await asyncio.start_server(lambda r, w: None, "127.0.0.1", 0)
- await _close(plain)
- assert not plain.is_serving()
- @pytest.mark.asyncio
- async def test_the_proxy_still_forwards(self_signed_cert):
- """The teardown changes must not cost the proxy its job: plain TCP in one
- end, TLS to the printer out the other."""
- received: list[bytes] = []
- upstream, upstream_port = await _printer(self_signed_cert, on_data=received.append)
- try:
- port, proxy = await create_tls_proxy("127.0.0.1", upstream_port)
- try:
- _, writer = await asyncio.open_connection("127.0.0.1", port)
- writer.write(b"OPTIONS rtsp://127.0.0.1/streaming/live/1 RTSP/1.0\r\n\r\n")
- await writer.drain()
- assert await _wait_for(lambda: bool(received))
- assert b"OPTIONS" in received[0]
- writer.close()
- finally:
- await _close(proxy)
- finally:
- await _shutdown(upstream)
|