test_camera_ffmpeg_termination.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. """Bounded post-kill ffmpeg cleanup (#2580, fix shape from PR #2581 by @ronaldheft).
  2. A SIGKILLed ffmpeg stuck in uninterruptible I/O on a dead RTSP socket can take
  3. arbitrarily long to be reaped. The cleanup paths used to ``await process.wait()``
  4. unbounded after ``kill()`` — on a P2S RTSP read timeout this parked the fan-out
  5. stream coroutine for 12 hours, leaving every viewer attached to a stalled
  6. broadcaster while snapshots/diagnostics (fresh connections) kept working.
  7. The same unbounded wait existed in THREE places, all bounded now:
  8. 1. ``_terminate_ffmpeg`` — the stream generator's cleanup (the reported hang).
  9. 2. ``stop_camera`` — hung the very request a user makes to recover.
  10. 3. ``cleanup_orphaned_streams`` — hung the janitor that is the safety net.
  11. """
  12. from __future__ import annotations
  13. import asyncio
  14. import time
  15. from contextlib import suppress
  16. import pytest
  17. from backend.app.api.routes import camera
  18. pytestmark = pytest.mark.asyncio
  19. class _FakeServer:
  20. def close(self) -> None:
  21. pass
  22. async def wait_closed(self) -> None:
  23. pass
  24. class _TimeoutReader:
  25. """stdout that immediately reports a read timeout (stalled RTSP)."""
  26. async def read(self, _size: int = -1) -> bytes:
  27. raise TimeoutError
  28. class _SingleFrameReader:
  29. """stdout that yields one complete JPEG then EOF."""
  30. def __init__(self) -> None:
  31. self._sent = False
  32. async def read(self, _size: int = -1) -> bytes:
  33. if self._sent:
  34. return b""
  35. self._sent = True
  36. return b"\xff\xd8fresh-frame\xff\xd9"
  37. class _StuckPostKillProcess:
  38. """ffmpeg whose post-kill wait() never completes unless cancelled."""
  39. def __init__(self, pid: int = 41001) -> None:
  40. self.pid = pid
  41. self.returncode = None
  42. self.stdout = _TimeoutReader()
  43. self.stderr = None
  44. self.wait_calls = 0
  45. self.killed = False
  46. self.post_kill_wait_cancelled = asyncio.Event()
  47. self._release = asyncio.Event()
  48. def terminate(self) -> None:
  49. pass
  50. def kill(self) -> None:
  51. self.killed = True
  52. async def wait(self) -> int:
  53. self.wait_calls += 1
  54. if self.wait_calls == 1:
  55. # Graceful-terminate window: simulate "didn't exit in time".
  56. raise TimeoutError
  57. try:
  58. await self._release.wait()
  59. except asyncio.CancelledError:
  60. self.post_kill_wait_cancelled.set()
  61. raise
  62. self.returncode = -9
  63. return self.returncode
  64. class _FrameProcess:
  65. """Healthy replacement ffmpeg delivering one frame."""
  66. def __init__(self, pid: int = 41002) -> None:
  67. self.pid = pid
  68. self.returncode = None
  69. self.stdout = _SingleFrameReader()
  70. self.stderr = None
  71. def terminate(self) -> None:
  72. pass
  73. def kill(self) -> None:
  74. pass
  75. async def wait(self) -> int:
  76. self.returncode = 0
  77. return self.returncode
  78. # ---------------------------------------------------------------------------
  79. # 1. _terminate_ffmpeg — the helper itself is bounded
  80. # ---------------------------------------------------------------------------
  81. async def test_terminate_ffmpeg_abandons_unreaped_kill(monkeypatch):
  82. monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
  83. proc = _StuckPostKillProcess()
  84. # Must return promptly instead of hanging on the post-kill wait.
  85. await asyncio.wait_for(camera._terminate_ffmpeg(proc, "test"), timeout=1.0)
  86. assert proc.killed is True
  87. assert proc.post_kill_wait_cancelled.is_set()
  88. assert proc.pid not in camera._spawned_ffmpeg_pids
  89. # ---------------------------------------------------------------------------
  90. # 2. Stream generator — reconnects instead of pinning the fan-out pump
  91. # (regression scenario from PR #2581)
  92. # ---------------------------------------------------------------------------
  93. async def test_rtsp_stream_reconnects_past_unreaped_ffmpeg(monkeypatch):
  94. """RTSP read timeout → kill hangs → generator must still spawn a fresh
  95. ffmpeg and deliver a frame, not block in cleanup forever."""
  96. stalled = _StuckPostKillProcess()
  97. recovered = _FrameProcess()
  98. processes = iter((stalled, recovered))
  99. spawned: list[object] = []
  100. async def fake_create_subprocess_exec(*_args, **_kwargs):
  101. process = next(processes)
  102. spawned.append(process)
  103. return process
  104. async def fake_create_tls_proxy(_ip_address: str, _port: int):
  105. return 48521, _FakeServer()
  106. monkeypatch.setattr(camera, "get_ffmpeg_path", lambda: "/fake/ffmpeg")
  107. monkeypatch.setattr(camera, "create_tls_proxy", fake_create_tls_proxy)
  108. monkeypatch.setattr(camera.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
  109. monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.01)
  110. stream = camera.generate_rtsp_mjpeg_stream(
  111. ip_address="192.0.2.17",
  112. access_code="test-code",
  113. model="P2S",
  114. fps=15,
  115. stream_id="9999-fanout",
  116. disconnect_event=asyncio.Event(),
  117. printer_id=9999,
  118. )
  119. try:
  120. chunk = await asyncio.wait_for(anext(stream), timeout=5.0)
  121. assert b"fresh-frame" in chunk
  122. assert stalled.killed is True
  123. assert stalled.post_kill_wait_cancelled.is_set()
  124. assert len(spawned) == 2, "expected a replacement ffmpeg to be spawned"
  125. finally:
  126. stalled._release.set()
  127. with suppress(Exception):
  128. await asyncio.wait_for(stream.aclose(), timeout=2.0)
  129. # ---------------------------------------------------------------------------
  130. # 3. Janitor — cleanup_orphaned_streams must not hang on an unreaped process
  131. # ---------------------------------------------------------------------------
  132. async def test_cleanup_orphaned_streams_bounded_on_unreaped_process(monkeypatch):
  133. monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
  134. monkeypatch.setattr(camera, "_scan_bambu_ffmpeg_pids", lambda: [])
  135. import os
  136. # Real pid: janitor layer 2 prunes _spawned_ffmpeg_pids entries whose pid
  137. # doesn't exist (os.kill(pid, 0)), which would reset the spawn age and
  138. # skip the stale-stream kill below.
  139. proc = _StuckPostKillProcess(pid=os.getpid())
  140. proc.wait_calls = 1 # skip the graceful-terminate branch; janitor kills directly
  141. sid = "9998-fanout"
  142. now = time.time()
  143. camera._active_streams[sid] = proc
  144. camera._spawned_ffmpeg_pids[proc.pid] = now - 120 # spawned long ago
  145. camera._stream_last_frame_times[sid] = now - 60 # stale: no frames >30s
  146. try:
  147. # Must complete despite proc.wait() never returning.
  148. await asyncio.wait_for(camera.cleanup_orphaned_streams(), timeout=2.0)
  149. assert proc.killed is True
  150. assert sid not in camera._active_streams
  151. assert proc.pid not in camera._spawned_ffmpeg_pids
  152. finally:
  153. proc._release.set()
  154. camera._active_streams.pop(sid, None)
  155. camera._spawned_ffmpeg_pids.pop(proc.pid, None)
  156. camera._stream_last_frame_times.pop(sid, None)
  157. camera._disconnect_events.pop(sid, None)