test_camera_tls_proxy_uvloop_3001.py 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. """The RTSPS proxy must survive whichever event loop production actually runs (#3001).
  2. 1.2.5.4 shipped ``server._bambuddy_proxy_handlers = handlers`` at the end of
  3. ``create_tls_proxy``. That is legal on ``asyncio.base_events.Server``, which
  4. carries a ``__dict__``, and an outright ``AttributeError`` on
  5. ``uvloop.loop.Server``, a Cython cdef class that does not::
  6. AttributeError: 'uvloop.loop.Server' object has no attribute
  7. '_bambuddy_proxy_handlers' and no __dict__ for setting new attributes
  8. Which loop you get is decided by the launch command, not by anything in the
  9. app. Every unit file this repo ships pins ``--loop asyncio`` (added for #1896),
  10. so none of them could hit this -- but ``requirements.txt`` pins
  11. ``uvicorn[standard]``, which installs uvloop on Linux, so any launcher without
  12. that flag gets uvloop from ``--loop auto``. The Proxmox VE Helper-Scripts LXC
  13. writes its own unit with no loop pinned, and native installs predating the
  14. #1896 pin never gained it, because ``update.sh`` does not rewrite unit files.
  15. So the loop this code runs on is not ours to assume, which is the whole reason
  16. these tests exist. The proxy raised before opening a socket, which is why the
  17. in-app diagnostic reported
  18. ``capture_exception`` at 0 ms while network reachability passed at 1 ms, and
  19. why live view, snapshots and timelapse frames all went at once on every RTSP
  20. model (X1, H2*, P2*). A1/P1 use the chamber-image protocol and return before
  21. the proxy, so they were untouched.
  22. The suite could not see any of it: ``conftest.event_loop`` builds its loop from
  23. the default policy, so every async test in the repo runs on the selector loop
  24. -- the one loop where that assignment works. Hence two tests here. The first
  25. drives the real function on a real uvloop loop. The second states the
  26. underlying contract without needing uvloop installed at all: the proxy must not
  27. store anything *on* the server object, because the server it gets is not
  28. guaranteed to accept attributes.
  29. """
  30. from __future__ import annotations
  31. import asyncio
  32. import pytest
  33. from backend.app.services.camera import _proxy_handlers, close_tls_proxy, create_tls_proxy
  34. def test_create_tls_proxy_works_on_a_uvloop_loop():
  35. """The regression itself, on the loop that production uses.
  36. Deliberately not an async test: the point is the loop implementation, and
  37. an async test would inherit the session's selector loop and prove nothing.
  38. ``uvloop.run`` owns its loop start to finish.
  39. """
  40. uvloop = pytest.importorskip("uvloop", reason="uvloop is a uvicorn[standard] extra; Linux only")
  41. async def scenario() -> int:
  42. # Port 322 is never dialled -- create_tls_proxy only binds the local
  43. # listener; the upstream connection is opened per client handler.
  44. port, server = await create_tls_proxy("127.0.0.1", 322)
  45. try:
  46. assert port > 0
  47. assert server in _proxy_handlers, (
  48. "handler set must be reachable from the registry, or close_tls_proxy has nothing to cancel"
  49. )
  50. finally:
  51. await close_tls_proxy(server)
  52. assert server not in _proxy_handlers, "close_tls_proxy must drop its registry entry"
  53. return port
  54. assert uvloop.run(scenario()) > 0
  55. async def test_create_tls_proxy_stores_nothing_on_the_server(monkeypatch):
  56. """Runs everywhere, including where uvloop is not installed.
  57. A stand-in ``Server`` with ``__slots__`` reproduces uvloop's constraint --
  58. no ``__dict__``, so any attribute the proxy tries to attach raises. If this
  59. fails, the code has gone back to writing on the server object.
  60. """
  61. class SlottedServer:
  62. """Minimum of ``asyncio.Server`` that ``create_tls_proxy`` touches."""
  63. __slots__ = ("sockets", "__weakref__")
  64. def __init__(self, sockets):
  65. self.sockets = sockets
  66. def close(self):
  67. pass
  68. async def wait_closed(self):
  69. pass
  70. real_start_server = asyncio.start_server
  71. created: list[asyncio.Server] = []
  72. async def fake_start_server(*args, **kwargs):
  73. """Bind for real -- the port has to be usable -- then hide the Server."""
  74. real = await real_start_server(*args, **kwargs)
  75. created.append(real)
  76. return SlottedServer(real.sockets)
  77. monkeypatch.setattr(asyncio, "start_server", fake_start_server)
  78. try:
  79. port, server = await create_tls_proxy("127.0.0.1", 322)
  80. assert port > 0
  81. assert _proxy_handlers.get(server) == set(), "a fresh proxy has no handlers yet, but must have an entry"
  82. await close_tls_proxy(server)
  83. assert server not in _proxy_handlers, "close_tls_proxy must drop its registry entry"
  84. finally:
  85. for real in created:
  86. real.close()
  87. await real.wait_closed()