test_camera_usb_stream_cleanup.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  1. """External/USB camera ffmpeg-leak cleanup (#2675, reporter @bitbarista).
  2. An external USB (V4L2) camera's ffmpeg used to be reachable only from its own
  3. stream generator's ``finally`` — which an abrupt client disconnect can skip
  4. (same cancellation-timing class as #776). Because external streams never
  5. registered into ``_active_streams`` / ``_disconnect_events`` / the spawned-PID
  6. map, both ``/camera/stop`` and ``cleanup_orphaned_streams`` were structurally
  7. blind to the leak, leaving ``/dev/videoN`` locked. The fix registers the external
  8. ffmpeg into the same registries the RTSP path uses.
  9. """
  10. from __future__ import annotations
  11. import asyncio
  12. import os
  13. import sys
  14. import time
  15. from contextlib import suppress
  16. from unittest.mock import AsyncMock, mock_open, patch
  17. import pytest
  18. from backend.app.api.routes import camera
  19. from backend.app.services import external_camera
  20. async def _instant_sleep(*_args, **_kwargs) -> None:
  21. """Drop-in for asyncio.sleep that returns immediately (no self-recursion)."""
  22. return None
  23. class _CleanProc:
  24. """ffmpeg that terminates cleanly when asked."""
  25. def __init__(self, pid: int) -> None:
  26. self.pid = pid
  27. self.returncode = None
  28. # Real Process objects always expose these (None when not piped), and
  29. # _terminate_ffmpeg drains them so a full pipe can't wedge the exit.
  30. self.stdout = None
  31. self.stderr = None
  32. def terminate(self) -> None:
  33. self.returncode = 0
  34. def kill(self) -> None:
  35. self.returncode = -9
  36. async def wait(self) -> int:
  37. return self.returncode if self.returncode is not None else 0
  38. class _ImmediateEOFReader:
  39. async def read(self, _size: int = -1) -> bytes:
  40. return b""
  41. class _UsbProc:
  42. """ffmpeg for a USB stream: yields no frames, exits at first read."""
  43. def __init__(self, pid: int = 52001) -> None:
  44. self.pid = pid
  45. self.returncode = None
  46. self.stdout = _ImmediateEOFReader()
  47. self.stderr = _ImmediateEOFReader()
  48. def terminate(self) -> None:
  49. self.returncode = 0
  50. def kill(self) -> None:
  51. self.returncode = -9
  52. async def wait(self) -> int:
  53. return 0
  54. # ---------------------------------------------------------------------------
  55. # 1. The stream generator hands its ffmpeg process to the on_process callback
  56. # ---------------------------------------------------------------------------
  57. @pytest.mark.asyncio
  58. async def test_stream_usb_registers_process_via_on_process(monkeypatch):
  59. """``_stream_usb`` must call ``on_process`` with the spawned ffmpeg so the
  60. route can register it — this is the linchpin of the whole fix."""
  61. class _FakePath:
  62. def __init__(self, _p: str) -> None:
  63. pass
  64. def exists(self) -> bool:
  65. return True
  66. proc = _UsbProc()
  67. async def fake_create_subprocess_exec(*_args, **_kwargs):
  68. return proc
  69. monkeypatch.setattr(external_camera, "get_ffmpeg_path", lambda: "/fake/ffmpeg")
  70. monkeypatch.setattr(external_camera, "Path", _FakePath)
  71. monkeypatch.setattr(external_camera.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
  72. monkeypatch.setattr(external_camera.asyncio, "sleep", _instant_sleep)
  73. captured: list[object] = []
  74. stream = external_camera._stream_usb("/dev/video0", 10, on_process=captured.append)
  75. try:
  76. async for _frame in stream:
  77. pass
  78. finally:
  79. with suppress(Exception):
  80. await stream.aclose()
  81. assert captured == [proc], "the spawned ffmpeg process must be handed to on_process"
  82. # ---------------------------------------------------------------------------
  83. # 2. /camera/stop now finds and kills a registered external USB process
  84. # (the reported {"stopped": 0} → {"stopped": 1})
  85. # ---------------------------------------------------------------------------
  86. @pytest.mark.asyncio
  87. async def test_stop_endpoint_terminates_registered_external_process(monkeypatch):
  88. monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
  89. monkeypatch.setattr(camera, "get_subscriber_count", lambda _key: 0)
  90. async def fake_shutdown(_key):
  91. return False
  92. monkeypatch.setattr(camera, "shutdown_broadcaster", fake_shutdown)
  93. printer_id = 7
  94. sid = f"{printer_id}-ext-abc12345"
  95. proc = _CleanProc(pid=52010)
  96. event = asyncio.Event()
  97. camera._active_streams[sid] = proc
  98. camera._disconnect_events[sid] = event
  99. camera._spawned_ffmpeg_pids[proc.pid] = time.time()
  100. camera._stream_last_frame_times[sid] = time.time()
  101. try:
  102. result = await camera.stop_camera_stream(printer_id, _=None)
  103. assert result["stopped"] == 1
  104. assert proc.returncode is not None, "the external ffmpeg must be terminated"
  105. assert event.is_set(), "the stream's stop event must be signalled"
  106. # Registry fully cleaned so it can't be double-reaped.
  107. assert sid not in camera._active_streams
  108. assert sid not in camera._disconnect_events
  109. assert proc.pid not in camera._spawned_ffmpeg_pids
  110. finally:
  111. camera._active_streams.pop(sid, None)
  112. camera._disconnect_events.pop(sid, None)
  113. camera._spawned_ffmpeg_pids.pop(proc.pid, None)
  114. camera._stream_last_frame_times.pop(sid, None)
  115. # ---------------------------------------------------------------------------
  116. # 3. The orphan janitor reaps a stale registered external USB stream
  117. # ---------------------------------------------------------------------------
  118. @pytest.mark.asyncio
  119. async def test_cleanup_janitor_reaps_stale_external_usb_stream(monkeypatch):
  120. monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
  121. monkeypatch.setattr(camera, "_scan_bambu_ffmpeg_pids", lambda: [])
  122. import os
  123. proc = _CleanProc(pid=os.getpid()) # real pid so layer-2 existence check keeps it
  124. sid = "7-ext-deadbeef"
  125. now = time.time()
  126. camera._active_streams[sid] = proc
  127. camera._spawned_ffmpeg_pids[proc.pid] = now - 120 # spawned long ago
  128. camera._stream_last_frame_times[sid] = now - 60 # stale: no frames >30s
  129. camera._disconnect_events[sid] = asyncio.Event()
  130. try:
  131. await asyncio.wait_for(camera.cleanup_orphaned_streams(), timeout=2.0)
  132. assert proc.returncode is not None, "stale external ffmpeg must be killed"
  133. assert sid not in camera._active_streams
  134. finally:
  135. camera._active_streams.pop(sid, None)
  136. camera._spawned_ffmpeg_pids.pop(proc.pid, None)
  137. camera._stream_last_frame_times.pop(sid, None)
  138. camera._disconnect_events.pop(sid, None)
  139. # ---------------------------------------------------------------------------
  140. # 4. The /proc "nuclear net" now matches USB (v4l2) ffmpeg from prior sessions
  141. # ---------------------------------------------------------------------------
  142. def test_scan_matches_v4l2_ffmpeg(monkeypatch):
  143. cmdline = b"ffmpeg\x00-f\x00v4l2\x00-i\x00/dev/video0\x00-f\x00mjpeg\x00-\x00"
  144. monkeypatch.setattr("os.listdir", lambda _p: ["52020"])
  145. monkeypatch.setattr(camera, "_read_ppid", lambda _pid: 1)
  146. with patch("builtins.open", mock_open(read_data=cmdline)):
  147. assert 52020 in camera._scan_bambu_ffmpeg_pids()
  148. def test_scan_matches_an_orphaned_rtsp_ffmpeg(monkeypatch):
  149. cmdline = b"ffmpeg\x00-i\x00rtsp://bblp:code@127.0.0.1:4000/streaming/live/1\x00-\x00"
  150. monkeypatch.setattr("os.listdir", lambda _p: ["52022"])
  151. monkeypatch.setattr(camera, "_read_ppid", lambda _pid: 1)
  152. with patch("builtins.open", mock_open(read_data=cmdline)):
  153. assert camera._scan_bambu_ffmpeg_pids() == [52022]
  154. @pytest.mark.skipif(sys.platform != "linux", reason="/proc")
  155. def test_scan_leaves_another_processes_camera_ffmpeg_alone(tmp_path):
  156. """For real, through /proc: the same command run by another program (here
  157. a shell standing in for another Bambuddy) is not found; ours is."""
  158. import subprocess
  159. fake = tmp_path / "ffmpeg"
  160. fake.write_text("#!/bin/sh\nsleep 30\n")
  161. fake.chmod(0o755)
  162. url = "rtsp://bblp:code@127.0.0.1:4000/streaming/live/1"
  163. theirs = subprocess.Popen(["sh", "-c", f"'{fake}' -i '{url}' -; true"])
  164. ours = subprocess.Popen([str(fake), "-i", url, "-"])
  165. try:
  166. deadline = time.monotonic() + 5
  167. other = None
  168. while other is None and time.monotonic() < deadline:
  169. for pid in os.listdir("/proc"):
  170. if pid.isdigit() and camera._read_ppid(int(pid)) == theirs.pid:
  171. other = int(pid)
  172. time.sleep(0.05)
  173. assert other is not None, "the other program's ffmpeg did not start"
  174. found = camera._scan_bambu_ffmpeg_pids()
  175. assert ours.pid in found
  176. assert other not in found
  177. finally:
  178. for proc in (theirs, ours):
  179. proc.kill()
  180. proc.wait()
  181. subprocess.run(["pkill", "-f", str(fake)], check=False)
  182. def test_scan_ignores_unrelated_ffmpeg(monkeypatch):
  183. # A transcode of a local file is not ours — must not be reaped.
  184. cmdline = b"ffmpeg\x00-i\x00/home/user/movie.mp4\x00out.mkv\x00"
  185. monkeypatch.setattr("os.listdir", lambda _p: ["52021"])
  186. with patch("builtins.open", mock_open(read_data=cmdline)):
  187. assert camera._scan_bambu_ffmpeg_pids() == []
  188. # ---------------------------------------------------------------------------
  189. # 5. Only our own USB ffmpeg: another program's camera process is left alone
  190. # ---------------------------------------------------------------------------
  191. OWN_PID = 4242
  192. ONE_SHOT = [b"/usr/bin/ffmpeg", b"-f", b"v4l2", b"-i", b"/dev/video0", b"-frames:v", b"1", b"-f", b"image2pipe", b"-"]
  193. STREAM = [b"ffmpeg", b"-f", b"v4l2", b"-framerate", b"10", b"-i", b"/dev/video2", b"-f", b"mjpeg", b"-"]
  194. def test_our_one_shot_and_stream_commands_are_ours():
  195. assert camera._is_bambuddy_ffmpeg(ONE_SHOT, OWN_PID, OWN_PID)
  196. assert camera._is_bambuddy_ffmpeg(STREAM, OWN_PID, OWN_PID)
  197. def test_an_orphan_from_an_earlier_run_is_ours():
  198. """Reparented to init once the Bambuddy that spawned it died."""
  199. assert camera._is_bambuddy_ffmpeg(STREAM, 1, OWN_PID)
  200. def test_another_programs_usb_ffmpeg_is_not_ours():
  201. """motion, an OctoPrint webcam or a recording on a bare-metal install."""
  202. assert not camera._is_bambuddy_ffmpeg(STREAM, 777, OWN_PID)
  203. def test_a_different_v4l2_command_shape_is_not_ours():
  204. recording = [b"ffmpeg", b"-f", b"v4l2", b"-input_format", b"h264", b"-i", b"/dev/video0", b"/srv/cam.mkv"]
  205. assert not camera._is_bambuddy_ffmpeg(recording, OWN_PID, OWN_PID)
  206. RTSP = [b"ffmpeg", b"-rtsp_transport", b"tcp", b"-i", b"rtsps://bblp:code@10.0.0.5:322/streaming/live/1", b"-"]
  207. def test_our_bambu_rtsp_and_its_orphans_are_ours():
  208. assert camera._is_bambuddy_ffmpeg(RTSP, OWN_PID, OWN_PID)
  209. assert camera._is_bambuddy_ffmpeg(RTSP, 1, OWN_PID)
  210. def test_another_bambuddys_rtsp_is_not_ours():
  211. """A second install or a test server on the same host runs the same
  212. command: its cleanup killed this one's live camera every minute."""
  213. assert not camera._is_bambuddy_ffmpeg(RTSP, 777, OWN_PID)
  214. assert not camera._is_bambuddy_ffmpeg(RTSP, None, OWN_PID)
  215. def test_unknown_parent_is_not_ours_for_usb():
  216. assert not camera._is_bambuddy_ffmpeg(STREAM, None, OWN_PID)
  217. # ---------------------------------------------------------------------------
  218. # 6. A stuck ffmpeg can never hang the stream's own cleanup (#2580)
  219. # ---------------------------------------------------------------------------
  220. class _WedgedProc:
  221. """ffmpeg blocked in write() on a full pipe: SIGTERM is ignored, and after
  222. SIGKILL wait() never returns while its pipes are unread."""
  223. def __init__(self) -> None:
  224. self.pid = 52999
  225. self.returncode = None
  226. self.stdout = asyncio.StreamReader()
  227. self.stdout.feed_data(b"\xff\xd8" + b"x" * 200 + b"\xff\xd9")
  228. self.stderr = asyncio.StreamReader()
  229. self.killed = False
  230. def terminate(self) -> None:
  231. pass
  232. def kill(self) -> None:
  233. self.killed = True
  234. self.returncode = -9
  235. async def wait(self) -> int:
  236. await asyncio.Event().wait()
  237. return -9 # pragma: no cover
  238. @pytest.mark.asyncio
  239. async def test_a_wedged_usb_ffmpeg_does_not_hang_the_stream_cleanup(monkeypatch):
  240. """The shared upstream's cleanup runs in the fan-out broadcaster, which stop,
  241. a camera change, printer delete and shutdown all wait on."""
  242. from backend.app.services import external_camera
  243. monkeypatch.setattr(camera, "_FFMPEG_TERM_TIMEOUT", 0.05)
  244. monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
  245. proc = _WedgedProc()
  246. monkeypatch.setattr(external_camera, "get_ffmpeg_path", lambda: "/usr/bin/ffmpeg")
  247. monkeypatch.setattr(external_camera, "_safe_usb_device_path", lambda _d: "/dev/video0")
  248. monkeypatch.setattr("asyncio.create_subprocess_exec", AsyncMock(return_value=proc))
  249. stream = external_camera._stream_usb("/dev/video0", 5)
  250. assert await asyncio.wait_for(anext(stream), 2)
  251. await asyncio.wait_for(stream.aclose(), 2)
  252. assert proc.killed