test_ffmpeg_reaped_3292.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. """A one-shot ffmpeg never outlives the capture that started it (#3292).
  2. The camera captures killed their ffmpeg on their own deadline and nowhere
  3. else: an error while reading its output, or the capture being cancelled, left
  4. it running. ffmpeg shares Bambuddy's memory limit in a container, so each one
  5. left behind counts against it until the container restarts.
  6. """
  7. import asyncio
  8. from pathlib import Path
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from backend.app.services import camera, external_camera
  12. from backend.app.utils.ffmpeg_output import reap_ffmpeg
  13. def _running_process(**overrides) -> MagicMock:
  14. proc = MagicMock()
  15. proc.pid = 4242
  16. proc.returncode = None
  17. proc.wait = AsyncMock(return_value=-9)
  18. proc.communicate = AsyncMock(side_effect=RuntimeError("pipe closed"))
  19. for key, value in overrides.items():
  20. setattr(proc, key, value)
  21. return proc
  22. class TestReapFfmpeg:
  23. @pytest.mark.asyncio
  24. async def test_a_running_process_is_killed_and_waited_for(self):
  25. proc = _running_process()
  26. await reap_ffmpeg(proc)
  27. proc.kill.assert_called_once()
  28. proc.wait.assert_awaited()
  29. @pytest.mark.asyncio
  30. async def test_a_finished_process_is_left_alone(self):
  31. proc = _running_process(returncode=0)
  32. await reap_ffmpeg(proc)
  33. proc.kill.assert_not_called()
  34. @pytest.mark.asyncio
  35. async def test_nothing_to_reap(self):
  36. await reap_ffmpeg(None)
  37. @pytest.mark.asyncio
  38. async def test_a_process_gone_meanwhile_is_fine(self):
  39. proc = _running_process()
  40. proc.kill.side_effect = ProcessLookupError
  41. await reap_ffmpeg(proc)
  42. @pytest.mark.asyncio
  43. async def test_one_that_will_not_exit_does_not_hang_the_caller(self):
  44. async def never():
  45. await asyncio.Event().wait()
  46. proc = _running_process(wait=never)
  47. await asyncio.wait_for(reap_ffmpeg(proc, timeout=0.05), 2)
  48. proc.kill.assert_called_once()
  49. class TestEveryCaptureReapsItsFfmpeg:
  50. @pytest.mark.asyncio
  51. async def test_external_rtsp_capture(self):
  52. proc = _running_process()
  53. with (
  54. patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  55. patch.object(external_camera, "_sanitize_camera_url", side_effect=lambda url, schemes: url),
  56. patch.object(external_camera, "read_ffmpeg_log_until_open", AsyncMock(side_effect=RuntimeError("boom"))),
  57. patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
  58. # Handled one level up, as before; what matters is the process.
  59. pytest.raises(RuntimeError),
  60. ):
  61. await external_camera._capture_rtsp_frame("rtsp://camera.example/stream", 5)
  62. proc.kill.assert_called_once()
  63. proc.wait.assert_awaited()
  64. @pytest.mark.asyncio
  65. async def test_external_rtsp_capture_cancelled(self):
  66. async def hang(_process):
  67. await asyncio.Event().wait()
  68. proc = _running_process()
  69. with (
  70. patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  71. patch.object(external_camera, "_sanitize_camera_url", side_effect=lambda url, schemes: url),
  72. patch.object(external_camera, "read_ffmpeg_log_until_open", hang),
  73. patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
  74. ):
  75. task = asyncio.create_task(external_camera._capture_rtsp_frame("rtsp://camera.example/stream", 5))
  76. await asyncio.sleep(0.05)
  77. task.cancel()
  78. with pytest.raises(asyncio.CancelledError):
  79. await task
  80. proc.kill.assert_called_once()
  81. @pytest.mark.asyncio
  82. async def test_usb_capture(self):
  83. proc = _running_process()
  84. with (
  85. patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  86. patch.object(external_camera, "_safe_usb_device_path", side_effect=lambda device: device),
  87. patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
  88. pytest.raises(RuntimeError),
  89. ):
  90. await external_camera._capture_usb_frame("/dev/video0", 5)
  91. proc.kill.assert_called_once()
  92. proc.wait.assert_awaited()
  93. @pytest.mark.asyncio
  94. async def test_printer_rtsp_capture(self):
  95. proc = _running_process()
  96. with (
  97. patch.object(camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  98. patch.object(camera, "create_tls_proxy", AsyncMock(return_value=(40000, None))),
  99. patch.object(camera, "close_tls_proxy", AsyncMock()),
  100. patch.object(camera, "read_ffmpeg_log_until_open", AsyncMock(side_effect=RuntimeError("boom"))),
  101. patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
  102. ):
  103. assert await camera._capture_camera_frame_bytes_uncoalesced("192.0.2.10", "code", "X1C", 5) is None
  104. proc.kill.assert_called_once()
  105. proc.wait.assert_awaited()
  106. @pytest.mark.asyncio
  107. async def test_last_frame_of_a_video(self, tmp_path):
  108. (tmp_path / "in.mp4").write_bytes(b"video")
  109. proc = _running_process()
  110. with (
  111. patch.object(camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  112. patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
  113. pytest.raises(RuntimeError),
  114. ):
  115. await camera.extract_video_last_frame(Path(tmp_path / "in.mp4"), Path(tmp_path / "out.jpg"))
  116. proc.kill.assert_called_once()
  117. proc.wait.assert_awaited()
  118. @pytest.mark.asyncio
  119. async def test_an_rtsp_capture_whose_ffmpeg_cannot_start_returns_none():
  120. """Nothing to reap, and the finally must not trip over the missing process."""
  121. with (
  122. patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  123. patch.object(external_camera, "_sanitize_camera_url", side_effect=lambda url, schemes: url),
  124. patch("asyncio.create_subprocess_exec", AsyncMock(side_effect=PermissionError("not executable"))),
  125. ):
  126. assert await external_camera._capture_rtsp_frame("rtsp://camera.example/stream", 5) is None
  127. @pytest.mark.asyncio
  128. async def test_a_killed_process_whose_output_lingers_is_not_called_still_running(caplog):
  129. """wait() also waits for the pipes; a dead child can still keep it waiting."""
  130. async def pipes_open():
  131. proc.returncode = -9
  132. await asyncio.Event().wait()
  133. proc = _running_process()
  134. proc.wait = pipes_open
  135. await reap_ffmpeg(proc, timeout=0.05)
  136. assert "did not exit" not in caplog.text
  137. assert "output did not close" in caplog.text