| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111 |
- """The RTSPS proxy must survive whichever event loop production actually runs (#3001).
- 1.2.5.4 shipped ``server._bambuddy_proxy_handlers = handlers`` at the end of
- ``create_tls_proxy``. That is legal on ``asyncio.base_events.Server``, which
- carries a ``__dict__``, and an outright ``AttributeError`` on
- ``uvloop.loop.Server``, a Cython cdef class that does not::
- AttributeError: 'uvloop.loop.Server' object has no attribute
- '_bambuddy_proxy_handlers' and no __dict__ for setting new attributes
- Which loop you get is decided by the launch command, not by anything in the
- app. Every unit file this repo ships pins ``--loop asyncio`` (added for #1896),
- so none of them could hit this -- but ``requirements.txt`` pins
- ``uvicorn[standard]``, which installs uvloop on Linux, so any launcher without
- that flag gets uvloop from ``--loop auto``. The Proxmox VE Helper-Scripts LXC
- writes its own unit with no loop pinned, and native installs predating the
- #1896 pin never gained it, because ``update.sh`` does not rewrite unit files.
- So the loop this code runs on is not ours to assume, which is the whole reason
- these tests exist. The proxy raised before opening a socket, which is why the
- in-app diagnostic reported
- ``capture_exception`` at 0 ms while network reachability passed at 1 ms, and
- why live view, snapshots and timelapse frames all went at once on every RTSP
- model (X1, H2*, P2*). A1/P1 use the chamber-image protocol and return before
- the proxy, so they were untouched.
- The suite could not see any of it: ``conftest.event_loop`` builds its loop from
- the default policy, so every async test in the repo runs on the selector loop
- -- the one loop where that assignment works. Hence two tests here. The first
- drives the real function on a real uvloop loop. The second states the
- underlying contract without needing uvloop installed at all: the proxy must not
- store anything *on* the server object, because the server it gets is not
- guaranteed to accept attributes.
- """
- from __future__ import annotations
- import asyncio
- import pytest
- from backend.app.services.camera import _proxy_handlers, close_tls_proxy, create_tls_proxy
- def test_create_tls_proxy_works_on_a_uvloop_loop():
- """The regression itself, on the loop that production uses.
- Deliberately not an async test: the point is the loop implementation, and
- an async test would inherit the session's selector loop and prove nothing.
- ``uvloop.run`` owns its loop start to finish.
- """
- uvloop = pytest.importorskip("uvloop", reason="uvloop is a uvicorn[standard] extra; Linux only")
- async def scenario() -> int:
- # Port 322 is never dialled -- create_tls_proxy only binds the local
- # listener; the upstream connection is opened per client handler.
- port, server = await create_tls_proxy("127.0.0.1", 322)
- try:
- assert port > 0
- assert server in _proxy_handlers, (
- "handler set must be reachable from the registry, or close_tls_proxy has nothing to cancel"
- )
- finally:
- await close_tls_proxy(server)
- assert server not in _proxy_handlers, "close_tls_proxy must drop its registry entry"
- return port
- assert uvloop.run(scenario()) > 0
- async def test_create_tls_proxy_stores_nothing_on_the_server(monkeypatch):
- """Runs everywhere, including where uvloop is not installed.
- A stand-in ``Server`` with ``__slots__`` reproduces uvloop's constraint --
- no ``__dict__``, so any attribute the proxy tries to attach raises. If this
- fails, the code has gone back to writing on the server object.
- """
- class SlottedServer:
- """Minimum of ``asyncio.Server`` that ``create_tls_proxy`` touches."""
- __slots__ = ("sockets", "__weakref__")
- def __init__(self, sockets):
- self.sockets = sockets
- def close(self):
- pass
- async def wait_closed(self):
- pass
- real_start_server = asyncio.start_server
- created: list[asyncio.Server] = []
- async def fake_start_server(*args, **kwargs):
- """Bind for real -- the port has to be usable -- then hide the Server."""
- real = await real_start_server(*args, **kwargs)
- created.append(real)
- return SlottedServer(real.sockets)
- monkeypatch.setattr(asyncio, "start_server", fake_start_server)
- try:
- port, server = await create_tls_proxy("127.0.0.1", 322)
- assert port > 0
- assert _proxy_handlers.get(server) == set(), "a fresh proxy has no handlers yet, but must have an entry"
- await close_tls_proxy(server)
- assert server not in _proxy_handlers, "close_tls_proxy must drop its registry entry"
- finally:
- for real in created:
- real.close()
- await real.wait_closed()
|