test_camera_stop_spares_live_external.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  1. """/camera/stop ends the leaving viewer's external streams, not the others'.
  2. The stop guard counted only fan-out subscribers, which external cameras never
  3. use: one viewer leaving (a Cam Wall tile turning to snapshot, the Camera page
  4. closed, the camera window's Refresh) killed every external RTSP/USB ffmpeg of
  5. the printer, and the other viewers froze. A viewer now names itself (a random
  6. id per mount, sent with its stream and its stop): its own streams end at once,
  7. also one whose cleanup was skipped (#2675), and so does any stream that sends no
  8. frames; other viewers' live streams keep running. A stop without an id (a
  9. cached older frontend) still ends them all.
  10. """
  11. from __future__ import annotations
  12. import asyncio
  13. import os
  14. import time
  15. import pytest
  16. from backend.app.api.routes import camera
  17. PRINTER = 7
  18. class _Proc:
  19. def __init__(self, pid: int) -> None:
  20. self.pid = pid
  21. self.returncode = None
  22. self.stdout = None
  23. self.stderr = None
  24. def terminate(self) -> None:
  25. self.returncode = 0
  26. def kill(self) -> None:
  27. self.returncode = -9
  28. async def wait(self) -> int:
  29. return self.returncode if self.returncode is not None else 0
  30. @pytest.fixture
  31. def no_fanout(monkeypatch):
  32. monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
  33. monkeypatch.setattr(camera, "get_subscriber_count", lambda _key: 0)
  34. async def no_broadcaster(_key):
  35. return False
  36. monkeypatch.setattr(camera, "shutdown_broadcaster", no_broadcaster)
  37. @pytest.fixture
  38. def register():
  39. added: list[tuple[str, _Proc]] = []
  40. def _register(suffix: str, pid: int, viewer: str | None, last_frame_age: float | None = 0.5):
  41. sid = f"{PRINTER}-ext-{suffix}"
  42. proc = _Proc(pid)
  43. event = asyncio.Event()
  44. camera._active_streams[sid] = proc
  45. camera._disconnect_events[sid] = event
  46. camera._spawned_ffmpeg_pids[pid] = time.time()
  47. if viewer is not None:
  48. camera._stream_viewers[sid] = viewer
  49. if last_frame_age is not None:
  50. camera._stream_last_frame_times[sid] = time.time() - last_frame_age
  51. added.append((sid, proc))
  52. return proc, event
  53. yield _register
  54. for sid, proc in added:
  55. camera._active_streams.pop(sid, None)
  56. camera._disconnect_events.pop(sid, None)
  57. camera._stream_last_frame_times.pop(sid, None)
  58. camera._stream_viewers.pop(sid, None)
  59. camera._spawned_ffmpeg_pids.pop(proc.pid, None)
  60. @pytest.mark.asyncio
  61. async def test_a_viewer_leaving_ends_its_own_stream_at_once(no_fanout, register):
  62. """#2675: its cleanup may have been skipped, and the stream still looks live."""
  63. own, own_event = register("own00001", 54001, viewer="tile-a", last_frame_age=0.1)
  64. result = await camera.stop_camera_stream(PRINTER, _=None, viewer="tile-a")
  65. assert result["stopped"] == 1
  66. assert own.returncode is not None and own_event.is_set()
  67. assert f"{PRINTER}-ext-own00001" not in camera._stream_viewers
  68. @pytest.mark.asyncio
  69. async def test_another_viewers_live_stream_keeps_running(no_fanout, register):
  70. own, _ = register("own00002", 54002, viewer="tile-a")
  71. other, other_event = register("other002", 54003, viewer="page-b")
  72. result = await camera.stop_camera_stream(PRINTER, _=None, viewer="tile-a")
  73. assert result["stopped"] == 1
  74. assert own.returncode is not None
  75. assert other.returncode is None and not other_event.is_set()
  76. assert f"{PRINTER}-ext-other002" in camera._active_streams
  77. @pytest.mark.asyncio
  78. async def test_a_stream_sending_no_frames_ends_whoever_opened_it(no_fanout, register):
  79. stuck, _ = register("stuck003", 54004, viewer="page-b", last_frame_age=60.0)
  80. never, _ = register("never003", 54005, viewer=None, last_frame_age=None)
  81. live, _ = register("live0003", 54006, viewer=None)
  82. result = await camera.stop_camera_stream(PRINTER, _=None, viewer="tile-a")
  83. assert result["stopped"] == 2
  84. assert stuck.returncode is not None and never.returncode is not None
  85. assert live.returncode is None
  86. @pytest.mark.asyncio
  87. async def test_a_stop_without_a_viewer_still_ends_them_all(no_fanout, register):
  88. """A cached older frontend sends no id."""
  89. a, _ = register("all00004", 54007, viewer="tile-a")
  90. b, _ = register("all00005", 54008, viewer="page-b")
  91. result = await camera.stop_camera_stream(PRINTER, _=None)
  92. assert result["stopped"] == 2
  93. assert a.returncode is not None and b.returncode is not None
  94. @pytest.mark.asyncio
  95. async def test_a_deleted_printer_loses_every_stream(no_fanout, register):
  96. live, live_event = register("live0006", 54009, viewer="page-b")
  97. await camera.forget_printer(PRINTER)
  98. assert live.returncode is not None and live_event.is_set()
  99. assert f"{PRINTER}-ext-live0006" not in camera._stream_viewers
  100. @pytest.mark.asyncio
  101. async def test_the_janitor_drops_the_viewer_of_a_stream_it_kills(no_fanout, register, monkeypatch):
  102. """Orphaned by a skipped cleanup and its viewer's stop never came (#2675)."""
  103. monkeypatch.setattr(camera, "_scan_bambu_ffmpeg_pids", lambda: [])
  104. # A real pid, so the janitor's existence check keeps it.
  105. stale, stale_event = register("stale007", os.getpid(), viewer="tile-a", last_frame_age=60.0)
  106. camera._spawned_ffmpeg_pids[stale.pid] = time.time() - 120
  107. await camera.cleanup_orphaned_streams()
  108. assert stale.returncode is not None and stale_event.is_set()
  109. assert f"{PRINTER}-ext-stale007" not in camera._stream_viewers
  110. @pytest.mark.asyncio
  111. async def test_the_janitor_keeps_the_viewer_of_a_stream_between_reconnects(no_fanout, register, monkeypatch):
  112. """Its ffmpeg died and the stream is starting the next one under the same id:
  113. its viewer's stop must still reach it."""
  114. monkeypatch.setattr(camera, "_scan_bambu_ffmpeg_pids", lambda: [])
  115. dead, _ = register("dead0008", 54011, viewer="tile-a")
  116. dead.returncode = 1
  117. await camera.cleanup_orphaned_streams()
  118. assert camera._stream_viewers.get(f"{PRINTER}-ext-dead0008") == "tile-a"
  119. assert f"{PRINTER}-ext-dead0008" in camera._disconnect_events