| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166 |
- """/camera/stop ends the leaving viewer's external streams, not the others'.
- The stop guard counted only fan-out subscribers, which external cameras never
- use: one viewer leaving (a Cam Wall tile turning to snapshot, the Camera page
- closed, the camera window's Refresh) killed every external RTSP/USB ffmpeg of
- the printer, and the other viewers froze. A viewer now names itself (a random
- id per mount, sent with its stream and its stop): its own streams end at once,
- also one whose cleanup was skipped (#2675), and so does any stream that sends no
- frames; other viewers' live streams keep running. A stop without an id (a
- cached older frontend) still ends them all.
- """
- from __future__ import annotations
- import asyncio
- import os
- import time
- import pytest
- from backend.app.api.routes import camera
- PRINTER = 7
- class _Proc:
- def __init__(self, pid: int) -> None:
- self.pid = pid
- self.returncode = None
- self.stdout = None
- self.stderr = None
- def terminate(self) -> None:
- self.returncode = 0
- def kill(self) -> None:
- self.returncode = -9
- async def wait(self) -> int:
- return self.returncode if self.returncode is not None else 0
- @pytest.fixture
- def no_fanout(monkeypatch):
- monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
- monkeypatch.setattr(camera, "get_subscriber_count", lambda _key: 0)
- async def no_broadcaster(_key):
- return False
- monkeypatch.setattr(camera, "shutdown_broadcaster", no_broadcaster)
- @pytest.fixture
- def register():
- added: list[tuple[str, _Proc]] = []
- def _register(suffix: str, pid: int, viewer: str | None, last_frame_age: float | None = 0.5):
- sid = f"{PRINTER}-ext-{suffix}"
- proc = _Proc(pid)
- event = asyncio.Event()
- camera._active_streams[sid] = proc
- camera._disconnect_events[sid] = event
- camera._spawned_ffmpeg_pids[pid] = time.time()
- if viewer is not None:
- camera._stream_viewers[sid] = viewer
- if last_frame_age is not None:
- camera._stream_last_frame_times[sid] = time.time() - last_frame_age
- added.append((sid, proc))
- return proc, event
- yield _register
- for sid, proc in added:
- camera._active_streams.pop(sid, None)
- camera._disconnect_events.pop(sid, None)
- camera._stream_last_frame_times.pop(sid, None)
- camera._stream_viewers.pop(sid, None)
- camera._spawned_ffmpeg_pids.pop(proc.pid, None)
- @pytest.mark.asyncio
- async def test_a_viewer_leaving_ends_its_own_stream_at_once(no_fanout, register):
- """#2675: its cleanup may have been skipped, and the stream still looks live."""
- own, own_event = register("own00001", 54001, viewer="tile-a", last_frame_age=0.1)
- result = await camera.stop_camera_stream(PRINTER, _=None, viewer="tile-a")
- assert result["stopped"] == 1
- assert own.returncode is not None and own_event.is_set()
- assert f"{PRINTER}-ext-own00001" not in camera._stream_viewers
- @pytest.mark.asyncio
- async def test_another_viewers_live_stream_keeps_running(no_fanout, register):
- own, _ = register("own00002", 54002, viewer="tile-a")
- other, other_event = register("other002", 54003, viewer="page-b")
- result = await camera.stop_camera_stream(PRINTER, _=None, viewer="tile-a")
- assert result["stopped"] == 1
- assert own.returncode is not None
- assert other.returncode is None and not other_event.is_set()
- assert f"{PRINTER}-ext-other002" in camera._active_streams
- @pytest.mark.asyncio
- async def test_a_stream_sending_no_frames_ends_whoever_opened_it(no_fanout, register):
- stuck, _ = register("stuck003", 54004, viewer="page-b", last_frame_age=60.0)
- never, _ = register("never003", 54005, viewer=None, last_frame_age=None)
- live, _ = register("live0003", 54006, viewer=None)
- result = await camera.stop_camera_stream(PRINTER, _=None, viewer="tile-a")
- assert result["stopped"] == 2
- assert stuck.returncode is not None and never.returncode is not None
- assert live.returncode is None
- @pytest.mark.asyncio
- async def test_a_stop_without_a_viewer_still_ends_them_all(no_fanout, register):
- """A cached older frontend sends no id."""
- a, _ = register("all00004", 54007, viewer="tile-a")
- b, _ = register("all00005", 54008, viewer="page-b")
- result = await camera.stop_camera_stream(PRINTER, _=None)
- assert result["stopped"] == 2
- assert a.returncode is not None and b.returncode is not None
- @pytest.mark.asyncio
- async def test_a_deleted_printer_loses_every_stream(no_fanout, register):
- live, live_event = register("live0006", 54009, viewer="page-b")
- await camera.forget_printer(PRINTER)
- assert live.returncode is not None and live_event.is_set()
- assert f"{PRINTER}-ext-live0006" not in camera._stream_viewers
- @pytest.mark.asyncio
- async def test_the_janitor_drops_the_viewer_of_a_stream_it_kills(no_fanout, register, monkeypatch):
- """Orphaned by a skipped cleanup and its viewer's stop never came (#2675)."""
- monkeypatch.setattr(camera, "_scan_bambu_ffmpeg_pids", lambda: [])
- # A real pid, so the janitor's existence check keeps it.
- stale, stale_event = register("stale007", os.getpid(), viewer="tile-a", last_frame_age=60.0)
- camera._spawned_ffmpeg_pids[stale.pid] = time.time() - 120
- await camera.cleanup_orphaned_streams()
- assert stale.returncode is not None and stale_event.is_set()
- assert f"{PRINTER}-ext-stale007" not in camera._stream_viewers
- @pytest.mark.asyncio
- async def test_the_janitor_keeps_the_viewer_of_a_stream_between_reconnects(no_fanout, register, monkeypatch):
- """Its ffmpeg died and the stream is starting the next one under the same id:
- its viewer's stop must still reach it."""
- monkeypatch.setattr(camera, "_scan_bambu_ffmpeg_pids", lambda: [])
- dead, _ = register("dead0008", 54011, viewer="tile-a")
- dead.returncode = 1
- await camera.cleanup_orphaned_streams()
- assert camera._stream_viewers.get(f"{PRINTER}-ext-dead0008") == "tile-a"
- assert f"{PRINTER}-ext-dead0008" in camera._disconnect_events
|