test_capture_pid_tracking.py 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. """Tests for capture PID tracking and cleanup exclusion (#172).
  2. The Obico detection service spawns short-lived ffmpeg processes for snapshot
  3. capture via capture_camera_frame_bytes(). These must be registered in
  4. _active_capture_pids so the cleanup task in routes/camera.py does not kill
  5. them as orphaned.
  6. """
  7. import asyncio
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from backend.app.services.camera import (
  11. _active_capture_pids,
  12. capture_camera_frame_bytes,
  13. )
  14. from backend.tests._fixtures.external_camera import INPUT_OPEN_LOG, stream_of
  15. @pytest.fixture(autouse=True)
  16. def _clear_capture_pids():
  17. """Ensure _active_capture_pids is empty before/after each test."""
  18. _active_capture_pids.clear()
  19. yield
  20. _active_capture_pids.clear()
  21. class TestCapturePidRegistration:
  22. """Verify PIDs are added/removed from _active_capture_pids."""
  23. @pytest.mark.asyncio
  24. async def test_pid_registered_during_capture(self):
  25. """PID is in _active_capture_pids while ffmpeg is running."""
  26. observed_pids_during_run: set[int] = set()
  27. fake_process = MagicMock()
  28. fake_process.pid = 99999
  29. fake_process.returncode = 0
  30. fake_process.stderr = stream_of(INPUT_OPEN_LOG)
  31. async def fake_communicate():
  32. # Snapshot what's in the set while "ffmpeg is running"
  33. observed_pids_during_run.update(_active_capture_pids)
  34. return (b"\xff\xd8" + b"\x00" * 200 + b"\xff\xd9", b"")
  35. fake_process.communicate = fake_communicate
  36. fake_proxy_server = AsyncMock()
  37. fake_proxy_server.close = MagicMock()
  38. with (
  39. patch("backend.app.services.camera.is_chamber_image_model", return_value=False),
  40. patch("backend.app.services.camera.get_camera_port", return_value=322),
  41. patch("backend.app.services.camera.create_tls_proxy", return_value=(12345, fake_proxy_server)),
  42. patch("backend.app.services.camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  43. patch("asyncio.create_subprocess_exec", return_value=fake_process),
  44. ):
  45. result = await capture_camera_frame_bytes("192.168.1.1", "test", "P2S", timeout=10)
  46. # PID was registered during capture
  47. assert 99999 in observed_pids_during_run
  48. # PID is removed after capture completes
  49. assert 99999 not in _active_capture_pids
  50. # Capture returned data
  51. assert result is not None
  52. @pytest.mark.asyncio
  53. async def test_pid_removed_after_failure(self):
  54. """PID is cleaned up even when ffmpeg returns non-zero."""
  55. fake_process = MagicMock()
  56. fake_process.pid = 88888
  57. fake_process.returncode = 1
  58. fake_process.stderr = stream_of(b"some error\n")
  59. async def fake_communicate():
  60. return (b"", b"some error")
  61. fake_process.communicate = fake_communicate
  62. fake_proxy_server = AsyncMock()
  63. fake_proxy_server.close = MagicMock()
  64. with (
  65. patch("backend.app.services.camera.is_chamber_image_model", return_value=False),
  66. patch("backend.app.services.camera.get_camera_port", return_value=322),
  67. patch("backend.app.services.camera.create_tls_proxy", return_value=(12345, fake_proxy_server)),
  68. patch("backend.app.services.camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  69. patch("asyncio.create_subprocess_exec", return_value=fake_process),
  70. ):
  71. result = await capture_camera_frame_bytes("192.168.1.1", "test", "P2S", timeout=10)
  72. assert result is None
  73. assert 88888 not in _active_capture_pids
  74. @pytest.mark.asyncio
  75. async def test_pid_removed_after_timeout(self):
  76. """PID is cleaned up when ffmpeg times out."""
  77. fake_process = MagicMock()
  78. fake_process.pid = 77777
  79. fake_process.returncode = None
  80. fake_process.stderr = asyncio.StreamReader() # ffmpeg still connecting
  81. fake_process.kill = MagicMock()
  82. async def fake_communicate():
  83. await asyncio.sleep(60) # Will be cancelled by wait_for
  84. return (b"", b"")
  85. fake_process.communicate = fake_communicate
  86. async def fake_wait():
  87. fake_process.returncode = -9
  88. fake_process.wait = fake_wait
  89. fake_proxy_server = AsyncMock()
  90. fake_proxy_server.close = MagicMock()
  91. with (
  92. patch("backend.app.services.camera.is_chamber_image_model", return_value=False),
  93. patch("backend.app.services.camera.get_camera_port", return_value=322),
  94. patch("backend.app.services.camera.create_tls_proxy", return_value=(12345, fake_proxy_server)),
  95. patch("backend.app.services.camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  96. patch("asyncio.create_subprocess_exec", return_value=fake_process),
  97. ):
  98. result = await capture_camera_frame_bytes("192.168.1.1", "test", "P2S", timeout=0.01)
  99. assert result is None
  100. assert 77777 not in _active_capture_pids
  101. @pytest.mark.asyncio
  102. async def test_no_pid_tracked_for_chamber_image_models(self):
  103. """Chamber image models (A1/P1) don't spawn ffmpeg — no PID tracking."""
  104. with (
  105. patch("backend.app.services.camera.is_chamber_image_model", return_value=True),
  106. patch("backend.app.services.camera.read_chamber_image_frame", return_value=b"\xff\xd8test\xff\xd9"),
  107. ):
  108. result = await capture_camera_frame_bytes("192.168.1.1", "test", "A1", timeout=10)
  109. assert result is not None
  110. assert len(_active_capture_pids) == 0
  111. @pytest.mark.asyncio
  112. async def test_no_pid_tracked_when_subprocess_fails(self):
  113. """If create_subprocess_exec raises, process is None — no PID to track."""
  114. fake_proxy_server = AsyncMock()
  115. fake_proxy_server.close = MagicMock()
  116. with (
  117. patch("backend.app.services.camera.is_chamber_image_model", return_value=False),
  118. patch("backend.app.services.camera.get_camera_port", return_value=322),
  119. patch("backend.app.services.camera.create_tls_proxy", return_value=(12345, fake_proxy_server)),
  120. patch("backend.app.services.camera.get_ffmpeg_path", return_value="/usr/bin/ffmpeg"),
  121. patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("ffmpeg")),
  122. ):
  123. result = await capture_camera_frame_bytes("192.168.1.1", "test", "P2S", timeout=10)
  124. assert result is None
  125. assert len(_active_capture_pids) == 0
  126. class TestCleanupExcludesCapturePids:
  127. """Verify cleanup_orphaned_streams skips PIDs in _active_capture_pids."""
  128. @pytest.mark.asyncio
  129. async def test_cleanup_skips_capture_pids(self):
  130. """A PID in _active_capture_pids must not be killed by cleanup."""
  131. from backend.app.api.routes.camera import cleanup_orphaned_streams
  132. _active_capture_pids.add(42000)
  133. with (
  134. patch("backend.app.api.routes.camera._scan_bambu_ffmpeg_pids", return_value=[42000]),
  135. patch("backend.app.api.routes.camera._active_streams", {}),
  136. patch("backend.app.api.routes.camera._spawned_ffmpeg_pids", {}),
  137. patch("os.kill") as mock_kill,
  138. ):
  139. await cleanup_orphaned_streams()
  140. # os.kill should NOT have been called with SIGKILL for our capture PID
  141. for call in mock_kill.call_args_list:
  142. pid, sig = call[0]
  143. assert pid != 42000, "cleanup killed an active capture PID"
  144. @pytest.mark.asyncio
  145. async def test_cleanup_kills_non_capture_pids(self):
  146. """PIDs NOT in _active_capture_pids should still be killed."""
  147. import signal
  148. from backend.app.api.routes.camera import cleanup_orphaned_streams
  149. # 42000 is a capture PID, 43000 is truly orphaned
  150. _active_capture_pids.add(42000)
  151. with (
  152. patch("backend.app.api.routes.camera._scan_bambu_ffmpeg_pids", return_value=[42000, 43000]),
  153. patch("backend.app.api.routes.camera._active_streams", {}),
  154. patch("backend.app.api.routes.camera._spawned_ffmpeg_pids", {}),
  155. patch("os.kill") as mock_kill,
  156. ):
  157. await cleanup_orphaned_streams()
  158. # 43000 should have been killed
  159. mock_kill.assert_any_call(43000, signal.SIGKILL)
  160. # 42000 should NOT have been killed with SIGKILL
  161. killed_pids = [call[0][0] for call in mock_kill.call_args_list if call[0][1] == signal.SIGKILL]
  162. assert 42000 not in killed_pids