| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493 |
- """Single-flight coalescing of one-shot external-camera captures (#2705-shape
- fix, filed against the external-camera path as a follow-up on #2707).
- V4L2 USB devices allow exactly one open handle - the same one-connection
- limit #2705 covers for Bambu firmware. The #2707 guards (``is_stream_active``
- / ``try_get_active_buffered_frame``) only keep a one-shot capturer from
- competing with the fan-out live view; nothing kept the capturers from
- competing with EACH OTHER when no viewer is attached, so an Obico poll and
- the in-print frame bank (say) could each open their own connection to the
- same USB device and collide.
- These tests drive ``capture_frame`` at the public boundary and count how
- many times the underlying capture ran, since "how many connections did we
- open" is the entire point of the fix. Mirrors
- ``test_camera_capture_coalescing.py``'s structure for the built-in path.
- """
- import asyncio
- import pytest
- from backend.app.services import external_camera as ec_module
- from backend.app.services.external_camera import capture_frame, capture_in_flight
- FRAME_A = b"\xff\xd8" + b"a" * 200 + b"\xff\xd9"
- FRAME_B = b"\xff\xd8" + b"b" * 200 + b"\xff\xd9"
- @pytest.fixture(autouse=True)
- def _clear_inflight():
- """The registry is module-global; don't leak tasks between tests."""
- ec_module._inflight_captures.clear()
- yield
- ec_module._inflight_captures.clear()
- class RecordingCapture:
- """Stand-in for the real capture, recording each call.
- ``gate`` (when set) holds every capture open until released, which is how
- these tests create the overlap window that used to produce two
- connections.
- """
- def __init__(self, frames=(FRAME_A, FRAME_B), gate: asyncio.Event | None = None):
- self.calls: list[tuple[str, str, str | None, int]] = []
- self._frames = list(frames)
- self._gate = gate
- self.started = asyncio.Event()
- async def __call__(self, url, camera_type, timeout, snapshot_url):
- self.calls.append((url, camera_type, snapshot_url, timeout))
- self.started.set()
- if self._gate is not None:
- await self._gate.wait()
- return self._frames.pop(0) if self._frames else None
- @property
- def count(self) -> int:
- return len(self.calls)
- @pytest.fixture
- def patch_capture(monkeypatch):
- def _install(capture):
- monkeypatch.setattr(ec_module, "_capture_frame_uncoalesced", capture)
- return capture
- return _install
- async def _let_leader_start(capture: RecordingCapture) -> None:
- """Wait until the leader is inside the capture, so the next caller joins it.
- Without this the second caller can reach the registry before the first
- has even been scheduled, which tests a different (and uninteresting) race.
- """
- await asyncio.wait_for(capture.started.wait(), timeout=1)
- @pytest.mark.asyncio
- async def test_simultaneous_callers_share_one_capture(patch_capture):
- """The reported collision: two consumers, one connection, two frames."""
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(gate=gate))
- leader = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=20))
- await _let_leader_start(capture)
- follower = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=15))
- await asyncio.sleep(0)
- gate.set()
- assert await leader == FRAME_A
- assert await follower == FRAME_A
- assert capture.count == 1
- @pytest.mark.asyncio
- async def test_five_callers_one_capture(patch_capture):
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(gate=gate))
- first = asyncio.create_task(capture_frame("/dev/video1", "usb"))
- await _let_leader_start(capture)
- rest = [asyncio.create_task(capture_frame("/dev/video1", "usb")) for _ in range(4)]
- await asyncio.sleep(0)
- gate.set()
- assert await asyncio.gather(first, *rest) == [FRAME_A] * 5
- assert capture.count == 1
- @pytest.mark.asyncio
- async def test_different_cameras_do_not_coalesce(patch_capture):
- """The one-connection limit is per camera, so the key must be too."""
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(gate=gate))
- one = asyncio.create_task(capture_frame("/dev/video1", "usb"))
- await _let_leader_start(capture)
- two = asyncio.create_task(capture_frame("/dev/video2", "usb"))
- await asyncio.sleep(0)
- gate.set()
- assert {await one, await two} == {FRAME_A, FRAME_B}
- assert capture.count == 2
- assert {url for url, *_ in capture.calls} == {"/dev/video1", "/dev/video2"}
- @pytest.mark.asyncio
- async def test_different_snapshot_url_does_not_coalesce(patch_capture):
- """#1177's snapshot_url override routes to a different endpoint entirely -
- two printers sharing a camera_url but differing only in snapshot_url must
- not share a capture."""
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(gate=gate))
- one = asyncio.create_task(capture_frame("http://cam/", "mjpeg", snapshot_url="http://cam/frame1.jpg"))
- await _let_leader_start(capture)
- two = asyncio.create_task(capture_frame("http://cam/", "mjpeg", snapshot_url="http://cam/frame2.jpg"))
- await asyncio.sleep(0)
- gate.set()
- assert {await one, await two} == {FRAME_A, FRAME_B}
- assert capture.count == 2
- @pytest.mark.asyncio
- async def test_coalescing_is_not_caching(patch_capture):
- """Sequential callers each capture fresh.
- Deliberate: plate detection and the finish-photo path decide things about
- a running print from these frames, and #1397 was a finish photo a few
- seconds stale showing the bed already lowered.
- """
- capture = patch_capture(RecordingCapture())
- assert await capture_frame("/dev/video1", "usb") == FRAME_A
- assert await capture_frame("/dev/video1", "usb") == FRAME_B
- assert capture.count == 2
- @pytest.mark.asyncio
- async def test_registry_is_empty_after_a_capture_finishes(patch_capture):
- """No leak, and nothing left behind for the next caller to join."""
- patch_capture(RecordingCapture())
- await capture_frame("/dev/video1", "usb")
- await asyncio.sleep(0) # let the done-callback run
- assert ec_module._inflight_captures == {}
- assert capture_in_flight("/dev/video1", "usb") is False
- @pytest.mark.asyncio
- async def test_failed_leader_does_not_poison_its_followers(patch_capture):
- """A follower that never got its own attempt gets one when the leader fails.
- Safe by then: the leader has finished, so there is no connection to
- compete with. This also covers the follower whose timeout is LONGER than
- the leader's — it isn't cut short by someone else's deadline.
- """
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(frames=(None, FRAME_B), gate=gate))
- leader = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=10))
- await _let_leader_start(capture)
- follower = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=20))
- await asyncio.sleep(0)
- gate.set()
- assert await leader is None
- assert await follower == FRAME_B
- assert capture.count == 2
- @pytest.mark.asyncio
- async def test_two_consecutive_failures_give_up(patch_capture):
- """Bounded retry: a follower doesn't chase failing captures forever.
- Two followers behind a failing leader. The first takes its own turn, the
- second joins THAT capture, and when it fails too the second gives up
- rather than opening a third connection.
- """
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(frames=(None, None), gate=gate))
- leader = asyncio.create_task(capture_frame("/dev/video1", "usb"))
- await _let_leader_start(capture)
- first = asyncio.create_task(capture_frame("/dev/video1", "usb"))
- await asyncio.sleep(0)
- second = asyncio.create_task(capture_frame("/dev/video1", "usb"))
- await asyncio.sleep(0)
- gate.set()
- assert await leader is None
- assert await first is None
- assert await second is None
- # The leader's capture plus one retry — not one per disappointed caller.
- assert capture.count == 2
- @pytest.mark.asyncio
- async def test_follower_timeout_does_not_sabotage_the_capture(patch_capture):
- """A follower giving up leaves the capture running for everyone else.
- Call sites disagree about the timeout, so a follower must be able to
- abandon a join without cancelling a capture other callers are still
- waiting on.
- """
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(gate=gate))
- leader = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=30))
- await _let_leader_start(capture)
- impatient = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=0.01))
- patient = asyncio.create_task(capture_frame("/dev/video1", "usb", timeout=30))
- assert await impatient is None # gave up on its own deadline
- gate.set()
- assert await leader == FRAME_A
- assert await patient == FRAME_A # unaffected by the one that walked away
- assert capture.count == 1
- @pytest.mark.asyncio
- async def test_cancelled_leader_still_delivers_to_followers(patch_capture):
- """Snapshot/capture requests get cancelled routinely (client navigates
- away mid-request). The follower must not lose the frame because the
- caller that happened to open the connection went away."""
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(gate=gate))
- leader = asyncio.create_task(capture_frame("/dev/video1", "usb"))
- await _let_leader_start(capture)
- follower = asyncio.create_task(capture_frame("/dev/video1", "usb"))
- await asyncio.sleep(0)
- leader.cancel()
- with pytest.raises(asyncio.CancelledError):
- await leader
- gate.set()
- assert await follower == FRAME_A
- assert capture.count == 1
- @pytest.mark.asyncio
- async def test_cancelling_a_follower_leaves_the_leader_alone(patch_capture):
- """The mirror case: the follower's cancellation is its own business."""
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(gate=gate))
- leader = asyncio.create_task(capture_frame("/dev/video1", "usb"))
- await _let_leader_start(capture)
- follower = asyncio.create_task(capture_frame("/dev/video1", "usb"))
- await asyncio.sleep(0)
- follower.cancel()
- with pytest.raises(asyncio.CancelledError):
- await follower
- gate.set()
- assert await leader == FRAME_A
- assert capture.count == 1
- @pytest.mark.asyncio
- async def test_capture_in_flight_reports_the_window(patch_capture):
- """The predicate a diagnose-style caller would use to know it will join,
- not measure its own connection."""
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(gate=gate))
- assert capture_in_flight("/dev/video1", "usb") is False
- leader = asyncio.create_task(capture_frame("/dev/video1", "usb"))
- await _let_leader_start(capture)
- assert capture_in_flight("/dev/video1", "usb") is True
- assert capture_in_flight("/dev/video2", "usb") is False # per camera
- gate.set()
- await leader
- await asyncio.sleep(0)
- assert capture_in_flight("/dev/video1", "usb") is False
- # ---------------------------------------------------------------------------
- # Failure must arrive as None, never as an exception
- # ---------------------------------------------------------------------------
- #
- # `test_failed_leader_does_not_poison_its_followers` above covers a leader that
- # RETURNS None. A leader that RAISES is a different path: the wrapper's retry
- # loop only catches TimeoutError and CancelledError, so an escaping exception
- # would reach every follower at once and none of them would take a turn of
- # their own — one caller's failure becoming N. The per-type helpers catch
- # narrowly (aiohttp.ClientError / OSError / timeouts), so the guarantee lives
- # in _capture_frame_uncoalesced's own blanket catch.
- @pytest.mark.asyncio
- async def test_an_unexpected_error_is_reported_as_a_failed_capture():
- """Not every failure is an OSError. An IncompleteReadError is an EOFError,
- which none of the per-type helpers catch."""
- async def raising(url, timeout):
- raise asyncio.IncompleteReadError(partial=b"", expected=4)
- import backend.app.services.external_camera as ec
- original = ec._capture_snapshot
- ec._capture_snapshot = raising
- try:
- result = await ec._capture_frame_uncoalesced("http://cam/snap", "snapshot", 5, None)
- finally:
- ec._capture_snapshot = original
- assert result is None
- @pytest.mark.asyncio
- async def test_a_raising_leader_does_not_take_its_followers_down_with_it(monkeypatch):
- """The whole point of coalescing is that one caller's connection serves
- several. It must not also mean one caller's crash fails several.
- Patches the per-type helper rather than ``_capture_frame_uncoalesced``,
- deliberately: the guarantee lives in that function's blanket catch, so a
- stand-in installed in its place would test the wrapper against a shape the
- wrapper can no longer be handed.
- """
- gate = asyncio.Event()
- attempts: list[str] = []
- async def raise_then_succeed(url, timeout):
- attempts.append(url)
- if len(attempts) == 1:
- await gate.wait()
- raise RuntimeError("ffmpeg died in a way nobody catches")
- return FRAME_B
- monkeypatch.setattr(ec_module, "_capture_rtsp_frame", raise_then_succeed)
- leader = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5))
- await asyncio.sleep(0)
- follower = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5))
- await asyncio.sleep(0)
- gate.set()
- leader_result, follower_result = await asyncio.gather(leader, follower, return_exceptions=True)
- assert not isinstance(leader_result, BaseException), f"leader raised {leader_result!r}"
- assert not isinstance(follower_result, BaseException), f"follower raised {follower_result!r}"
- assert leader_result is None, "the leader's own capture failed, so it gets None"
- assert follower_result == FRAME_B, "the follower took its own turn and succeeded"
- # ---------------------------------------------------------------------------
- # The connection test must not claim a connection it never opened
- # ---------------------------------------------------------------------------
- @pytest.mark.asyncio
- async def test_connection_reports_when_it_shared_someone_elses_capture(patch_capture):
- """A test landing while Obico is mid-poll gets that frame back. Reporting a
- bare success would credit a connection this test never made — and forcing
- its own would open the second handle the coalescing exists to prevent."""
- from backend.app.services.external_camera import test_connection
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate))
- other = asyncio.create_task(capture_frame("rtsp://cam/1", "rtsp", timeout=5))
- await _let_leader_start(capture)
- tested = asyncio.create_task(test_connection("rtsp://cam/1", "rtsp"))
- await asyncio.sleep(0)
- gate.set()
- result = await tested
- await other
- assert result["success"] is True
- assert result["coalesced"] is True
- assert capture.count == 1, "no second connection was opened"
- @pytest.mark.asyncio
- async def test_connection_reports_its_own_capture_as_not_coalesced(patch_capture):
- from backend.app.services.external_camera import test_connection
- capture = patch_capture(RecordingCapture(frames=(FRAME_A,)))
- result = await test_connection("rtsp://cam/1", "rtsp")
- assert result["success"] is True
- assert result["coalesced"] is False
- assert capture.count == 1
- @pytest.mark.asyncio
- async def test_connection_reports_coalesced_on_the_failure_path_too(patch_capture):
- """The flag describes where the answer came from, not whether it was good."""
- from backend.app.services.external_camera import test_connection
- capture = patch_capture(RecordingCapture(frames=(None,)))
- result = await test_connection("rtsp://cam/1", "rtsp")
- assert result["success"] is False
- assert result["coalesced"] is False
- assert capture.count == 1
- # ---------------------------------------------------------------------------
- # Credentials must not reach the log
- # ---------------------------------------------------------------------------
- #
- # camera.py's coalescing is keyed by IP address and has nothing to redact.
- # These keys carry the camera URL, and an RTSP camera URL routinely embeds
- # user:pass@ — which is why every other URL log in the module redacts.
- CREDENTIALED_URL = "rtsp://admin:hunter2@192.168.1.50:554/Streaming/Channels/101"
- @pytest.mark.asyncio
- async def test_the_reuse_log_line_redacts_the_password(patch_capture, caplog):
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate))
- with caplog.at_level("DEBUG", logger=ec_module.__name__):
- leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
- await _let_leader_start(capture)
- follower = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
- await asyncio.sleep(0)
- gate.set()
- await asyncio.gather(leader, follower)
- assert not [r.getMessage() for r in caplog.records if "hunter2" in r.getMessage()]
- @pytest.mark.asyncio
- async def test_the_gave_up_waiting_log_line_redacts_the_password(patch_capture, caplog):
- """This one is a warning, so it shows at the default level and lands in
- support bundles."""
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(frames=(FRAME_A,), gate=gate))
- with caplog.at_level("DEBUG", logger=ec_module.__name__):
- leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
- await _let_leader_start(capture)
- assert await capture_frame(CREDENTIALED_URL, "rtsp", timeout=0) is None
- gate.set()
- await leader
- messages = [r.getMessage() for r in caplog.records]
- assert any("Gave up waiting" in m for m in messages), "the timeout path did not run"
- assert not [m for m in messages if "hunter2" in m]
- @pytest.mark.asyncio
- async def test_the_failed_capture_log_line_redacts_the_password(patch_capture, caplog):
- gate = asyncio.Event()
- capture = patch_capture(RecordingCapture(frames=(None, FRAME_B), gate=gate))
- with caplog.at_level("DEBUG", logger=ec_module.__name__):
- leader = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
- await _let_leader_start(capture)
- follower = asyncio.create_task(capture_frame(CREDENTIALED_URL, "rtsp", timeout=5))
- await asyncio.sleep(0)
- gate.set()
- await asyncio.gather(leader, follower)
- assert not [r.getMessage() for r in caplog.records if "hunter2" in r.getMessage()]
|