test_camera_stream_registry_isolation.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. """A departing camera stream must not clean up its successor's state.
  2. The fan-out stream id used to be ``f"{printer_id}-fanout"`` — constant per
  3. printer, so every successive stream shared one registry key — and the
  4. generator's ``finally`` popped the per-printer frame buffer unconditionally.
  5. Teardown taking ~4s (the undrained-pipe deadlock, fixed separately) made the
  6. overlap wide enough to hit by closing and reopening the camera:
  7. 12.221 stream A cancelled, begins teardown
  8. 12.324 new viewer attaches
  9. 16.223 A finishes killing
  10. 16.224 new generator registers _active_streams["1-fanout"]
  11. ...then A's finally pops that very entry
  12. The damage is not cosmetic. ``is_stream_active()`` is what the #1348 / #1271
  13. guards consult before deciding whether opening a second camera connection is
  14. safe, so a printer with a viewer attached looked idle; the janitor's /proc scan
  15. reaps any ffmpeg missing from ``_active_streams``, so it killed the live stream;
  16. and ``/camera/stop`` reported ``Stopped 0``.
  17. The external-camera path already solved this with a per-instance id (#2675).
  18. These tests pin the same property for the fan-out path.
  19. """
  20. from __future__ import annotations
  21. import asyncio
  22. import time
  23. from contextlib import suppress
  24. import pytest
  25. from backend.app.api.routes import camera
  26. pytestmark = pytest.mark.asyncio
  27. PRINTER_ID = 7701
  28. @pytest.fixture(autouse=True)
  29. def _clean_registries():
  30. """These registries are module-global; leave them as we found them."""
  31. def _purge():
  32. for sid in [k for k in camera._active_streams if k.startswith(f"{PRINTER_ID}-")]:
  33. camera._active_streams.pop(sid, None)
  34. for sid in [k for k in camera._active_chamber_streams if k.startswith(f"{PRINTER_ID}-")]:
  35. camera._active_chamber_streams.pop(sid, None)
  36. for sid in [k for k in camera._stream_last_frame_times if k.startswith(f"{PRINTER_ID}-")]:
  37. camera._stream_last_frame_times.pop(sid, None)
  38. for sid in [k for k in camera._disconnect_events if k.startswith(f"{PRINTER_ID}-")]:
  39. camera._disconnect_events.pop(sid, None)
  40. camera._last_frames.pop(PRINTER_ID, None)
  41. camera._last_frame_times.pop(PRINTER_ID, None)
  42. camera._stream_start_times.pop(PRINTER_ID, None)
  43. _purge()
  44. yield
  45. _purge()
  46. def _seed_frame_state() -> None:
  47. camera._last_frames[PRINTER_ID] = b"\xff\xd8live\xff\xd9"
  48. camera._last_frame_times[PRINTER_ID] = time.time()
  49. camera._stream_start_times[PRINTER_ID] = time.time()
  50. # ---------------------------------------------------------------------------
  51. # _new_fanout_stream_id — one key per stream, not per printer
  52. # ---------------------------------------------------------------------------
  53. async def test_fanout_stream_ids_are_unique_per_stream():
  54. """Two streams for one printer must never collide in the registries."""
  55. ids = {camera._new_fanout_stream_id(PRINTER_ID) for _ in range(50)}
  56. assert len(ids) == 50, "ids collide, so one stream can clean up another's entry"
  57. async def test_camera_stream_has_no_function_local_module_imports():
  58. """A local ``import x`` anywhere in camera_stream shadows x for the WHOLE
  59. function, including branches that never reach the import.
  60. This is not hypothetical: an ``import uuid`` inside the external-camera
  61. branch meant building the fan-out id on the RTSP path raised
  62. UnboundLocalError, so the camera would not start on any printer without an
  63. external camera configured. ``time`` and ``uuid`` are module-level now;
  64. keep them that way.
  65. """
  66. import ast
  67. import inspect
  68. tree = ast.parse(inspect.getsource(camera.camera_stream))
  69. local_imports = [alias.name for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names]
  70. assert local_imports == [], f"function-local imports shadow the whole function: {local_imports}"
  71. async def test_fanout_stream_id_keeps_the_printer_prefix():
  72. """is_stream_active / stop_camera_stream / camera-status all scan for it."""
  73. stream_id = camera._new_fanout_stream_id(PRINTER_ID)
  74. assert stream_id.startswith(f"{PRINTER_ID}-")
  75. camera._active_streams[stream_id] = object()
  76. assert camera.is_stream_active(PRINTER_ID) is True
  77. # ---------------------------------------------------------------------------
  78. # _release_printer_frame_state — the ownership check itself
  79. # ---------------------------------------------------------------------------
  80. async def test_frame_state_survives_when_another_rtsp_stream_is_live():
  81. _seed_frame_state()
  82. camera._active_streams[f"{PRINTER_ID}-fanout-successor"] = object()
  83. camera._release_printer_frame_state(PRINTER_ID)
  84. assert PRINTER_ID in camera._last_frames, "successor's buffered frame was wiped"
  85. assert PRINTER_ID in camera._last_frame_times
  86. assert PRINTER_ID in camera._stream_start_times
  87. async def test_frame_state_survives_when_a_chamber_stream_is_live():
  88. """A1/P1 models register in a different dict; ownership spans both."""
  89. _seed_frame_state()
  90. camera._active_chamber_streams[f"{PRINTER_ID}-fanout-successor"] = (None, None)
  91. camera._release_printer_frame_state(PRINTER_ID)
  92. assert PRINTER_ID in camera._last_frames
  93. async def test_last_stream_out_releases_the_frame_state():
  94. """The other half: with nothing left running, stale state must not linger."""
  95. _seed_frame_state()
  96. camera._release_printer_frame_state(PRINTER_ID)
  97. assert PRINTER_ID not in camera._last_frames
  98. assert PRINTER_ID not in camera._last_frame_times
  99. assert PRINTER_ID not in camera._stream_start_times
  100. async def test_release_is_a_noop_without_a_printer_id():
  101. _seed_frame_state()
  102. camera._release_printer_frame_state(None)
  103. assert PRINTER_ID in camera._last_frames
  104. # ---------------------------------------------------------------------------
  105. # The whole generator cleanup path, with a successor already registered
  106. # ---------------------------------------------------------------------------
  107. class _FakeServer:
  108. def close(self) -> None:
  109. pass
  110. async def wait_closed(self) -> None:
  111. pass
  112. class _OneFrameThenEOF:
  113. def __init__(self) -> None:
  114. self._sent = False
  115. async def read(self, _size: int = -1) -> bytes:
  116. if self._sent:
  117. return b""
  118. self._sent = True
  119. return b"\xff\xd8predecessor\xff\xd9"
  120. class _Proc:
  121. def __init__(self, pid: int = 77010) -> None:
  122. self.pid = pid
  123. self.returncode = None
  124. self.stdout = _OneFrameThenEOF()
  125. self.stderr = None
  126. def terminate(self) -> None:
  127. self.returncode = 0
  128. def kill(self) -> None:
  129. self.returncode = -9
  130. async def wait(self) -> int:
  131. if self.returncode is None:
  132. self.returncode = 0
  133. return self.returncode
  134. async def test_departing_generator_leaves_its_successors_registry_entry_alone(monkeypatch):
  135. """End of the real cleanup path, with a second stream already registered."""
  136. async def _fake_exec(*_args, **_kwargs):
  137. return _Proc()
  138. async def _fake_proxy(_ip: str, _port: int):
  139. return 48999, _FakeServer()
  140. monkeypatch.setattr(camera, "get_ffmpeg_path", lambda: "/fake/ffmpeg")
  141. monkeypatch.setattr(camera, "create_tls_proxy", _fake_proxy)
  142. monkeypatch.setattr(camera.asyncio, "create_subprocess_exec", _fake_exec)
  143. predecessor_id = f"{PRINTER_ID}-fanout-aaaaaaaa"
  144. successor_id = f"{PRINTER_ID}-fanout-bbbbbbbb"
  145. stream = camera.generate_rtsp_mjpeg_stream(
  146. ip_address="192.0.2.31",
  147. access_code="test-code",
  148. model="P2S",
  149. fps=15,
  150. stream_id=predecessor_id,
  151. disconnect_event=asyncio.Event(),
  152. printer_id=PRINTER_ID,
  153. )
  154. # Drive it far enough to buffer a frame, as a real viewer would.
  155. chunk = await asyncio.wait_for(anext(stream), timeout=5.0)
  156. assert b"predecessor" in chunk
  157. assert camera._last_frames[PRINTER_ID].endswith(b"predecessor\xff\xd9")
  158. # A viewer reopens the camera mid-teardown: a fresh stream registers under
  159. # its own id and republishes the buffered frame.
  160. camera._active_streams[successor_id] = object()
  161. camera._last_frames[PRINTER_ID] = b"\xff\xd8successor\xff\xd9"
  162. with suppress(Exception):
  163. await asyncio.wait_for(stream.aclose(), timeout=5.0)
  164. assert successor_id in camera._active_streams, "predecessor removed its successor's entry"
  165. assert camera.is_stream_active(PRINTER_ID) is True, "a viewer is attached; guards must see it"
  166. assert camera._last_frames[PRINTER_ID].endswith(b"successor\xff\xd9"), "successor's frame was wiped"
  167. assert predecessor_id not in camera._active_streams, "predecessor must still clean up after itself"