test_camera_ffmpeg_termination.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  1. """ffmpeg teardown: draining the pipes, and the bounded waits behind it.
  2. Originally #2580 (fix shape from PR #2581 by @ronaldheft): the cleanup paths
  3. ``await process.wait()``-ed unbounded after ``kill()``, which on a P2S RTSP read
  4. timeout parked the fan-out stream coroutine for 12 hours, leaving every viewer
  5. attached to a stalled broadcaster while snapshots (fresh connections) kept
  6. working. Three places had it, all bounded now:
  7. 1. ``_terminate_ffmpeg`` — the stream generator's cleanup (the reported hang).
  8. 2. ``stop_camera`` — hung the very request a user makes to recover.
  9. 3. ``cleanup_orphaned_streams`` — hung the janitor that is the safety net.
  10. That diagnosis — "a SIGKILLed ffmpeg stuck in uninterruptible I/O" — turned out
  11. to be wrong, and the bound was capping a deadlock of our own making. ffmpeg was
  12. blocked writing to a stdout pipe nobody was reading, which makes SIGTERM
  13. unactionable, and ``wait()`` cannot observe an exit while a pipe transport is
  14. still undrained. So the abandon path fired on every camera close, costing 4s of
  15. the printer's single camera connection each time. The pipes are drained now; the
  16. bounds remain as backstops, and the tests for them stay valid.
  17. The draining tests below drive a REAL subprocess, because the failure is in
  18. asyncio's pipe/transport bookkeeping — a fake process object cannot reproduce
  19. it and would happily pass against the broken code.
  20. """
  21. from __future__ import annotations
  22. import asyncio
  23. import logging
  24. import sys
  25. import time
  26. from contextlib import suppress
  27. import pytest
  28. from backend.app.api.routes import camera
  29. pytestmark = pytest.mark.asyncio
  30. # Stands in for ffmpeg: floods stdout, and handles SIGTERM the way ffmpeg does
  31. # — a handler that sets a flag which only the main loop checks, so a process
  32. # blocked in write() never acts on it until something drains the pipe.
  33. _FFMPEG_LIKE = """
  34. import signal, sys
  35. stop = False
  36. def _handler(*_a):
  37. global stop
  38. stop = True
  39. signal.signal(signal.SIGTERM, _handler)
  40. sys.stderr.write("x" * 4096)
  41. sys.stderr.flush()
  42. while not stop:
  43. sys.stdout.buffer.write(b"x" * 65536)
  44. sys.stdout.buffer.flush()
  45. """
  46. # Same, but SIGTERM is ignored outright — forces the SIGKILL branch.
  47. _SIGTERM_PROOF = """
  48. import signal, sys
  49. signal.signal(signal.SIGTERM, signal.SIG_IGN)
  50. while True:
  51. sys.stdout.buffer.write(b"x" * 65536)
  52. sys.stdout.buffer.flush()
  53. """
  54. async def _spawn(program: str) -> asyncio.subprocess.Process:
  55. """Start the stand-in and let it fill its stdout pipe, as the cancel path
  56. leaves a real ffmpeg."""
  57. process = await asyncio.create_subprocess_exec(
  58. sys.executable,
  59. "-c",
  60. program,
  61. stdout=asyncio.subprocess.PIPE,
  62. stderr=asyncio.subprocess.PIPE,
  63. )
  64. await asyncio.sleep(0.4)
  65. return process
  66. class _FakeServer:
  67. def close(self) -> None:
  68. pass
  69. async def wait_closed(self) -> None:
  70. pass
  71. class _TimeoutReader:
  72. """stdout that immediately reports a read timeout (stalled RTSP)."""
  73. async def read(self, _size: int = -1) -> bytes:
  74. raise TimeoutError
  75. class _SingleFrameReader:
  76. """stdout that yields one complete JPEG then EOF."""
  77. def __init__(self) -> None:
  78. self._sent = False
  79. async def read(self, _size: int = -1) -> bytes:
  80. if self._sent:
  81. return b""
  82. self._sent = True
  83. return b"\xff\xd8fresh-frame\xff\xd9"
  84. class _StuckPostKillProcess:
  85. """ffmpeg whose post-kill wait() never completes unless cancelled."""
  86. def __init__(self, pid: int = 41001) -> None:
  87. self.pid = pid
  88. self.returncode = None
  89. self.stdout = _TimeoutReader()
  90. self.stderr = None
  91. self.wait_calls = 0
  92. self.killed = False
  93. self.post_kill_wait_cancelled = asyncio.Event()
  94. self._release = asyncio.Event()
  95. def terminate(self) -> None:
  96. pass
  97. def kill(self) -> None:
  98. self.killed = True
  99. async def wait(self) -> int:
  100. self.wait_calls += 1
  101. if self.wait_calls == 1:
  102. # Graceful-terminate window: simulate "didn't exit in time".
  103. raise TimeoutError
  104. try:
  105. await self._release.wait()
  106. except asyncio.CancelledError:
  107. self.post_kill_wait_cancelled.set()
  108. raise
  109. self.returncode = -9
  110. return self.returncode
  111. class _FrameProcess:
  112. """Healthy replacement ffmpeg delivering one frame."""
  113. def __init__(self, pid: int = 41002) -> None:
  114. self.pid = pid
  115. self.returncode = None
  116. self.stdout = _SingleFrameReader()
  117. self.stderr = None
  118. def terminate(self) -> None:
  119. pass
  120. def kill(self) -> None:
  121. pass
  122. async def wait(self) -> int:
  123. self.returncode = 0
  124. return self.returncode
  125. # ---------------------------------------------------------------------------
  126. # 0. _terminate_ffmpeg drains the pipes — against a real subprocess
  127. # ---------------------------------------------------------------------------
  128. async def test_terminate_drains_stdout_so_sigterm_works(caplog):
  129. """A process blocked writing to a full pipe still shuts down on SIGTERM.
  130. Undrained, this took the full grace period plus the SIGKILL bound (4s
  131. measured) and ended in the abandon error. Drained, SIGTERM lands.
  132. """
  133. process = await _spawn(_FFMPEG_LIKE)
  134. camera._spawned_ffmpeg_pids[process.pid] = time.time()
  135. with caplog.at_level(logging.WARNING, logger=camera.logger.name):
  136. started = time.monotonic()
  137. await asyncio.wait_for(camera._terminate_ffmpeg(process, "test-drain"), timeout=5.0)
  138. elapsed = time.monotonic() - started
  139. assert process.returncode is not None, "wait() must observe the exit"
  140. # Comfortably under the 2.0s grace period: proves SIGTERM was acted on
  141. # rather than timing out into the kill branch.
  142. assert elapsed < 1.5, f"teardown took {elapsed:.2f}s — pipes likely not drained"
  143. assert "didn't terminate gracefully" not in caplog.text
  144. assert "abandoning wait" not in caplog.text
  145. assert process.pid not in camera._spawned_ffmpeg_pids
  146. async def test_terminate_observes_kill_of_a_sigterm_proof_process(monkeypatch, caplog):
  147. """Even when SIGTERM is genuinely ignored, wait() must see the SIGKILL.
  148. This is the case the abandon error was invented for. With the pipes drained
  149. the exit is observable, so it must not fire.
  150. """
  151. monkeypatch.setattr(camera, "_FFMPEG_TERM_TIMEOUT", 0.3)
  152. process = await _spawn(_SIGTERM_PROOF)
  153. camera._spawned_ffmpeg_pids[process.pid] = time.time()
  154. with caplog.at_level(logging.WARNING, logger=camera.logger.name):
  155. await asyncio.wait_for(camera._terminate_ffmpeg(process, "test-kill"), timeout=5.0)
  156. assert process.returncode == -9, "SIGKILLed exit must be observed, not abandoned"
  157. assert "didn't terminate gracefully" in caplog.text # SIGTERM really was ignored
  158. assert "abandoning wait" not in caplog.text
  159. assert process.pid not in camera._spawned_ffmpeg_pids
  160. async def test_terminate_is_a_noop_for_an_already_dead_process():
  161. """The early return must still drop the pid from the tracking dict."""
  162. process = await asyncio.create_subprocess_exec(sys.executable, "-c", "pass")
  163. await process.wait()
  164. camera._spawned_ffmpeg_pids[process.pid] = time.time()
  165. await asyncio.wait_for(camera._terminate_ffmpeg(process, "test-dead"), timeout=2.0)
  166. assert process.pid not in camera._spawned_ffmpeg_pids
  167. # ---------------------------------------------------------------------------
  168. # 1. _terminate_ffmpeg — the helper itself is bounded
  169. # ---------------------------------------------------------------------------
  170. async def test_terminate_ffmpeg_abandons_unreaped_kill(monkeypatch):
  171. monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
  172. proc = _StuckPostKillProcess()
  173. # Must return promptly instead of hanging on the post-kill wait.
  174. await asyncio.wait_for(camera._terminate_ffmpeg(proc, "test"), timeout=1.0)
  175. assert proc.killed is True
  176. assert proc.post_kill_wait_cancelled.is_set()
  177. assert proc.pid not in camera._spawned_ffmpeg_pids
  178. # ---------------------------------------------------------------------------
  179. # 2. Stream generator — reconnects instead of pinning the fan-out pump
  180. # (regression scenario from PR #2581)
  181. # ---------------------------------------------------------------------------
  182. async def test_rtsp_stream_reconnects_past_unreaped_ffmpeg(monkeypatch):
  183. """RTSP read timeout → kill hangs → generator must still spawn a fresh
  184. ffmpeg and deliver a frame, not block in cleanup forever."""
  185. stalled = _StuckPostKillProcess()
  186. recovered = _FrameProcess()
  187. processes = iter((stalled, recovered))
  188. spawned: list[object] = []
  189. async def fake_create_subprocess_exec(*_args, **_kwargs):
  190. process = next(processes)
  191. spawned.append(process)
  192. return process
  193. async def fake_create_tls_proxy(_ip_address: str, _port: int):
  194. return 48521, _FakeServer()
  195. monkeypatch.setattr(camera, "get_ffmpeg_path", lambda: "/fake/ffmpeg")
  196. monkeypatch.setattr(camera, "create_tls_proxy", fake_create_tls_proxy)
  197. monkeypatch.setattr(camera.asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
  198. monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.01)
  199. stream = camera.generate_rtsp_mjpeg_stream(
  200. ip_address="192.0.2.17",
  201. access_code="test-code",
  202. model="P2S",
  203. fps=15,
  204. stream_id="9999-fanout",
  205. disconnect_event=asyncio.Event(),
  206. printer_id=9999,
  207. )
  208. try:
  209. chunk = await asyncio.wait_for(anext(stream), timeout=5.0)
  210. assert b"fresh-frame" in chunk
  211. assert stalled.killed is True
  212. assert stalled.post_kill_wait_cancelled.is_set()
  213. assert len(spawned) == 2, "expected a replacement ffmpeg to be spawned"
  214. finally:
  215. stalled._release.set()
  216. with suppress(Exception):
  217. await asyncio.wait_for(stream.aclose(), timeout=2.0)
  218. # ---------------------------------------------------------------------------
  219. # 3. Janitor — cleanup_orphaned_streams must not hang on an unreaped process
  220. # ---------------------------------------------------------------------------
  221. async def test_cleanup_orphaned_streams_bounded_on_unreaped_process(monkeypatch):
  222. monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
  223. monkeypatch.setattr(camera, "_scan_bambu_ffmpeg_pids", lambda: [])
  224. import os
  225. # Real pid: janitor layer 2 prunes _spawned_ffmpeg_pids entries whose pid
  226. # doesn't exist (os.kill(pid, 0)), which would reset the spawn age and
  227. # skip the stale-stream kill below.
  228. proc = _StuckPostKillProcess(pid=os.getpid())
  229. proc.wait_calls = 1 # skip the graceful-terminate branch; janitor kills directly
  230. sid = "9998-fanout"
  231. now = time.time()
  232. camera._active_streams[sid] = proc
  233. camera._spawned_ffmpeg_pids[proc.pid] = now - 120 # spawned long ago
  234. camera._stream_last_frame_times[sid] = now - 60 # stale: no frames >30s
  235. try:
  236. # Must complete despite proc.wait() never returning.
  237. await asyncio.wait_for(camera.cleanup_orphaned_streams(), timeout=2.0)
  238. assert proc.killed is True
  239. assert sid not in camera._active_streams
  240. assert proc.pid not in camera._spawned_ffmpeg_pids
  241. finally:
  242. proc._release.set()
  243. camera._active_streams.pop(sid, None)
  244. camera._spawned_ffmpeg_pids.pop(proc.pid, None)
  245. camera._stream_last_frame_times.pop(sid, None)
  246. camera._disconnect_events.pop(sid, None)