|
|
@@ -0,0 +1,111 @@
|
|
|
+"""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()
|