| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168 |
- """A one-shot ffmpeg never outlives the capture that started it (#3292).
- The camera captures killed their ffmpeg on their own deadline and nowhere
- else: an error while reading its output, or the capture being cancelled, left
- it running. ffmpeg shares Bambuddy's memory limit in a container, so each one
- left behind counts against it until the container restarts.
- """
- import asyncio
- from pathlib import Path
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from backend.app.services import camera, external_camera
- from backend.app.utils.ffmpeg_output import reap_ffmpeg
- def _running_process(**overrides) -> MagicMock:
- proc = MagicMock()
- proc.pid = 4242
- proc.returncode = None
- proc.wait = AsyncMock(return_value=-9)
- proc.communicate = AsyncMock(side_effect=RuntimeError("pipe closed"))
- for key, value in overrides.items():
- setattr(proc, key, value)
- return proc
- class TestReapFfmpeg:
- @pytest.mark.asyncio
- async def test_a_running_process_is_killed_and_waited_for(self):
- proc = _running_process()
- await reap_ffmpeg(proc)
- proc.kill.assert_called_once()
- proc.wait.assert_awaited()
- @pytest.mark.asyncio
- async def test_a_finished_process_is_left_alone(self):
- proc = _running_process(returncode=0)
- await reap_ffmpeg(proc)
- proc.kill.assert_not_called()
- @pytest.mark.asyncio
- async def test_nothing_to_reap(self):
- await reap_ffmpeg(None)
- @pytest.mark.asyncio
- async def test_a_process_gone_meanwhile_is_fine(self):
- proc = _running_process()
- proc.kill.side_effect = ProcessLookupError
- await reap_ffmpeg(proc)
- @pytest.mark.asyncio
- async def test_one_that_will_not_exit_does_not_hang_the_caller(self):
- async def never():
- await asyncio.Event().wait()
- proc = _running_process(wait=never)
- await asyncio.wait_for(reap_ffmpeg(proc, timeout=0.05), 2)
- proc.kill.assert_called_once()
- class TestEveryCaptureReapsItsFfmpeg:
- @pytest.mark.asyncio
- async def test_external_rtsp_capture(self):
- proc = _running_process()
- with (
- patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
- patch.object(external_camera, "_sanitize_camera_url", side_effect=lambda url, schemes: url),
- patch.object(external_camera, "read_ffmpeg_log_until_open", AsyncMock(side_effect=RuntimeError("boom"))),
- patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
- # Handled one level up, as before; what matters is the process.
- pytest.raises(RuntimeError),
- ):
- await external_camera._capture_rtsp_frame("rtsp://camera.example/stream", 5)
- proc.kill.assert_called_once()
- proc.wait.assert_awaited()
- @pytest.mark.asyncio
- async def test_external_rtsp_capture_cancelled(self):
- async def hang(_process):
- await asyncio.Event().wait()
- proc = _running_process()
- with (
- patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
- patch.object(external_camera, "_sanitize_camera_url", side_effect=lambda url, schemes: url),
- patch.object(external_camera, "read_ffmpeg_log_until_open", hang),
- patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
- ):
- task = asyncio.create_task(external_camera._capture_rtsp_frame("rtsp://camera.example/stream", 5))
- await asyncio.sleep(0.05)
- task.cancel()
- with pytest.raises(asyncio.CancelledError):
- await task
- proc.kill.assert_called_once()
- @pytest.mark.asyncio
- async def test_usb_capture(self):
- proc = _running_process()
- with (
- patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
- patch.object(external_camera, "_safe_usb_device_path", side_effect=lambda device: device),
- patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
- pytest.raises(RuntimeError),
- ):
- await external_camera._capture_usb_frame("/dev/video0", 5)
- proc.kill.assert_called_once()
- proc.wait.assert_awaited()
- @pytest.mark.asyncio
- async def test_printer_rtsp_capture(self):
- proc = _running_process()
- with (
- patch.object(camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
- patch.object(camera, "create_tls_proxy", AsyncMock(return_value=(40000, None))),
- patch.object(camera, "close_tls_proxy", AsyncMock()),
- patch.object(camera, "read_ffmpeg_log_until_open", AsyncMock(side_effect=RuntimeError("boom"))),
- patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
- ):
- assert await camera._capture_camera_frame_bytes_uncoalesced("192.0.2.10", "code", "X1C", 5) is None
- proc.kill.assert_called_once()
- proc.wait.assert_awaited()
- @pytest.mark.asyncio
- async def test_last_frame_of_a_video(self, tmp_path):
- (tmp_path / "in.mp4").write_bytes(b"video")
- proc = _running_process()
- with (
- patch.object(camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
- patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)),
- pytest.raises(RuntimeError),
- ):
- await camera.extract_video_last_frame(Path(tmp_path / "in.mp4"), Path(tmp_path / "out.jpg"))
- proc.kill.assert_called_once()
- proc.wait.assert_awaited()
- @pytest.mark.asyncio
- async def test_an_rtsp_capture_whose_ffmpeg_cannot_start_returns_none():
- """Nothing to reap, and the finally must not trip over the missing process."""
- with (
- patch.object(external_camera, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
- patch.object(external_camera, "_sanitize_camera_url", side_effect=lambda url, schemes: url),
- patch("asyncio.create_subprocess_exec", AsyncMock(side_effect=PermissionError("not executable"))),
- ):
- assert await external_camera._capture_rtsp_frame("rtsp://camera.example/stream", 5) is None
- @pytest.mark.asyncio
- async def test_a_killed_process_whose_output_lingers_is_not_called_still_running(caplog):
- """wait() also waits for the pipes; a dead child can still keep it waiting."""
- async def pipes_open():
- proc.returncode = -9
- await asyncio.Event().wait()
- proc = _running_process()
- proc.wait = pipes_open
- await reap_ffmpeg(proc, timeout=0.05)
- assert "did not exit" not in caplog.text
- assert "output did not close" in caplog.text
|