Przeglądaj źródła

fix(camera): external cameras share one stream per printer

Every viewer of an external camera started its own upstream, so a
second viewer of a USB camera (a Cam Wall tile and the Camera page, two
browsers) failed with "device busy", and an MJPEG or RTSP camera got
one connection and one ffmpeg per viewer. External streams now go
through the fan-out broadcaster like the printer's own camera: one
upstream per camera, shared by every viewer, closed a few seconds after
the last one leaves. A changed camera gets its own upstream once the
old one has let go of the device. The external RTSP and USB stream
cleanup stops ffmpeg with the bounded terminator (#2580); unbounded it
would hang the shared teardown.
maziggy 5 dni temu
rodzic
commit
31888777ae

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **A USB camera can be watched from several places at once** — Every viewer of an external camera opened its own connection to it. A USB camera can only be read by one program at a time, so opening the Camera page while a Cam Wall tile (or another browser) was already showing it gave a blank picture. External cameras now share one connection per printer, like the printer's own camera, which also means one connection per MJPEG or snapshot camera and one `ffmpeg` per RTSP or USB camera instead of one per viewer. As with the printer's own camera, the viewer who opens the connection sets its frame rate: with a Cam Wall tile already showing the camera, the Camera page gets the tile's 8 frames per second until everyone has closed it.
 - **Camera snapshots and the camera test no longer fail while someone watches the live view** — A USB camera can be read by one program at a time, so while a viewer was watching, every snapshot Home Assistant or the camera wall fetched from an external camera failed, and the external camera's **Test** button failed too. Snapshots now return the live view's frame, and the Test button reports the camera working from that frame. While the live view is still starting, a snapshot asks the caller to try again a moment later instead of taking the camera from the viewer (the camera wall and the camera page now retry once on their own); this now applies to the printer's own camera as well. An external camera with its own snapshot URL is still fetched from that URL.
 - **USB camera photos are no longer killed mid-capture** — A cleanup job that runs every minute stops leftover camera processes, and it did not know about the one-shot USB capture behind finish photos, timelapse frames and snapshots, so it sometimes killed one in progress. It also stopped any other program's `ffmpeg` reading a USB camera on the same machine; it now only stops Bambuddy's own.
 - **The camera Test button shows the resolution properly** — It read "Camera connected640x480"; it now reads "Camera connected (640x480)".

+ 186 - 140
backend/app/api/routes/camera.py

@@ -2,6 +2,7 @@
 
 import asyncio
 import contextlib
+import hashlib
 import logging
 import os
 import re
@@ -48,9 +49,11 @@ from backend.app.services.camera import (
 )
 from backend.app.services.camera_fanout import (
     MjpegBroadcaster,
+    active_broadcaster_keys,
     get_or_create_broadcaster,
     get_subscriber_count,
     iter_subscriber,
+    retire_broadcasters,
     shutdown_broadcaster,
 )
 from backend.app.services.camera_light import camera_light
@@ -134,18 +137,10 @@ _disconnect_events: dict[str, asyncio.Event] = {}
 # Track last frame time per stream_id (not just per printer_id) for stale detection
 _stream_last_frame_times: dict[str, float] = {}
 
-# The viewer that opened an external camera stream (a random id per viewer,
-# sent with its stream and its /camera/stop): that viewer's stop ends its own
-# streams and leaves the other viewers' running. External cameras do not use the
-# fan-out, so the subscriber guard cannot see their other viewers.
-_stream_viewers: dict[str, str] = {}
-
-# A /camera/stop with a viewer id also ends any external stream that sent no
-# frame for this long, whoever opened it (stuck, or orphaned by a skipped
-# cleanup, #2675). Same threshold as /camera/status's "stalled".
-STOP_STALE_EXTERNAL_SECONDS = 10.0
-
-# A viewer id: what the frontend generates, and nothing that could forge a log line.
+# A viewer id (a random id per viewer mount, sent with its stream and its
+# /camera/stop): what the frontend generates, and nothing that could forge a log
+# line. Every camera fans out now, so the subscriber guard of the stop route
+# decides; the id is accepted for the frontends that send it.
 ViewerId = Annotated[str | None, Query(pattern=r"^[A-Za-z0-9_-]{1,64}$")]
 
 # How much of a streaming ffmpeg's stderr to retain: enough for the input
@@ -1098,6 +1093,111 @@ async def _while_allowed(frames, opener: StreamOpener, printer_id: int):
             yield chunk
 
 
+def _external_fanout_key(printer_id: int, url: str, camera_type: str) -> str:
+    """The broadcaster key of a printer's external camera.
+
+    Carries a digest of the camera, not the URL itself, which may hold its login.
+    """
+    digest = hashlib.sha256(f"{camera_type}\x00{url}".encode()).hexdigest()[:12]
+    return f"printer-{printer_id}-ext-{digest}"
+
+
+def _printer_broadcaster_keys(printer_id: int) -> list[str]:
+    """A printer's broadcaster keys: its own camera's, and any live external one."""
+    external = f"printer-{printer_id}-ext-"
+    return [f"printer-{printer_id}"] + [key for key in active_broadcaster_keys() if key.startswith(external)]
+
+
+async def _retire_other_external_broadcasters(printer_id: int, *, keep: str) -> None:
+    """Shut down a printer's external upstreams for a camera it no longer uses,
+    and wait until they have let go of it."""
+    await retire_broadcasters(f"printer-{printer_id}-ext-", keep=keep)
+
+
+async def _external_upstream(
+    generate_mjpeg_stream,
+    printer_id: int,
+    camera_url: str,
+    camera_type: str,
+    fps: int,
+    disconnect_event: asyncio.Event,
+):
+    """The one upstream of an external camera, run by its fan-out broadcaster.
+
+    Registered in the same registries the RTSP and chamber paths use (#2675), so
+    /camera/stop and cleanup_orphaned_streams find and kill a leaked ffmpeg
+    holding a USB device, and is_stream_active() sees the stream. The stream id
+    keeps the ``{printer_id}-`` prefix both scanners key on.
+    """
+    stream_id = f"{printer_id}-ext-{uuid.uuid4().hex[:8]}"
+    # Set here, not only by a joining viewer: one that joined while the previous
+    # upstream was tearing down had its start time cleared by that teardown,
+    # and without one /camera/status never reports the stream stalled.
+    _stream_start_times[printer_id] = time.time()
+    # The broadcaster's disconnect event doubles as the stream's: forget_printer
+    # and the stop route set it, and generate_mjpeg_stream stops on it.
+    _disconnect_events[stream_id] = disconnect_event
+
+    # Mutable holder so the finally can unregister whatever process is
+    # currently registered (the RTSP path may respawn across reconnects).
+    current_proc: dict[str, asyncio.subprocess.Process] = {}
+
+    def _register_external_process(proc: asyncio.subprocess.Process) -> None:
+        prev = current_proc.get("proc")
+        if prev is not None and prev.pid != proc.pid:
+            _spawned_ffmpeg_pids.pop(prev.pid, None)
+        current_proc["proc"] = proc
+        _active_streams[stream_id] = proc
+        _spawned_ffmpeg_pids[proc.pid] = time.time()
+        _stream_last_frame_times[stream_id] = time.time()
+
+    def _publish_external_frame(frame: bytes) -> None:
+        """Make the live frame reusable by one-shot consumers (#2707).
+
+        Raw frame, not the multipart-wrapped chunk the generator yields,
+        because that is what those consumers expect. Also in the grace window
+        after the last viewer, as on the printer's own camera: the upstream
+        still holds the camera, and a snapshot gets its frame.
+        """
+        if not disconnect_event.is_set():
+            _last_frames[printer_id] = frame
+
+    try:
+        async with contextlib.aclosing(
+            generate_mjpeg_stream(
+                camera_url,
+                camera_type,
+                fps,
+                on_process=_register_external_process,
+                on_frame=_publish_external_frame,
+                stop_event=disconnect_event,
+            )
+        ) as frames:
+            async for frame in frames:
+                # Set by forget_printer or a stop: an MJPEG or snapshot camera
+                # runs no process a stop could kill, and only checks it between
+                # sessions.
+                if disconnect_event.is_set():
+                    break
+                now = time.time()
+                _stream_last_frame_times[stream_id] = now
+                _last_frame_times[printer_id] = now
+                yield frame
+    finally:
+        # Best-effort unregister. If an abrupt teardown skips this finally, the
+        # registry entries persist, which is exactly what lets the stop
+        # endpoint / janitor reap the leaked process.
+        proc = current_proc.get("proc")
+        if proc is not None:
+            _spawned_ffmpeg_pids.pop(proc.pid, None)
+        _active_streams.pop(stream_id, None)
+        _disconnect_events.pop(stream_id, None)
+        _stream_last_frame_times.pop(stream_id, None)
+        # Clean up frame buffer and timestamps, as the printer's own camera does.
+        _release_printer_frame_state(printer_id)
+        logger.info("External camera stream ended for printer %s", printer_id)
+
+
 @router.get("/{printer_id}/camera/stream")
 async def camera_stream(
     printer_id: int,
@@ -1154,110 +1254,75 @@ async def camera_stream(
             "Using external camera (%s) for printer %s at %s fps", printer.external_camera_type, printer_id, fps
         )
 
-        # Register the stream into the SAME registries the RTSP/chamber paths use
-        # (#2675) so `/camera/stop` and cleanup_orphaned_streams can find and kill
-        # a leaked ffmpeg holding a USB device open. Before this, external streams
-        # only tracked _active_external_streams and were structurally invisible to
-        # both the stop endpoint and the janitor. The stream_id keeps the
-        # `{printer_id}-` prefix both scanners key on, plus a unique suffix so two
-        # concurrent viewers of one printer don't clobber each other's entry.
-        stream_id = f"{printer_id}-ext-{uuid.uuid4().hex[:8]}"
-        stop_event = asyncio.Event()
-        _disconnect_events[stream_id] = stop_event
-        # Track stream start
-        _stream_start_times[printer_id] = time.time()
-
-        # Mutable holder so the wrapper's finally can unregister whatever process
-        # is currently registered (the RTSP path may respawn across reconnects).
-        current_proc: dict[str, asyncio.subprocess.Process] = {}
-
-        def _register_external_process(proc: asyncio.subprocess.Process) -> None:
-            prev = current_proc.get("proc")
-            if prev is not None and prev.pid != proc.pid:
-                _spawned_ffmpeg_pids.pop(prev.pid, None)
-            current_proc["proc"] = proc
-            _active_streams[stream_id] = proc
-            _spawned_ffmpeg_pids[proc.pid] = time.time()
-            _stream_last_frame_times[stream_id] = time.time()
-
-        def _publish_external_frame(frame: bytes) -> None:
-            """Make the live frame reusable by one-shot consumers (#2707).
-
-            Only the built-in camera paths populated _last_frames, so every
-            external-camera consumer — layer timelapse, finish photo, Obico,
-            plate check — found an empty buffer and opened its own handle on a
-            device that allows exactly one reader, which simply failed while a
-            viewer was attached. Raw frame, not the multipart-wrapped chunk the
-            generator yields, because that is what those consumers expect.
-            """
-            if not stop_event.is_set():
-                _last_frames[printer_id] = frame
-
         camera_url = printer.external_camera_url
         # None and "" take the same branch in generate_mjpeg_stream.
         camera_type = printer.external_camera_type or ""
 
-        async def external_stream_wrapper():
-            """Wrap external stream to track start/stop and update frame times."""
+        # Fan-out (RC-C6), as for the printer's own camera (#1089): one upstream
+        # per camera, shared by every viewer. A USB camera allows one V4L2
+        # handle, so a second viewer with its own upstream failed with "device
+        # busy"; an MJPEG or RTSP camera got one connection and one ffmpeg per
+        # viewer. The key carries the camera, so a changed URL or type gets an
+        # upstream of its own and the old one is shut down.
+        fanout_key = _external_fanout_key(printer_id, camera_url, camera_type)
+        await _retire_other_external_broadcasters(printer_id, keep=fanout_key)
+
+        def _external_factory(disconnect_event: asyncio.Event):
+            return _external_upstream(generate_mjpeg_stream, printer_id, camera_url, camera_type, fps, disconnect_event)
+
+        async def _is_client_gone() -> bool:
             try:
-                # Counted here, not in the handler: a body that never starts (the
-                # client left first) never runs the finally that uncounts it.
+                return await request.is_disconnected()
+            except Exception:
+                return True
+
+        def _log_external_detach(remaining: int) -> None:
+            logger.info("Camera viewer detached from %s (subscribers=%d)", fanout_key, remaining)
+
+        async def external_viewer():
+            """One viewer of the shared upstream."""
+            # Chamber light while the viewer watches (#1655), switched on before
+            # subscribing: the first subscriber starts the upstream, whose first
+            # frame should already be lit.
+            async with camera_light(printer, wait=False):
+                # Subscribed and counted here, not in the handler: a body that
+                # never starts (the client left first) never runs the finally
+                # that undoes it.
+                broadcaster = await get_or_create_broadcaster(fanout_key, _external_factory)
+                try:
+                    queue = await broadcaster.subscribe()
+                except RuntimeError:
+                    # Stopped between the lookup and the subscribe (grace teardown).
+                    broadcaster = await get_or_create_broadcaster(fanout_key, _external_factory)
+                    queue = await broadcaster.subscribe()
                 _active_external_streams[printer_id] = _active_external_streams.get(printer_id, 0) + 1
-                if viewer:
-                    _stream_viewers[stream_id] = viewer
-                # Chamber light while the viewer watches (#1655).
-                async with (
-                    camera_light(printer, wait=False),
-                    contextlib.aclosing(
-                        generate_mjpeg_stream(
-                            camera_url,
-                            camera_type,
-                            fps,
-                            on_process=_register_external_process,
-                            on_frame=_publish_external_frame,
-                            stop_event=stop_event,
+                # When the shared upstream first started (setdefault), as for the
+                # printer's own camera; its end clears it.
+                _stream_start_times.setdefault(printer_id, time.time())
+                logger.info("Camera viewer attached to %s (subscribers=%d)", fanout_key, broadcaster.subscriber_count)
+                try:
+                    # aclosing, so a closed response unsubscribes at once, not
+                    # when the generator is collected.
+                    async with contextlib.aclosing(
+                        iter_subscriber(
+                            broadcaster,
+                            queue,
+                            is_disconnected=_is_client_gone,
+                            on_unsubscribe=_log_external_detach,
                         )
-                    ) as frames,
-                ):
-                    async for frame in frames:
-                        # Set by forget_printer: an MJPEG or snapshot camera runs
-                        # no process a stop could kill, and only checks it
-                        # between sessions.
-                        if stop_event.is_set():
-                            break
-                        # generate_mjpeg_stream already handles rate limiting;
-                        # track frame times (per-printer + per-stream) for stall detection
-                        now = time.time()
-                        _last_frame_times[printer_id] = now
-                        _stream_last_frame_times[stream_id] = now
-                        yield frame
-            finally:
-                # Best-effort unregister. If an abrupt disconnect skips this
-                # finally, the registry entries persist — which is exactly what
-                # lets the stop endpoint / janitor reap the leaked process.
-                stop_event.set()
-                proc = current_proc.get("proc")
-                if proc is not None:
-                    _spawned_ffmpeg_pids.pop(proc.pid, None)
-                _active_streams.pop(stream_id, None)
-                _disconnect_events.pop(stream_id, None)
-                _stream_last_frame_times.pop(stream_id, None)
-                _stream_viewers.pop(stream_id, None)
-                remaining = _active_external_streams.get(printer_id, 0) - 1
-                if remaining > 0:
-                    _active_external_streams[printer_id] = remaining
-                else:
-                    _active_external_streams.pop(printer_id, None)
-                    # Now that this path publishes a buffered frame, it has to
-                    # retract it too, and the per-printer timings it used to
-                    # leave behind. Only with the printer's last external stream:
-                    # is_stream_active() cannot see an MJPEG or snapshot camera's
-                    # other viewers, whose stall detection needs these.
-                    _release_printer_frame_state(printer_id)
-                logger.info("External camera stream ended for printer %s", printer_id)
+                    ) as chunks:
+                        async for chunk in chunks:
+                            yield chunk
+                finally:
+                    remaining = _active_external_streams.get(printer_id, 0) - 1
+                    if remaining > 0:
+                        _active_external_streams[printer_id] = remaining
+                    else:
+                        _active_external_streams.pop(printer_id, None)
+                    logger.info("External camera viewer left printer %s", printer_id)
 
         return StreamingResponse(
-            _while_allowed(external_stream_wrapper(), opener, printer_id),
+            _while_allowed(external_viewer(), opener, printer_id),
             media_type="multipart/x-mixed-replace; boundary=frame",
             headers={
                 "Cache-Control": "no-cache, no-store, must-revalidate",
@@ -1383,14 +1448,10 @@ async def stop_camera_stream(
     after subscribers drop to 0) handles cleanup when the leaving viewer's
     HTTP connection actually closes.
 
-    External cameras do not use the fan-out. A viewer passes its id (the one it
-    opened its stream with): its own external streams end at once (also one
-    whose cleanup was skipped, #2675), as does any that sends no frames; other
-    viewers' live streams keep running. Without an id (an older frontend) every
-    stream of the printer ends, as before.
+    External cameras fan out too (RC-C6), under their own broadcaster key, so
+    their viewers count the same way.
     """
-    broadcaster_key = f"printer-{printer_id}"
-    remaining_subscribers = get_subscriber_count(broadcaster_key)
+    remaining_subscribers = sum(get_subscriber_count(key) for key in _printer_broadcaster_keys(printer_id))
     if remaining_subscribers >= 1:
         logger.info(
             "Skipping force-shutdown for printer %s: %d subscriber(s) still attached; "
@@ -1400,7 +1461,7 @@ async def stop_camera_stream(
         )
         return {"stopped": 0, "skipped": True}
 
-    stopped = await _stop_printer_streams(printer_id, viewer=viewer)
+    stopped = await _stop_printer_streams(printer_id)
     logger.info("Stopped %s camera stream(s) for printer %s", stopped, printer_id)
     return {"stopped": stopped}
 
@@ -1414,8 +1475,7 @@ async def forget_printer(printer_id: int) -> None:
     """
     await _stop_printer_streams(printer_id)
     # External MJPEG and snapshot streams run no process the stop finds; they end
-    # at their next frame once told to. Not in _stop_printer_streams: the stop
-    # route would end every other viewer's stream of the printer with them.
+    # at their next frame once told to.
     for stream_id, event in list(_disconnect_events.items()):
         if stream_id.startswith(f"{printer_id}-"):
             event.set()
@@ -1425,34 +1485,22 @@ async def forget_printer(printer_id: int) -> None:
     _active_external_streams.pop(printer_id, None)
 
 
-async def _stop_printer_streams(printer_id: int, *, viewer: str | None = None) -> int:
-    """Stop the fan-out broadcaster and the stream processes of a printer.
-
-    With a viewer (a viewer's /camera/stop), external camera streams other
-    viewers opened and that still send frames are left running; that viewer's
-    own streams end, and so does any external stream that sent nothing for
-    STOP_STALE_EXTERNAL_SECONDS (stuck, or orphaned by a skipped cleanup, #2675).
-    """
-    broadcaster_key = f"printer-{printer_id}"
+async def _stop_printer_streams(printer_id: int) -> int:
+    """Stop the fan-out broadcasters and the stream processes of a printer."""
     stopped = 0
-    now = time.time()
 
-    # Tear down the fan-out broadcaster first (#1089). This cleanly notifies
-    # all subscribed viewers and asks the upstream generator to stop
-    # reconnecting before we fall back to forcefully killing the process below.
-    if await shutdown_broadcaster(broadcaster_key):
-        logger.info("Shut down camera fan-out broadcaster for printer %s", printer_id)
+    # Tear down the fan-out broadcasters first (#1089): the printer's own camera
+    # and its external one. This cleanly notifies all subscribed viewers and asks
+    # the upstream generator to stop reconnecting before we fall back to
+    # forcefully killing the process below.
+    for key in _printer_broadcaster_keys(printer_id):
+        if await shutdown_broadcaster(key):
+            logger.info("Shut down camera fan-out broadcaster %s", key)
 
     # Stop ffmpeg/RTSP streams
     to_remove = []
     for stream_id, process in list(_active_streams.items()):
         if stream_id.startswith(f"{printer_id}-"):
-            if viewer is not None and stream_id.startswith(f"{printer_id}-ext-"):
-                last_frame = _stream_last_frame_times.get(stream_id)
-                stale = last_frame is None or now - last_frame >= STOP_STALE_EXTERNAL_SECONDS
-                if _stream_viewers.get(stream_id) != viewer and not stale:
-                    logger.info("Leaving external camera stream %s running: another viewer's", stream_id)
-                    continue
             to_remove.append(stream_id)
             # Signal the generator to stop reconnecting BEFORE killing the process
             event = _disconnect_events.get(stream_id)
@@ -1472,7 +1520,6 @@ async def _stop_printer_streams(printer_id: int, *, viewer: str | None = None) -
         _active_streams.pop(stream_id, None)
         _disconnect_events.pop(stream_id, None)
         _stream_last_frame_times.pop(stream_id, None)
-        _stream_viewers.pop(stream_id, None)
 
     # Stop chamber image streams
     to_remove_chamber = []
@@ -2364,7 +2411,6 @@ async def cleanup_orphaned_streams():
             _active_streams.pop(sid, None)
             _disconnect_events.pop(sid, None)
             _stream_last_frame_times.pop(sid, None)
-            _stream_viewers.pop(sid, None)
             _spawned_ffmpeg_pids.pop(proc.pid, None)
             cleaned += 1
 

+ 21 - 0
backend/app/services/camera_fanout.py

@@ -314,6 +314,27 @@ async def shutdown_broadcaster(key: str) -> bool:
     return True
 
 
+async def retire_broadcasters(prefix: str, *, keep: str) -> None:
+    """Shut down every broadcaster under ``prefix`` except ``keep``, and wait
+    (bounded) until their upstreams have closed.
+
+    For a camera that changed: the new upstream has a different key, so the
+    predecessor gate cannot hold it back while the old one still has the
+    device open, and a camera that allows one reader (USB) would refuse it.
+    Covers one already tearing down after its grace window, too.
+    """
+    async with _registry_lock:
+        retiring = [bc for key, bc in _broadcasters.items() if key.startswith(prefix) and key != keep]
+    for bc in retiring:
+        if not bc.stopped:
+            await bc.force_shutdown()
+    for bc in retiring:
+        try:
+            await asyncio.wait_for(bc.wait_until_torn_down(), timeout=_TEARDOWN_WAIT_SECONDS)
+        except TimeoutError:
+            logger.warning("Broadcaster %r didn't tear down in time; continuing", bc.key)
+
+
 async def shutdown_all_broadcasters() -> None:
     """Tear down every broadcaster (for app shutdown)."""
     async with _registry_lock:

+ 16 - 12
backend/app/services/external_camera.py

@@ -1128,6 +1128,20 @@ async def _stream_mjpeg(url: str) -> AsyncGenerator[bytes, None]:
         logger.error("MJPEG stream error: %s", e)
 
 
+async def _end_stream_ffmpeg(process: asyncio.subprocess.Process) -> None:
+    """Stop a streaming ffmpeg, bounded.
+
+    Through the camera routes' terminator, which drains the pipes while it
+    waits: a streaming ffmpeg is usually blocked writing to a full stdout pipe,
+    where it ignores SIGTERM and, once killed, wait() never returns (#2580).
+    Unbounded, that hung the shared upstream's teardown, and with it every stop,
+    camera change, printer delete and shutdown waiting on the fan-out.
+    """
+    from backend.app.api.routes.camera import _terminate_ffmpeg
+
+    await _terminate_ffmpeg(process)
+
+
 async def _stream_rtsp(
     url: str,
     fps: int,
@@ -1304,12 +1318,7 @@ async def _stream_rtsp(
         if log_watch is not None:
             log_watch.cancel()
         if process and process.returncode is None:
-            process.terminate()
-            try:
-                await asyncio.wait_for(process.wait(), timeout=2.0)
-            except TimeoutError:
-                process.kill()
-                await process.wait()
+            await _end_stream_ffmpeg(process)
         if proxy_server:
             await close_tls_proxy(proxy_server)
 
@@ -1417,9 +1426,4 @@ async def _stream_usb(
         logger.error("USB stream error: %s", e)
     finally:
         if process and process.returncode is None:
-            process.terminate()
-            try:
-                await asyncio.wait_for(process.wait(), timeout=2.0)
-            except TimeoutError:
-                process.kill()
-                await process.wait()
+            await _end_stream_ffmpeg(process)

+ 226 - 0
backend/tests/integration/test_camera_external_fanout.py

@@ -0,0 +1,226 @@
+"""External camera streams share one upstream per printer (RC-C6).
+
+Every viewer of an external camera started its own upstream: its own ffmpeg,
+MJPEG connection or snapshot poll. A USB camera allows one V4L2 handle, so a
+second viewer (a Cam Wall tile and the camera page, two browsers) failed with
+"device busy". External cameras now use the same fan-out broadcaster as the
+printer's own camera (#1089).
+"""
+
+import asyncio
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from backend.app.api.routes import camera
+from backend.app.models.printer import Printer
+from backend.app.services import camera_fanout
+
+pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
+
+
+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
+
+
+class _Upstream:
+    """A fake generate_mjpeg_stream that counts how often a camera is opened."""
+
+    def __init__(self) -> None:
+        self.opened: list[tuple[str, str]] = []
+        self.closed = 0
+
+    async def __call__(self, url, camera_type, fps, *, on_process=None, on_frame=None, stop_event=None):
+        self.opened.append((url, camera_type))
+        if on_process is not None:
+            on_process(_Proc(60000 + len(self.opened)))
+        try:
+            while stop_event is None or not stop_event.is_set():
+                if on_frame is not None:
+                    on_frame(b"\xff\xd8frame\xff\xd9")
+                yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n\xff\xd8frame\xff\xd9\r\n"
+                await asyncio.sleep(0.01)
+        finally:
+            self.closed += 1
+
+
+@pytest.fixture
+def upstream(monkeypatch):
+    fake = _Upstream()
+    monkeypatch.setattr(camera_fanout, "_GRACE_SECONDS", 0.05)
+    with patch("backend.app.services.external_camera.generate_mjpeg_stream", fake):
+        yield fake
+
+
+@pytest.fixture
+async def usb_printer(async_client, db_session) -> int:
+    # async_client points database.async_session, which camera_stream opens, at the test DB.
+    row = Printer(
+        name="USB cam",
+        serial_number="CAM-FANOUT",
+        ip_address="192.0.2.71",
+        access_code="x",
+        external_camera_enabled=True,
+        external_camera_url="/dev/video0",
+        external_camera_type="usb",
+    )
+    db_session.add(row)
+    await db_session.commit()
+    yield row.id
+    await camera.forget_printer(row.id)
+
+
+def _request() -> MagicMock:
+    request = MagicMock()
+    request.is_disconnected = AsyncMock(return_value=False)
+    return request
+
+
+async def _open(printer_id: int, viewer: str | None = None):
+    response = await camera.camera_stream(printer_id, _request(), fps=5, opener=None, viewer=viewer)
+    stream = response.body_iterator
+    await asyncio.wait_for(anext(stream), 2)
+    return stream
+
+
+async def _settle() -> None:
+    await asyncio.sleep(0.15)
+
+
+async def test_two_viewers_share_one_upstream(upstream, usb_printer):
+    tile = await _open(usb_printer, "tile-a")
+    page = await _open(usb_printer, "page-b")
+
+    await asyncio.wait_for(anext(tile), 2)
+    await asyncio.wait_for(anext(page), 2)
+
+    assert upstream.opened == [("/dev/video0", "usb")]
+    usb_streams = [sid for sid in camera._active_streams if sid.startswith(f"{usb_printer}-ext-")]
+    assert len(usb_streams) == 1
+
+    await tile.aclose()
+    await page.aclose()
+
+
+async def test_the_other_viewer_keeps_watching_when_one_leaves(upstream, usb_printer):
+    tile = await _open(usb_printer)
+    page = await _open(usb_printer)
+
+    await tile.aclose()
+
+    assert await asyncio.wait_for(anext(page), 2)
+    assert (await camera.camera_status(usb_printer, None))["active"]
+    assert upstream.closed == 0
+    await page.aclose()
+
+
+async def test_the_upstream_closes_after_the_last_viewer(upstream, usb_printer):
+    page = await _open(usb_printer)
+    await page.aclose()
+
+    await _settle()
+
+    assert upstream.closed == 1
+    assert not any(sid.startswith(f"{usb_printer}-ext-") for sid in camera._active_streams)
+    assert not (await camera.camera_status(usb_printer, None))["active"]
+
+
+async def test_a_viewer_back_within_the_grace_window_reuses_the_upstream(upstream, usb_printer, monkeypatch):
+    """A page reload: closing and reopening a USB camera costs seconds."""
+    monkeypatch.setattr(camera_fanout, "_GRACE_SECONDS", 5.0)
+    first = await _open(usb_printer)
+    await first.aclose()
+
+    again = await _open(usb_printer)
+
+    assert len(upstream.opened) == 1
+    status = await camera.camera_status(usb_printer, None)
+    assert status["active"] and status["stream_uptime"] is not None
+    await again.aclose()
+
+
+async def test_a_stop_while_another_viewer_watches_leaves_the_stream(upstream, usb_printer):
+    page = await _open(usb_printer, "page-b")
+
+    result = await camera.stop_camera_stream(usb_printer, _=None, viewer="tile-a")
+
+    assert result.get("skipped") is True
+    assert await asyncio.wait_for(anext(page), 2)
+    await page.aclose()
+
+
+async def test_a_stop_with_nobody_watching_ends_the_upstream(upstream, usb_printer, monkeypatch):
+    monkeypatch.setattr(camera_fanout, "_GRACE_SECONDS", 5.0)
+    page = await _open(usb_printer)
+    await page.aclose()
+
+    await camera.stop_camera_stream(usb_printer, _=None)
+
+    assert upstream.closed == 1
+    assert not any(sid.startswith(f"{usb_printer}-ext-") for sid in camera._active_streams)
+
+
+async def test_a_changed_camera_gets_its_own_upstream(upstream, usb_printer, db_session):
+    old = await _open(usb_printer)
+
+    row = await db_session.get(Printer, usb_printer)
+    row.external_camera_url = "/dev/video2"
+    await db_session.commit()
+    new = await _open(usb_printer)
+
+    assert upstream.opened == [("/dev/video0", "usb"), ("/dev/video2", "usb")]
+    # The old camera's viewer is ended rather than left on the old device.
+    with pytest.raises(StopAsyncIteration):
+        for _ in range(20):
+            await asyncio.wait_for(anext(old), 2)
+    assert await asyncio.wait_for(anext(new), 2)
+    await new.aclose()
+
+
+async def test_a_deleted_printer_ends_the_shared_stream(upstream, usb_printer):
+    page = await _open(usb_printer)
+
+    await camera.forget_printer(usb_printer)
+
+    with pytest.raises(StopAsyncIteration):
+        for _ in range(20):
+            await asyncio.wait_for(anext(page), 2)
+    await _settle()
+    assert upstream.closed == 1
+
+
+async def test_the_live_frame_is_published_for_one_shot_captures(upstream, usb_printer):
+    page = await _open(usb_printer)
+
+    defer, frame = camera.live_frame_for_capture(usb_printer)
+
+    assert defer and frame == b"\xff\xd8frame\xff\xd9"
+    await page.aclose()
+
+
+async def test_a_snapshot_in_the_grace_window_gets_the_live_frame(upstream, usb_printer, monkeypatch):
+    """The upstream still holds the camera for a few seconds after the last viewer."""
+    monkeypatch.setattr(camera_fanout, "_GRACE_SECONDS", 5.0)
+    page = await _open(usb_printer)
+    await page.aclose()
+    # Only frames taken after the viewer left count.
+    camera._last_frames.pop(usb_printer, None)
+    await asyncio.sleep(0.05)
+
+    defer, frame = camera.live_frame_for_capture(usb_printer)
+
+    assert defer and frame == b"\xff\xd8frame\xff\xd9"
+    assert not (await camera.camera_status(usb_printer, None))["stalled"]

+ 17 - 19
backend/tests/integration/test_camera_stream_recheck.py

@@ -65,8 +65,15 @@ def _frames(monkeypatch):
         yield
 
 
+def _request() -> MagicMock:
+    """A connected client: the fan-out asks between frames whether it left."""
+    request = MagicMock()
+    request.is_disconnected = AsyncMock(return_value=False)
+    return request
+
+
 async def _open(printer_id: int, opener):
-    response = await camera.camera_stream(printer_id, MagicMock(), fps=5, opener=opener)
+    response = await camera.camera_stream(printer_id, _request(), fps=5, opener=opener)
     stream = response.body_iterator
     await anext(stream)
     return stream
@@ -168,7 +175,7 @@ async def test_a_stream_opened_as_the_session_ends_does_not_outlive_it(admin, pr
     )
     await db_session.commit()
 
-    response = await camera.camera_stream(printer, MagicMock(), fps=5, opener=opener)
+    response = await camera.camera_stream(printer, _request(), fps=5, opener=opener)
     assert await _ended(response.body_iterator)
     assert await auth.camera_stream_access(token) is None
 
@@ -377,16 +384,20 @@ async def test_a_stream_whose_body_never_starts_leaves_the_camera_inactive(admin
     """The client went away before the first chunk: no finally runs, so the
     count must only go up once the stream really runs."""
     token = await auth.create_camera_stream_token(username="camadmin")
-    response = await camera.camera_stream(printer, MagicMock(), fps=5, opener=await _opener(token))
+    response = await camera.camera_stream(printer, _request(), fps=5, opener=await _opener(token))
 
     await response.body_iterator.aclose()
 
     assert not await _status(printer)
 
 
-async def test_one_viewer_leaving_keeps_the_printers_frame_state_for_the_others(admin, printer):
+async def test_one_viewer_leaving_keeps_the_printers_frame_state_for_the_others(admin, printer, monkeypatch):
     """Without it /camera/status has no stream_uptime, so the remaining viewer can
-    never read the camera as stalled and would not reconnect if it stops."""
+    never read the camera as stalled and would not reconnect if it stops. The
+    state goes with the shared upstream, after its grace window."""
+    from backend.app.services import camera_fanout
+
+    monkeypatch.setattr(camera_fanout, "_GRACE_SECONDS", 0.05)
     token = await auth.create_camera_stream_token(username="camadmin")
     tile = await _open(printer, await _opener(token))
     viewer = await _open(printer, await _opener(token))
@@ -397,24 +408,11 @@ async def test_one_viewer_leaving_keeps_the_printers_frame_state_for_the_others(
     assert printer in camera._last_frames
 
     await viewer.aclose()
+    await asyncio.sleep(0.3)
     assert (await camera.camera_status(printer, None))["stream_uptime"] is None
     assert printer not in camera._last_frames
 
 
-async def test_an_external_stream_remembers_the_viewer_that_opened_it(admin, printer):
-    """So that viewer's /camera/stop ends it and leaves the others' running."""
-    token = await auth.create_camera_stream_token(username="camadmin")
-    response = await camera.camera_stream(printer, MagicMock(), fps=5, opener=await _opener(token), viewer="tile-a")
-    stream = response.body_iterator
-    await anext(stream)
-
-    owned = [sid for sid, viewer in camera._stream_viewers.items() if sid.startswith(f"{printer}-ext-")]
-    assert len(owned) == 1 and camera._stream_viewers[owned[0]] == "tile-a"
-
-    await stream.aclose()
-    assert owned[0] not in camera._stream_viewers
-
-
 async def test_a_viewer_id_is_a_short_token(async_client: AsyncClient, admin, printer):
     login = await async_client.post("/api/v1/auth/login", json={"username": "camadmin", "password": "AdminPass1!"})
     headers = {"Authorization": f"Bearer {login.json()['access_token']}"}

+ 55 - 0
backend/tests/unit/services/test_camera_fanout.py

@@ -584,3 +584,58 @@ async def test_subscriber_with_no_frames_detaches_promptly():
     await asyncio.wait_for(drain(), timeout=3.0)
     assert bc.subscriber_count == 0
     await bc.force_shutdown()
+
+
+class TestRetireBroadcasters:
+    """A changed camera gets a new key, so the predecessor gate cannot hold its
+    upstream back while the old one still has a single-reader device open."""
+
+    @staticmethod
+    def _slow_closing(closed: list[str], name: str):
+        async def factory(disconnect_event: asyncio.Event) -> AsyncGenerator[bytes, None]:
+            try:
+                while not disconnect_event.is_set():
+                    yield b"--frame\r\n"
+                    await asyncio.sleep(0.01)
+            finally:
+                await asyncio.sleep(0.2)  # closing the device takes a while
+                closed.append(name)
+
+        return factory
+
+    @pytest.mark.asyncio
+    async def test_waits_until_the_old_upstream_has_closed(self):
+        closed: list[str] = []
+        old = await camera_fanout.get_or_create_broadcaster("printer-9-ext-old", self._slow_closing(closed, "old"))
+        await old.subscribe()
+        await asyncio.sleep(0.05)
+
+        await camera_fanout.retire_broadcasters("printer-9-ext-", keep="printer-9-ext-new")
+
+        assert closed == ["old"]
+
+    @pytest.mark.asyncio
+    async def test_also_waits_for_one_already_tearing_down(self):
+        closed: list[str] = []
+        old = await camera_fanout.get_or_create_broadcaster("printer-9-ext-old", self._slow_closing(closed, "old"))
+        await old.subscribe()
+        await asyncio.sleep(0.05)
+        teardown = asyncio.create_task(old.force_shutdown())
+        await asyncio.sleep(0.01)
+
+        await camera_fanout.retire_broadcasters("printer-9-ext-", keep="printer-9-ext-new")
+
+        assert closed == ["old"]
+        await teardown
+
+    @pytest.mark.asyncio
+    async def test_leaves_the_kept_one_and_other_printers_alone(self):
+        closed: list[str] = []
+        kept = await camera_fanout.get_or_create_broadcaster("printer-9-ext-new", self._slow_closing(closed, "new"))
+        other = await camera_fanout.get_or_create_broadcaster("printer-19-ext-x", self._slow_closing(closed, "other"))
+        await kept.subscribe()
+        await other.subscribe()
+
+        await camera_fanout.retire_broadcasters("printer-9-ext-", keep="printer-9-ext-new")
+
+        assert not kept.stopped and not other.stopped

+ 32 - 63
backend/tests/unit/test_camera_stop_spares_live_external.py

@@ -1,13 +1,12 @@
-"""/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.
+"""/camera/stop and the janitor on external camera streams.
+
+External cameras used to run one stream per viewer, and the stop route told
+them apart by a viewer id. They now share one upstream through the fan-out
+(RC-C6, see test_camera_external_fanout.py), so the subscriber guard decides
+whether a stop may end it, as for the printer's own camera. What is left to
+pin here: a stop that gets past the guard ends the printer's external streams,
+a deleted printer loses all of them, and the janitor still finds a leaked or
+stuck one (#2675).
 """
 
 from __future__ import annotations
@@ -55,15 +54,13 @@ def no_fanout(monkeypatch):
 def register():
     added: list[tuple[str, _Proc]] = []
 
-    def _register(suffix: str, pid: int, viewer: str | None, last_frame_age: float | None = 0.5):
+    def _register(suffix: str, pid: int, 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))
@@ -74,93 +71,65 @@ def register():
         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)
+async def test_a_stop_with_nobody_watching_ends_every_external_stream(no_fanout, register):
+    """Also one whose cleanup was skipped and still looks live (#2675)."""
+    a, a_event = register("all00004", 54007)
+    b, _ = register("all00005", 54008, last_frame_age=60.0)
 
     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
+    assert a.returncode is not None and b.returncode is not None and a_event.is_set()
+    assert f"{PRINTER}-ext-all00004" not in camera._active_streams
 
 
 @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")
+async def test_a_stop_while_someone_watches_ends_nothing(no_fanout, register, monkeypatch):
+    live, live_event = register("live0005", 54009)
+    monkeypatch.setattr(camera, "get_subscriber_count", lambda _key: 1)
 
-    result = await camera.stop_camera_stream(PRINTER, _=None)
+    result = await camera.stop_camera_stream(PRINTER, _=None, viewer="tile-a")
 
-    assert result["stopped"] == 2
-    assert a.returncode is not None and b.returncode is not None
+    assert result.get("skipped") is True
+    assert live.returncode is None and not live_event.is_set()
 
 
 @pytest.mark.asyncio
 async def test_a_deleted_printer_loses_every_stream(no_fanout, register):
-    live, live_event = register("live0006", 54009, viewer="page-b")
+    live, live_event = register("live0006", 54010)
 
     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
+    assert f"{PRINTER}-ext-live0006" not in camera._active_streams
 
 
 @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)."""
+async def test_the_janitor_kills_a_stuck_external_stream(no_fanout, register, monkeypatch):
+    """Orphaned by a skipped cleanup, with nobody left to stop it (#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)
+    stale, stale_event = register("stale007", os.getpid(), 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
+    assert f"{PRINTER}-ext-stale007" not in camera._active_streams
 
 
 @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."""
+async def test_the_janitor_keeps_a_stream_between_reconnects(no_fanout, register, monkeypatch):
+    """Its ffmpeg died and the upstream is starting the next one under the same
+    id: a stop must still reach it."""
     monkeypatch.setattr(camera, "_scan_bambu_ffmpeg_pids", lambda: [])
-    dead, _ = register("dead0008", 54011, viewer="tile-a")
+    dead, _ = register("dead0008", 54011)
     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

+ 51 - 1
backend/tests/unit/test_camera_usb_stream_cleanup.py

@@ -14,7 +14,7 @@ from __future__ import annotations
 import asyncio
 import time
 from contextlib import suppress
-from unittest.mock import mock_open, patch
+from unittest.mock import AsyncMock, mock_open, patch
 
 import pytest
 
@@ -240,3 +240,53 @@ def test_bambu_rtsp_is_ours_whatever_the_parent():
 
 def test_unknown_parent_is_not_ours_for_usb():
     assert not camera._is_bambuddy_ffmpeg(STREAM, None, OWN_PID)
+
+
+# ---------------------------------------------------------------------------
+# 6. A stuck ffmpeg can never hang the stream's own cleanup (#2580)
+# ---------------------------------------------------------------------------
+
+
+class _WedgedProc:
+    """ffmpeg blocked in write() on a full pipe: SIGTERM is ignored, and after
+    SIGKILL wait() never returns while its pipes are unread."""
+
+    def __init__(self) -> None:
+        self.pid = 52999
+        self.returncode = None
+        self.stdout = asyncio.StreamReader()
+        self.stdout.feed_data(b"\xff\xd8" + b"x" * 200 + b"\xff\xd9")
+        self.stderr = asyncio.StreamReader()
+        self.killed = False
+
+    def terminate(self) -> None:
+        pass
+
+    def kill(self) -> None:
+        self.killed = True
+        self.returncode = -9
+
+    async def wait(self) -> int:
+        await asyncio.Event().wait()
+        return -9  # pragma: no cover
+
+
+@pytest.mark.asyncio
+async def test_a_wedged_usb_ffmpeg_does_not_hang_the_stream_cleanup(monkeypatch):
+    """The shared upstream's cleanup runs in the fan-out broadcaster, which stop,
+    a camera change, printer delete and shutdown all wait on."""
+    from backend.app.services import external_camera
+
+    monkeypatch.setattr(camera, "_FFMPEG_TERM_TIMEOUT", 0.05)
+    monkeypatch.setattr(camera, "_FFMPEG_KILL_TIMEOUT", 0.05)
+    proc = _WedgedProc()
+    monkeypatch.setattr(external_camera, "get_ffmpeg_path", lambda: "/usr/bin/ffmpeg")
+    monkeypatch.setattr(external_camera, "_safe_usb_device_path", lambda _d: "/dev/video0")
+    monkeypatch.setattr("asyncio.create_subprocess_exec", AsyncMock(return_value=proc))
+
+    stream = external_camera._stream_usb("/dev/video0", 5)
+    assert await asyncio.wait_for(anext(stream), 2)
+
+    await asyncio.wait_for(stream.aclose(), 2)
+
+    assert proc.killed

+ 3 - 3
frontend/src/hooks/useCameraViewerId.ts

@@ -2,9 +2,9 @@ import { useState } from 'react';
 
 /**
  * A random id for one camera viewer, stable while it is mounted. It goes on
- * the viewer's stream URL and on its /camera/stop, so the stop ends that
- * viewer's own external camera stream and leaves other viewers' running
- * (external cameras run one stream per viewer, unseen by the fan-out guard).
+ * the viewer's stream URL and on its /camera/stop. Every camera, external ones
+ * included, now shares one upstream per printer, and the stop leaves it running
+ * while another viewer still watches; the id names the viewer in the request.
  *
  * Math.random, not crypto.randomUUID: that needs a secure context, and
  * Bambuddy is often opened over plain http on the LAN.