test_ffmpeg_rtsp_timeout_flag.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. """Regression for #1504: ffmpeg RTSP socket-I/O timeout flag.
  2. The RTSP demuxer's client-side socket I/O timeout option name varies by
  3. ffmpeg version (full chronology in
  4. `backend/app/services/camera.rtsp_socket_timeout_flag`). Hard-coding
  5. either ``-timeout`` or ``-stimeout`` regresses one half of the install
  6. base. The flag is therefore probed at runtime; this module tests that
  7. probe and guards against either RTSP ffmpeg argv re-hard-coding the
  8. wrong literal.
  9. """
  10. from pathlib import Path
  11. from unittest.mock import patch
  12. import pytest
  13. import backend.app.services.camera as camera_svc
  14. from backend.app.services.camera import rtsp_socket_timeout_flag
  15. @pytest.fixture(autouse=True)
  16. def _reset_cache():
  17. """The probe caches its result in a module-level global. Reset it
  18. before every test so each one sees a fresh probe."""
  19. camera_svc._rtsp_socket_timeout_flag = None
  20. yield
  21. camera_svc._rtsp_socket_timeout_flag = None
  22. class TestRtspSocketTimeoutFlagProbe:
  23. def test_prefers_stimeout_when_ffmpeg_advertises_it(self):
  24. """Transitional ffmpeg (~late-4.x): both options are listed and
  25. ``-timeout`` is the broken listen-mode option — pick ``-stimeout``."""
  26. transitional_help = (
  27. " -listen_timeout <int> ... incoming connections ...\n"
  28. " -stimeout <int64> ... socket TCP I/O ...\n"
  29. " -timeout <int> ... DEPRECATED ...\n"
  30. )
  31. with (
  32. patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  33. patch("backend.app.services.camera.subprocess.run") as mock_run,
  34. ):
  35. mock_run.return_value.stdout = transitional_help
  36. mock_run.return_value.stderr = ""
  37. assert rtsp_socket_timeout_flag() == "stimeout"
  38. def test_falls_back_to_timeout_on_modern_ffmpeg(self):
  39. """Modern ffmpeg (5+/6+/7+): ``-stimeout`` no longer exists and
  40. ``-timeout`` is back to meaning socket I/O — pick ``-timeout``."""
  41. modern_help = (
  42. " -listen_timeout <int> ... incoming connections ...\n"
  43. " -timeout <int64> ... socket I/O ...\n"
  44. " -reorder_queue_size <int> ... reordered packets ...\n"
  45. )
  46. with (
  47. patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  48. patch("backend.app.services.camera.subprocess.run") as mock_run,
  49. ):
  50. mock_run.return_value.stdout = modern_help
  51. mock_run.return_value.stderr = ""
  52. assert rtsp_socket_timeout_flag() == "timeout"
  53. def test_defaults_to_timeout_when_ffmpeg_missing(self):
  54. """No ffmpeg available — return the modern default so we don't
  55. wedge ffmpeg-less unit tests trying to import camera.py."""
  56. with patch.object(camera_svc, "get_ffmpeg_path", return_value=None):
  57. assert rtsp_socket_timeout_flag() == "timeout"
  58. def test_defaults_to_timeout_when_probe_raises(self):
  59. """If subprocess probe blows up, prefer the modern default —
  60. breaking the transitional-ffmpeg case is preferable to crashing
  61. every live-view start."""
  62. with (
  63. patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  64. patch("backend.app.services.camera.subprocess.run", side_effect=OSError("boom")),
  65. ):
  66. assert rtsp_socket_timeout_flag() == "timeout"
  67. def test_result_is_cached_across_calls(self):
  68. """Probing ffmpeg is a subprocess spawn; cache it for the
  69. process lifetime (ffmpeg won't swap mid-run)."""
  70. with (
  71. patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  72. patch("backend.app.services.camera.subprocess.run") as mock_run,
  73. ):
  74. mock_run.return_value.stdout = " -timeout <int64>\n"
  75. mock_run.return_value.stderr = ""
  76. rtsp_socket_timeout_flag()
  77. rtsp_socket_timeout_flag()
  78. rtsp_socket_timeout_flag()
  79. assert mock_run.call_count == 1
  80. def test_substring_match_does_not_false_positive(self):
  81. """Match the option as ``-stimeout `` (trailing space) so an
  82. unrelated mention like ``-listen_timeout`` or a fragment in
  83. another section doesn't trick us into picking the missing flag."""
  84. only_listen_help = (
  85. " -listen_timeout <int> ... incoming connections ...\n"
  86. " -timeout <int64> ... socket I/O ...\n"
  87. )
  88. with (
  89. patch.object(camera_svc, "get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  90. patch("backend.app.services.camera.subprocess.run") as mock_run,
  91. ):
  92. mock_run.return_value.stdout = only_listen_help
  93. mock_run.return_value.stderr = ""
  94. assert rtsp_socket_timeout_flag() == "timeout"
  95. class TestRtspArgvUsesProbe:
  96. """The two RTSP ffmpeg callers must not hard-code either flag literal —
  97. they must consume the probe so version-dependent correctness is
  98. preserved. Guards #1504 from being half-fixed again."""
  99. # Anchor on this file so the assertion is CWD-independent (pytest can
  100. # be invoked from the project root OR from backend/, depending on who
  101. # runs it). __file__ lives at backend/tests/unit/, so the repo root
  102. # is three parents up.
  103. _REPO_ROOT = Path(__file__).resolve().parents[3]
  104. _RTSP_FFMPEG_CALLERS = (
  105. "backend/app/api/routes/camera.py",
  106. "backend/app/services/external_camera.py",
  107. )
  108. @pytest.mark.parametrize("rel", _RTSP_FFMPEG_CALLERS)
  109. def test_no_hard_coded_timeout_literal(self, rel):
  110. """Neither RTSP ffmpeg argv may pass a hard-coded ``-timeout``
  111. or ``-stimeout`` literal — both must come from the probe."""
  112. src = (self._REPO_ROOT / rel).read_text()
  113. assert '"-timeout"' not in src, (
  114. f"{rel} hard-codes `-timeout` — this is the listen-mode option on "
  115. f"transitional ffmpeg (EADDRINUSE, #1504). Use rtsp_socket_timeout_flag()."
  116. )
  117. assert '"-stimeout"' not in src, (
  118. f"{rel} hard-codes `-stimeout` — this option was removed in ffmpeg 7. Use rtsp_socket_timeout_flag()."
  119. )
  120. assert "rtsp_socket_timeout_flag()" in src, (
  121. f"{rel} should derive its RTSP socket timeout flag from rtsp_socket_timeout_flag() — see #1504."
  122. )