test_camera_stderr_summary.py 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. """Tests for _summarize_ffmpeg_stderr (#925).
  2. The ffmpeg banner (version / build / configuration / lib*) dumps ~20 lines
  3. before any actual error. Before this fix, every failed camera retry logged
  4. the full banner, producing hundreds of lines per failure — see #925 where a
  5. single click produced 555 lines across 30 retries. The helper strips the
  6. banner so logs stay focused on the real error.
  7. """
  8. import asyncio
  9. from backend.app.api.routes.camera import _read_ffmpeg_stderr, _summarize_ffmpeg_stderr
  10. _FAKE_BANNER = """ffmpeg version 7.1.3-0+deb13u1 Copyright (c) 2000-2025 the FFmpeg developers
  11. built with gcc 14 (Debian 14.2.0-19)
  12. configuration: --prefix=/usr --extra-version=0+deb13u1 --toolchain=hardened --enable-gpl --enable-gnutls
  13. libavutil 59. 39.100 / 59. 39.100
  14. libavcodec 61. 19.101 / 61. 19.101
  15. libavformat 61. 7.100 / 61. 7.100
  16. libavdevice 61. 3.100 / 61. 3.100
  17. libavfilter 10. 4.100 / 10. 4.100
  18. libswscale 8. 3.100 / 8. 3.100
  19. libswresample 5. 3.100 / 5. 3.100
  20. libpostproc 58. 3.100 / 58. 3.100
  21. """
  22. def test_empty_input():
  23. assert _summarize_ffmpeg_stderr("") == ""
  24. assert _summarize_ffmpeg_stderr(None) == ""
  25. def test_keeps_error_lines_drops_banner():
  26. stderr = _FAKE_BANNER + (
  27. "[in#0 @ 0x64a7cd6350c0] Error opening input: Invalid data found when processing input\n"
  28. "Error opening input file rtsp://[CREDENTIALS]@192.0.2.1:322/streaming/live/1.\n"
  29. "Error opening input files: Invalid data found when processing input\n"
  30. )
  31. result = _summarize_ffmpeg_stderr(stderr)
  32. # Banner gone
  33. assert "ffmpeg version" not in result
  34. assert "configuration:" not in result
  35. assert "libavcodec" not in result
  36. # Real errors preserved
  37. assert "Error opening input: Invalid data found when processing input" in result
  38. assert "Error opening input file rtsp" in result
  39. def test_caps_at_10_lines():
  40. stderr = _FAKE_BANNER + "\n".join(f"error line {i}" for i in range(25))
  41. result = _summarize_ffmpeg_stderr(stderr)
  42. lines = result.splitlines()
  43. assert len(lines) == 10
  44. # Keeps the *last* 10 lines (most recent errors closest to failure)
  45. assert lines[-1] == "error line 24"
  46. assert lines[0] == "error line 15"
  47. def test_drops_blank_lines():
  48. stderr = "real error\n\n\n \nsecond error\n"
  49. result = _summarize_ffmpeg_stderr(stderr)
  50. assert result == "real error\nsecond error"
  51. def test_banner_only_returns_empty():
  52. """If ffmpeg prints only the banner (no errors), the summary should be empty."""
  53. assert _summarize_ffmpeg_stderr(_FAKE_BANNER) == ""
  54. # --- _read_ffmpeg_stderr (#1395) -------------------------------------------
  55. # A stalled-but-alive ffmpeg (the P2S RTSP failure) never closes stderr, so a
  56. # read-to-EOF discarded everything it had already printed. _read_ffmpeg_stderr
  57. # now drains incrementally and must return that buffered output.
  58. class _FakeProcess:
  59. """Minimal stand-in for asyncio.subprocess.Process — only .stderr is read."""
  60. def __init__(self, stderr):
  61. self.stderr = stderr
  62. def _reader_with(data: bytes, *, eof: bool) -> asyncio.StreamReader:
  63. reader = asyncio.StreamReader()
  64. if data:
  65. reader.feed_data(data)
  66. if eof:
  67. reader.feed_eof()
  68. return reader
  69. async def test_read_stderr_captures_output_from_a_running_ffmpeg():
  70. """The #1395 regression: ffmpeg is alive and has NOT closed stderr (no EOF).
  71. The output it already printed must still be returned, not discarded while
  72. waiting for an EOF that never arrives."""
  73. stderr = _FAKE_BANNER + "[rtsp @ 0x5] Could not find codec parameters\n"
  74. proc = _FakeProcess(_reader_with(stderr.encode(), eof=False))
  75. result = await _read_ffmpeg_stderr(proc)
  76. assert result is not None
  77. assert "Could not find codec parameters" in result
  78. assert "ffmpeg version" not in result # banner still stripped
  79. async def test_read_stderr_captures_output_from_an_exited_ffmpeg():
  80. stderr = _FAKE_BANNER + "Error opening input: Connection refused\n"
  81. proc = _FakeProcess(_reader_with(stderr.encode(), eof=True))
  82. result = await _read_ffmpeg_stderr(proc)
  83. assert result is not None
  84. assert "Connection refused" in result
  85. async def test_read_stderr_returns_none_when_no_stderr_pipe():
  86. assert await _read_ffmpeg_stderr(_FakeProcess(None)) is None
  87. async def test_read_stderr_returns_none_for_banner_only_output():
  88. """Banner with no actionable lines summarizes to empty -> None."""
  89. proc = _FakeProcess(_reader_with(_FAKE_BANNER.encode(), eof=True))
  90. assert await _read_ffmpeg_stderr(proc) is None