test_capture_pid_tracking.py 8.1 KB

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