test_external_camera_capture_coalescing.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. """Single-flight coalescing of one-shot external-camera captures (#2705-shape
  2. fix, filed against the external-camera path as a follow-up on #2707).
  3. V4L2 USB devices allow exactly one open handle - the same one-connection
  4. limit #2705 covers for Bambu firmware. The #2707 guards (``is_stream_active``
  5. / ``try_get_active_buffered_frame``) only keep a one-shot capturer from
  6. competing with the fan-out live view; nothing kept the capturers from
  7. competing with EACH OTHER when no viewer is attached, so an Obico poll and
  8. the in-print frame bank (say) could each open their own connection to the
  9. same USB device and collide.
  10. These tests drive ``capture_frame`` at the public boundary and count how
  11. many times the underlying capture ran, since "how many connections did we
  12. open" is the entire point of the fix. Mirrors
  13. ``test_camera_capture_coalescing.py``'s structure for the built-in path.
  14. """
  15. import asyncio
  16. import pytest
  17. from backend.app.services import external_camera as ec_module
  18. from backend.app.services.external_camera import capture_frame, capture_in_flight
  19. FRAME_A = b"\xff\xd8" + b"a" * 200 + b"\xff\xd9"
  20. FRAME_B = b"\xff\xd8" + b"b" * 200 + b"\xff\xd9"
  21. @pytest.fixture(autouse=True)
  22. def _clear_inflight():
  23. """The registry is module-global; don't leak tasks between tests."""
  24. ec_module._inflight_captures.clear()
  25. yield
  26. ec_module._inflight_captures.clear()
  27. class RecordingCapture:
  28. """Stand-in for the real capture, recording each call.
  29. ``gate`` (when set) holds every capture open until released, which is how
  30. these tests create the overlap window that used to produce two
  31. connections.
  32. """
  33. def __init__(self, frames=(FRAME_A, FRAME_B), gate: asyncio.Event | None = None):
  34. self.calls: list[tuple[str, str, str | None, int]] = []
  35. self._frames = list(frames)
  36. self._gate = gate
  37. self.started = asyncio.Event()
  38. async def __call__(self, url, camera_type, timeout, snapshot_url):
  39. self.calls.append((url, camera_type, snapshot_url, timeout))
  40. self.started.set()
  41. if self._gate is not None:
  42. await self._gate.wait()
  43. return self._frames.pop(0) if self._frames else None
  44. @property
  45. def count(self) -> int:
  46. return len(self.calls)
  47. @pytest.fixture
  48. def patch_capture(monkeypatch):
  49. def _install(capture):
  50. monkeypatch.setattr(ec_module, "_capture_frame_uncoalesced", capture)
  51. return capture
  52. return _install
  53. async def _let_leader_start(capture: RecordingCapture) -> None:
  54. """Wait until the leader is inside the capture, so the next caller joins it.
  55. Without this the second caller can reach the registry before the first
  56. has even been scheduled, which tests a different (and uninteresting) race.
  57. """
  58. await asyncio.wait_for(capture.started.wait(), timeout=1)
  59. @pytest.mark.asyncio
  60. async def test_simultaneous_callers_share_one_capture(patch_capture):
  61. """The reported collision: two consumers, one connection, two frames."""
  62. gate = asyncio.Event()
  63. capture = patch_capture(RecordingCapture(gate=gate))
  64. leader = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=20))
  65. await _let_leader_start(capture)
  66. follower = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=15))
  67. await asyncio.sleep(0)
  68. gate.set()
  69. assert await leader == FRAME_A
  70. assert await follower == FRAME_A
  71. assert capture.count == 1
  72. @pytest.mark.asyncio
  73. async def test_five_callers_one_capture(patch_capture):
  74. gate = asyncio.Event()
  75. capture = patch_capture(RecordingCapture(gate=gate))
  76. first = asyncio.create_task(capture_frame("/dev/video1", "usb"))
  77. await _let_leader_start(capture)
  78. rest = [asyncio.create_task(capture_frame("/dev/video1", "usb")) for _ in range(4)]
  79. await asyncio.sleep(0)
  80. gate.set()
  81. assert await asyncio.gather(first, *rest) == [FRAME_A] * 5
  82. assert capture.count == 1
  83. @pytest.mark.asyncio
  84. async def test_different_cameras_do_not_coalesce(patch_capture):
  85. """The one-connection limit is per camera, so the key must be too."""
  86. gate = asyncio.Event()
  87. capture = patch_capture(RecordingCapture(gate=gate))
  88. one = asyncio.create_task(capture_frame("/dev/video1", "usb"))
  89. await _let_leader_start(capture)
  90. two = asyncio.create_task(capture_frame("/dev/video2", "usb"))
  91. await asyncio.sleep(0)
  92. gate.set()
  93. assert {await one, await two} == {FRAME_A, FRAME_B}
  94. assert capture.count == 2
  95. assert {url for url, *_ in capture.calls} == {"/dev/video1", "/dev/video2"}
  96. @pytest.mark.asyncio
  97. async def test_different_snapshot_url_does_not_coalesce(patch_capture):
  98. """#1177's snapshot_url override routes to a different endpoint entirely -
  99. two printers sharing a camera_url but differing only in snapshot_url must
  100. not share a capture."""
  101. gate = asyncio.Event()
  102. capture = patch_capture(RecordingCapture(gate=gate))
  103. one = asyncio.create_task(capture_frame("http://cam/", "mjpeg", snapshot_url="http://cam/frame1.jpg"))
  104. await _let_leader_start(capture)
  105. two = asyncio.create_task(capture_frame("http://cam/", "mjpeg", snapshot_url="http://cam/frame2.jpg"))
  106. await asyncio.sleep(0)
  107. gate.set()
  108. assert {await one, await two} == {FRAME_A, FRAME_B}
  109. assert capture.count == 2
  110. @pytest.mark.asyncio
  111. async def test_coalescing_is_not_caching(patch_capture):
  112. """Sequential callers each capture fresh.
  113. Deliberate: plate detection and the finish-photo path decide things about
  114. a running print from these frames, and #1397 was a finish photo a few
  115. seconds stale showing the bed already lowered.
  116. """
  117. capture = patch_capture(RecordingCapture())
  118. assert await capture_frame("/dev/video1", "usb") == FRAME_A
  119. assert await capture_frame("/dev/video1", "usb") == FRAME_B
  120. assert capture.count == 2
  121. @pytest.mark.asyncio
  122. async def test_registry_is_empty_after_a_capture_finishes(patch_capture):
  123. """No leak, and nothing left behind for the next caller to join."""
  124. patch_capture(RecordingCapture())
  125. await capture_frame("/dev/video1", "usb")
  126. await asyncio.sleep(0) # let the done-callback run
  127. assert ec_module._inflight_captures == {}
  128. assert capture_in_flight("/dev/video1", "usb") is False
  129. @pytest.mark.asyncio
  130. async def test_failed_leader_does_not_poison_its_followers(patch_capture):
  131. """A follower that never got its own attempt gets one when the leader fails.
  132. Safe by then: the leader has finished, so there is no connection to
  133. compete with. This also covers the follower whose timeout is LONGER than
  134. the leader's — it isn't cut short by someone else's deadline.
  135. """
  136. gate = asyncio.Event()
  137. capture = patch_capture(RecordingCapture(frames=(None, FRAME_B), gate=gate))
  138. leader = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=10))
  139. await _let_leader_start(capture)
  140. follower = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=20))
  141. await asyncio.sleep(0)
  142. gate.set()
  143. assert await leader is None
  144. assert await follower == FRAME_B
  145. assert capture.count == 2
  146. @pytest.mark.asyncio
  147. async def test_two_consecutive_failures_give_up(patch_capture):
  148. """Bounded retry: a follower doesn't chase failing captures forever.
  149. Two followers behind a failing leader. The first takes its own turn, the
  150. second joins THAT capture, and when it fails too the second gives up
  151. rather than opening a third connection.
  152. """
  153. gate = asyncio.Event()
  154. capture = patch_capture(RecordingCapture(frames=(None, None), gate=gate))
  155. leader = asyncio.create_task(capture_frame("/dev/video1", "usb"))
  156. await _let_leader_start(capture)
  157. first = asyncio.create_task(capture_frame("/dev/video1", "usb"))
  158. await asyncio.sleep(0)
  159. second = asyncio.create_task(capture_frame("/dev/video1", "usb"))
  160. await asyncio.sleep(0)
  161. gate.set()
  162. assert await leader is None
  163. assert await first is None
  164. assert await second is None
  165. # The leader's capture plus one retry — not one per disappointed caller.
  166. assert capture.count == 2
  167. @pytest.mark.asyncio
  168. async def test_follower_timeout_does_not_sabotage_the_capture(patch_capture):
  169. """A follower giving up leaves the capture running for everyone else.
  170. Call sites disagree about the timeout, so a follower must be able to
  171. abandon a join without cancelling a capture other callers are still
  172. waiting on.
  173. """
  174. gate = asyncio.Event()
  175. capture = patch_capture(RecordingCapture(gate=gate))
  176. leader = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=30))
  177. await _let_leader_start(capture)
  178. impatient = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=0.01))
  179. patient = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=30))
  180. assert await impatient is None # gave up on its own deadline
  181. gate.set()
  182. assert await leader == FRAME_A
  183. assert await patient == FRAME_A # unaffected by the one that walked away
  184. assert capture.count == 1
  185. @pytest.mark.asyncio
  186. async def test_cancelled_leader_still_delivers_to_followers(patch_capture):
  187. """Snapshot/capture requests get cancelled routinely (client navigates
  188. away mid-request). The follower must not lose the frame because the
  189. caller that happened to open the connection went away."""
  190. gate = asyncio.Event()
  191. capture = patch_capture(RecordingCapture(gate=gate))
  192. leader = asyncio.create_task(capture_frame("/dev/video1", "usb"))
  193. await _let_leader_start(capture)
  194. follower = asyncio.create_task(capture_frame("/dev/video1", "usb"))
  195. await asyncio.sleep(0)
  196. leader.cancel()
  197. with pytest.raises(asyncio.CancelledError):
  198. await leader
  199. gate.set()
  200. assert await follower == FRAME_A
  201. assert capture.count == 1
  202. @pytest.mark.asyncio
  203. async def test_cancelling_a_follower_leaves_the_leader_alone(patch_capture):
  204. """The mirror case: the follower's cancellation is its own business."""
  205. gate = asyncio.Event()
  206. capture = patch_capture(RecordingCapture(gate=gate))
  207. leader = asyncio.create_task(capture_frame("/dev/video1", "usb"))
  208. await _let_leader_start(capture)
  209. follower = asyncio.create_task(capture_frame("/dev/video1", "usb"))
  210. await asyncio.sleep(0)
  211. follower.cancel()
  212. with pytest.raises(asyncio.CancelledError):
  213. await follower
  214. gate.set()
  215. assert await leader == FRAME_A
  216. assert capture.count == 1
  217. @pytest.mark.asyncio
  218. async def test_capture_in_flight_reports_the_window(patch_capture):
  219. """The predicate a diagnose-style caller would use to know it will join,
  220. not measure its own connection."""
  221. gate = asyncio.Event()
  222. capture = patch_capture(RecordingCapture(gate=gate))
  223. assert capture_in_flight("/dev/video1", "usb") is False
  224. leader = asyncio.create_task(capture_frame("/dev/video1", "usb"))
  225. await _let_leader_start(capture)
  226. assert capture_in_flight("/dev/video1", "usb") is True
  227. assert capture_in_flight("/dev/video2", "usb") is False # per camera
  228. gate.set()
  229. await leader
  230. await asyncio.sleep(0)
  231. assert capture_in_flight("/dev/video1", "usb") is False
  232. # ---------------------------------------------------------------------------
  233. # Failure must arrive as None, never as an exception
  234. # ---------------------------------------------------------------------------
  235. #
  236. # `test_failed_leader_does_not_poison_its_followers` above covers a leader that
  237. # RETURNS None. A leader that RAISES is a different path: the wrapper's retry
  238. # loop only catches TimeoutError and CancelledError, so an escaping exception
  239. # would reach every follower at once and none of them would take a turn of
  240. # their own — one caller's failure becoming N. The per-type helpers catch
  241. # narrowly (aiohttp.ClientError / OSError / timeouts), so the guarantee lives
  242. # in _capture_frame_uncoalesced's own blanket catch.
  243. @pytest.mark.asyncio
  244. async def test_an_unexpected_error_is_reported_as_a_failed_capture():
  245. """Not every failure is an OSError. An IncompleteReadError is an EOFError,
  246. which none of the per-type helpers catch."""
  247. async def raising(url, timeout):
  248. raise asyncio.IncompleteReadError(partial=b"", expected=4)
  249. import backend.app.services.external_camera as ec
  250. original = ec._capture_snapshot
  251. ec._capture_snapshot = raising
  252. try:
  253. result = await ec._capture_frame_uncoalesced("http://cam/snap", "snapshot", 5, None)
  254. finally:
  255. ec._capture_snapshot = original
  256. assert result is None
  257. @pytest.mark.asyncio
  258. async def test_a_raising_leader_does_not_take_its_followers_down_with_it(monkeypatch):
  259. """The whole point of coalescing is that one caller's connection serves
  260. several. It must not also mean one caller's crash fails several.
  261. Patches the per-type helper rather than ``_capture_frame_uncoalesced``,
  262. deliberately: the guarantee lives in that function's blanket catch, so a
  263. stand-in installed in its place would test the wrapper against a shape the
  264. wrapper can no longer be handed.
  265. """
  266. gate = asyncio.Event()
  267. attempts: list[str] = []
  268. async def raise_then_succeed(url, timeout):
  269. attempts.append(url)
  270. if len(attempts) == 1:
  271. await gate.wait()
  272. raise RuntimeError("ffmpeg died in a way nobody catches")
  273. return FRAME_B
  274. monkeypatch.setattr(ec_module, "_capture_rtsp_frame", raise_then_succeed)
  275. leader = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5))
  276. await asyncio.sleep(0)
  277. follower = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5))
  278. await asyncio.sleep(0)
  279. gate.set()
  280. leader_result, follower_result = await asyncio.gather(leader, follower, return_exceptions=True)
  281. assert not isinstance(leader_result, BaseException), f"leader raised {leader_result!r}"
  282. assert not isinstance(follower_result, BaseException), f"follower raised {follower_result!r}"
  283. assert leader_result is None, "the leader's own capture failed, so it gets None"
  284. assert follower_result == FRAME_B, "the follower took its own turn and succeeded"
  285. # ---------------------------------------------------------------------------
  286. # The connection test must not claim a connection it never opened
  287. # ---------------------------------------------------------------------------
  288. @pytest.mark.asyncio
  289. async def test_connection_reports_when_it_shared_someone_elses_capture(patch_capture):
  290. """A test landing while Obico is mid-poll gets that frame back. Reporting a
  291. bare success would credit a connection this test never made — and forcing
  292. its own would open the second handle the coalescing exists to prevent."""
  293. from backend.app.services.external_camera import test_connection
  294. gate = asyncio.Event()
  295. capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate))
  296. other = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5))
  297. await _let_leader_start(capture)
  298. tested = asyncio.create_task(test_connection("rtsp://cam/1", "rtsp"))
  299. await asyncio.sleep(0)
  300. gate.set()
  301. result = await tested
  302. await other
  303. assert result["success"] is True
  304. assert result["coalesced"] is True
  305. assert capture.count == 1, "no second connection was opened"
  306. @pytest.mark.asyncio
  307. async def test_connection_reports_its_own_capture_as_not_coalesced(patch_capture):
  308. from backend.app.services.external_camera import test_connection
  309. capture = patch_capture(RecordingCapture(frames=(FRAME_A,)))
  310. result = await test_connection("rtsp://cam/1", "rtsp")
  311. assert result["success"] is True
  312. assert result["coalesced"] is False
  313. assert capture.count == 1
  314. @pytest.mark.asyncio
  315. async def test_connection_reports_coalesced_on_the_failure_path_too(patch_capture):
  316. """The flag describes where the answer came from, not whether it was good."""
  317. from backend.app.services.external_camera import test_connection
  318. capture = patch_capture(RecordingCapture(frames=(None,)))
  319. result = await test_connection("rtsp://cam/1", "rtsp")
  320. assert result["success"] is False
  321. assert result["coalesced"] is False
  322. assert capture.count == 1
  323. # ---------------------------------------------------------------------------
  324. # Credentials must not reach the log
  325. # ---------------------------------------------------------------------------
  326. #
  327. # camera.py's coalescing is keyed by IP address and has nothing to redact.
  328. # These keys carry the camera URL, and an RTSP camera URL routinely embeds
  329. # user:pass@ — which is why every other URL log in the module redacts.
  330. CREDENTIALED_URL = "rtsp://admin:hunter2@192.168.1.50:554/Streaming/Channels/101"
  331. @pytest.mark.asyncio
  332. async def test_the_reuse_log_line_redacts_the_password(patch_capture, caplog):
  333. gate = asyncio.Event()
  334. capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate))
  335. with caplog.at_level("DEBUG", logger=ec_module.__name__):
  336. leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
  337. await _let_leader_start(capture)
  338. follower = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
  339. await asyncio.sleep(0)
  340. gate.set()
  341. await asyncio.gather(leader, follower)
  342. assert not [r.getMessage() for r in caplog.records if "hunter2" in r.getMessage()]
  343. @pytest.mark.asyncio
  344. async def test_the_gave_up_waiting_log_line_redacts_the_password(patch_capture, caplog):
  345. """This one is a warning, so it shows at the default level and lands in
  346. support bundles."""
  347. gate = asyncio.Event()
  348. capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate))
  349. with caplog.at_level("DEBUG", logger=ec_module.__name__):
  350. leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
  351. await _let_leader_start(capture)
  352. assert await capture_frame(CREDENTIALED_URL, "rtsp", timeout=0) is None
  353. gate.set()
  354. await leader
  355. messages = [r.getMessage() for r in caplog.records]
  356. assert any("Gave up waiting" in m for m in messages), "the timeout path did not run"
  357. assert not [m for m in messages if "hunter2" in m]
  358. @pytest.mark.asyncio
  359. async def test_the_failed_capture_log_line_redacts_the_password(patch_capture, caplog):
  360. gate = asyncio.Event()
  361. capture = patch_capture(RecordingCapture(frames=(None, FRAME_B), gate=gate))
  362. with caplog.at_level("DEBUG", logger=ec_module.__name__):
  363. leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
  364. await _let_leader_start(capture)
  365. follower = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
  366. await asyncio.sleep(0)
  367. gate.set()
  368. await asyncio.gather(leader, follower)
  369. assert not [r.getMessage() for r in caplog.records if "hunter2" in r.getMessage()]