fix(camera): one registry key per stream, not per printer (issue #2707)

Closing a camera view and reopening it immediately could leave the new
stream unregistered while it was running and delivering frames. The
damage was all indirect: is_stream_active() reported no viewer, so Obico
polling and snapshots opened a second camera connection against the live
view (the thing #1348 and #1271 exist to prevent); the janitor's /proc
scan found an ffmpeg missing from _active_streams and killed the live
stream as an orphan; and /camera/stop reported "Stopped 0" with a stream
running.

The fan-out stream id was f"{printer_id}-fanout" -- constant per printer,
so every successive stream shared one registry key, and the departing
generator's finally popped whatever was under it, including its
successor's entry. The same finally also cleared the per-printer frame
buffer unconditionally, discarding the new stream's frame. It needed the
two streams to overlap, which the 4s teardown made easy.

Each stream now gets its own key via _new_fanout_stream_id(), so a
generator can only clean up after itself -- the external-camera path
already does this (#2675) and this brings the fan-out path in line. The
per-printer dicts are released through _release_printer_frame_state(),
which checks that no other stream for the printer is still running; both
the RTSP and chamber-image cleanups had the same unconditional pop.

Also hoisted time and uuid to module level and dropped four
function-local `import time` statements. A local import shadows the name
for the whole function, so any use on a branch that doesn't reach the
import raises UnboundLocalError -- a real hazard in camera_stream, whose
external-camera branch imported both while the RTSP path needs them too.
A test pins camera_stream as free of function-local imports.
This commit is contained in:
maziggy
2026-07-30 12:35:27 +02:00
parent 18cc906fad
commit f26bcbbcce
3 changed files with 288 additions and 19 deletions
+2 -1
View File
@@ -24,7 +24,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Debug logs now record what the printer reports between the last layer and the end of a print (#2547, reporter @anthonyma94)** — The finish photo wants a moment that Bambu firmware does not obviously announce: printing done, toolhead parked, filament unload not yet started. Bambuddy has been driving that capture from `stg_cur=22` ("Filament unloading"), which turns out to fire on no model at all — across 247 support bundles there is not a single stage-22 capture, including the window in which it was the only trigger in the code, where all 104 captures on A1, A1 Mini, H2C, H2D, P1S, P2S, X1C and X2D fell through to the after-the-fact fallback. Choosing a replacement was not possible from the bundles we had, because outside `stg_cur` and `mc_print_sub_stage` every stage and action field the printers send is dropped unread, and the most promising candidates (`print_real_action`, `mc_action`, `mc_stage`) are absent from A1, A1 Mini and P1S payloads entirely. With debug logging enabled, Bambuddy now dumps those raw fields for the window between the last object layer and the end of the print — opening on the first end-of-print signal (last layer reached, progress at 99+, or no remaining time), logging only what changed frame to frame, and closing on the state transition — so a single debug bundle per model can show whether any firmware marks that moment. Diagnostics only: nothing reads these values, they are printer telemetry with nothing identifying in them, and at normal log levels the probe does no work at all. Covered by tests for the window boundaries, the frame budget and the guarantee that the probe cannot break status ingest.
### Fixed
- **Closing the camera held the printer's camera connection for four more seconds, then logged an error that wasn't true (#NNNN)** — Every time a camera view closed, the log recorded `ffmpeg didn't terminate gracefully, killing` and then `ffmpeg did not exit within 2.0s of SIGKILL; abandoning wait`. Both waits expired every single time, so each close cost a fixed four seconds — and because Bambu firmware allows exactly one camera connection, that was four seconds in which nothing else could use the camera: reopening the view, a snapshot, Obico, or the diagnostic. **Root cause.** ffmpeg is started with its output and error streams as pipes, and the shutdown path had stopped reading them. A process whose output pipe is full blocks mid-write, and ffmpeg's shutdown signal only sets a flag that its main loop checks on the next pass, so the polite request could never be acted on and the grace period was dead time. The forced kill did work — but Python cannot report an exit while a pipe is still unread, so the second wait expired too and Bambuddy concluded the process was stuck when it had already gone. Measured at 4.00s per close before, ~0.15s after. **Fix.** Both pipes are now drained while the process is being stopped, which makes the polite shutdown effective and the exit observable. The forced-kill path and its time limit remain as backstops, so a genuinely wedged process still can't hang a stream, a Stop request, or the cleanup task. This also corrects the conclusion recorded for #2580: that 12-hour hang was the unbounded form of this same self-inflicted stall rather than a stuck ffmpeg, so bounding the wait had capped the symptom without removing the cause. Covered by tests that drive a real subprocess — the fault lives in Python's pipe bookkeeping, so a stand-in object would pass against the broken code — including one that verifies a process ignoring the polite signal still has its forced exit observed rather than abandoned.
- **Reopening the camera quickly could leave the new stream invisible to Bambuddy (#2707)** — Closing a camera view and opening it again straight away could leave the newly started stream unregistered, even though it was running and showing frames. The consequences were all indirect, which is what made it hard to spot: Bambuddy believed no viewer was attached, so Obico polling and snapshots would open a second camera connection and fight the live view — precisely what the guards added in #1348 and #1271 exist to prevent; the background cleanup task saw a camera process with no stream attached to it and killed the live stream as an orphan, usually within a minute; and pressing Stop reported that it had stopped nothing while the view was still running. **Root cause.** Each printer's fan-out stream was registered under a key derived from the printer alone, so every successive stream for that printer reused the same key, and the departing stream's cleanup removed whatever was registered under it — including its own replacement. The same cleanup also cleared the printer's most recent camera frame unconditionally, discarding the new stream's frame. It needed the old and new streams to overlap, which the four-second teardown fixed above made easy to hit. **Fix.** Each stream now gets its own registry key, so one stream can only ever clean up after itself — the same approach the external-camera path already uses (#2675) — and the shared per-printer frame is only released when no stream for that printer is left running. Covered by tests for both halves, including one that drives the real cleanup path with a second stream already registered.
- **Closing the camera held the printer's camera connection for four more seconds, then logged an error that wasn't true (#2707)** — Every time a camera view closed, the log recorded `ffmpeg didn't terminate gracefully, killing` and then `ffmpeg did not exit within 2.0s of SIGKILL; abandoning wait`. Both waits expired every single time, so each close cost a fixed four seconds — and because Bambu firmware allows exactly one camera connection, that was four seconds in which nothing else could use the camera: reopening the view, a snapshot, Obico, or the diagnostic. **Root cause.** ffmpeg is started with its output and error streams as pipes, and the shutdown path had stopped reading them. A process whose output pipe is full blocks mid-write, and ffmpeg's shutdown signal only sets a flag that its main loop checks on the next pass, so the polite request could never be acted on and the grace period was dead time. The forced kill did work — but Python cannot report an exit while a pipe is still unread, so the second wait expired too and Bambuddy concluded the process was stuck when it had already gone. Measured at 4.00s per close before, ~0.15s after. **Fix.** Both pipes are now drained while the process is being stopped, which makes the polite shutdown effective and the exit observable. The forced-kill path and its time limit remain as backstops, so a genuinely wedged process still can't hang a stream, a Stop request, or the cleanup task. This also corrects the conclusion recorded for #2580: that 12-hour hang was the unbounded form of this same self-inflicted stall rather than a stuck ffmpeg, so bounding the wait had capped the symptom without removing the cause. Covered by tests that drive a real subprocess — the fault lives in Python's pipe bookkeeping, so a stand-in object would pass against the broken code — including one that verifies a process ignoring the polite signal still has its forced exit observed rather than abandoned.
- **Two snapshots taken at the same moment opened two competing camera connections (#2705, reporter @gzimbric)** — Bambu firmware allows exactly one camera connection at a time. Bambuddy already knew this: a snapshot taken while somebody is watching the live view reuses the viewer's frame instead of opening a second socket. What nothing covered was two *snapshots* overlapping with no viewer attached at all — an Obico poll and a printer-wall refresh landing 200 ms apart, each correctly concluding it wasn't competing with a viewer, and then colliding with each other. On the reporter's P2S this knocked over the live stream that was feeding the camera wall, which was then reaped for having received no frames for 58 seconds. Eight paths take one-shot frames independently — Obico polling, `/camera/snapshot`, the finish-photo capture and its disk-writing sibling, plate detection, the camera connection test, and the diagnostic — so any pair of them could overlap, and a shorter Obico interval widened the window. **Fix.** Simultaneous captures for the same printer now share one connection: the first opens it, everyone arriving while it is in flight gets the same frame. Every consumer here wants "a recent frame" rather than a frame stamped at its own microsecond, so identical bytes are the right answer. This shares captures, it does not cache them — a request arriving after the previous capture finished still takes a fresh frame, because plate detection and the finish photo judge a running print from these images and a stale frame there is worse than a slow one. Each caller keeps its own deadline (they range from 10 to 30 seconds) rather than inheriting whichever one happened to open the connection, giving up alone leaves the capture running for whoever else is waiting on it, and a capture that fails doesn't hand its failure to callers that never got an attempt of their own — they retry, which by then competes with nothing. One visible consequence: when the **Diagnose** tool shares a capture this way its frame-capture stage is labelled `coalesced_capture`, because the pass is real but the timing shown is mostly time spent waiting, and a diagnostic must not report on a connection it never opened. Wiki updated. Covered by tests for the reported collision, the five-callers-one-connection case the reporter verified on live hardware, per-printer isolation, staying coalescing rather than becoming a cache, registry cleanup, a failed capture not poisoning its followers, bounded retry, a follower abandoning its wait without sabotaging the capture, and cancellation from either side.
- **Auto-matched filament showed a green tick when the colour was plainly wrong (#2687, reporter @pchulpjoost)** — The Filament Mapping panel reported a slot as matched, with the header reading **(Ready)**, while the swatch beside it showed the slice wanted dark red and the tray it had picked held Dark Green. Manually selecting that very same tray from the dropdown correctly reported the colour mismatch, which is what made the disagreement so visible. **Root cause.** Auto-match ranks candidate trays by filament preset ID (`tray_info_idx`) first, and when exactly one loaded tray carried the preset the slice asked for, that tray was accepted as a *definitive* match on the assumption "same preset means same spool, so the colour must agree too". The preset ID names the **variant**, not the spool — `GFA00` is PLA Basic, `GFA01` PLA Matte, `GFA17` PLA Translucent, in every colour Bambu sells it. So a user with one Matte spool loaded matched every Matte requirement regardless of colour, and the colour comparison was never reached. This is why the report came in for PLA Matte in particular: generic PLA Basic is usually loaded several times over, which sent the match down a different path that did compare colours correctly. **Fix.** The colour verdict is now taken from the tray that was actually selected, never from which rule selected it, and the automatic and manual paths share one comparison so they cannot drift apart again. The preset still decides *selection*, because the Basic/Matte/Silk distinction matters ([#2650](https://github.com/maziggy/bambuddy/issues/2650)) — a wrong-coloured tray of the right variant is still chosen, but it is now reported as an amber **Color mismatch** instead of a green tick, and you can print anyway or pick another slot. A near-enough shade still counts as a match, and a 3MF that specifies no colour for a slot is satisfied by any colour rather than being flagged. Dispatch behaviour is unchanged: **Force color match** already required an exact colour before sending a job, so nothing was ever printed in the wrong colour because of this — the panel was simply telling you it was fine when it wasn't. Frontend-only. Wiki updated. Covered by tests for the unique-preset wrong-colour case, agreement between the auto and manual verdicts, the near-shade and colourless-requirement cases, and the multi-preset path that already worked.
- **P1-series archives kept the worse finish photo when the timelapse arrived late (#2704 follow-up)** — When a print records a timelapse, Bambuddy prefers the video's last frame as the finish photo: the firmware stops recording after the toolhead parks but before the end G-code drops the bed, so it frames the finished print properly, where a live camera grab at that moment catches an already-lowered plate. Bambuddy waited 60 seconds for the video and then gave up, because the print-complete notification is waiting on that photo and holding a notification for minutes is worse than sending it with the live grab. On P1-series printers the video usually arrives later than that — they write MJPEG AVI instead of H.264 MP4 and serve it slowly, so across the support bundles their median was 33 seconds but the 90th percentile was 167 and the slowest observed was 546; every other model finished inside 26 seconds. The result was that the printers most in need of the better photo were the ones that never got it. **Fix.** The notification still goes out on the same 60-second bound with the live grab, so nothing gets slower. If the video was still on its way when that bound expired, Bambuddy now keeps waiting in the background and adds the extracted frame to the archive when it lands, at the front of the photo list so opening the gallery shows it first. The live grab is kept rather than replaced — the notification that already went out links to that exact file, and removing it would leave a broken image in Discord or Telegram. Covered by tests for the ordering, the longer budget, idempotency and the cases where the video never arrives.
+48 -18
View File
@@ -5,6 +5,8 @@ import logging
import os
import subprocess
import sys
import time
import uuid
from collections.abc import AsyncGenerator
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -207,8 +209,6 @@ async def generate_chamber_mjpeg_stream(
# Save frame to buffer for photo capture and track timestamp
if printer_id is not None:
import time
_last_frames[printer_id] = frame
_last_frame_times[printer_id] = time.time()
@@ -240,10 +240,7 @@ async def generate_chamber_mjpeg_stream(
_stream_last_frame_times.pop(stream_id, None)
# Clean up frame buffer and timestamps
if printer_id is not None:
_last_frames.pop(printer_id, None)
_last_frame_times.pop(printer_id, None)
_stream_start_times.pop(printer_id, None)
_release_printer_frame_state(printer_id)
# Close the connection
try:
@@ -254,6 +251,44 @@ async def generate_chamber_mjpeg_stream(
logger.info("Chamber image stream stopped for %s (stream_id=%s)", ip_address, stream_id)
def _new_fanout_stream_id(printer_id: int) -> str:
"""Registry key for one fan-out stream INSTANCE, not for the printer.
A plain ``f"{printer_id}-fanout"`` meant every successive stream for a
printer shared one key, so a departing generator's cleanup removed the entry
its successor had just registered. The external-camera path already carries a
per-instance suffix for exactly this reason (#2675); this gives the fan-out
path the same property.
The ``f"{printer_id}-"`` prefix is load-bearing — ``is_stream_active``,
``stop_camera_stream`` and ``/camera/status`` all find a printer's streams by
scanning for it — so the suffix goes on the end.
"""
return f"{printer_id}-fanout-{uuid.uuid4().hex[:8]}"
def _release_printer_frame_state(printer_id: int | None) -> None:
"""Drop a printer's buffered frame and timings — unless a stream still owns them.
These three dicts are keyed by printer, not by stream, so a departing
generator must not clear them while a newer stream for the same printer is
running. That used to happen routinely: stream ids were per-printer, so a
predecessor's cleanup wiped its successor's state, leaving
``is_stream_active()`` False with a viewer attached (which is exactly what
the #1348 / #1271 guards read before deciding whether it is safe to open a
second camera connection), the janitor free to reap the live ffmpeg as an
orphan, and snapshots without a frame to reuse.
Call this AFTER removing the departing stream's own key, so the check
reports on other streams rather than on the caller.
"""
if printer_id is None or is_stream_active(printer_id):
return
_last_frames.pop(printer_id, None)
_last_frame_times.pop(printer_id, None)
_stream_start_times.pop(printer_id, None)
async def _drain_pipe(reader) -> None:
"""Read a subprocess pipe to EOF and discard, so it can never block.
@@ -597,8 +632,6 @@ async def generate_rtsp_mjpeg_stream(
got_any_frames = True
if printer_id is not None:
import time
_last_frames[printer_id] = frame
_last_frame_times[printer_id] = time.time()
if stream_id:
@@ -671,10 +704,7 @@ async def generate_rtsp_mjpeg_stream(
_stream_last_frame_times.pop(stream_id, None)
# Clean up frame buffer and timestamps
if printer_id is not None:
_last_frames.pop(printer_id, None)
_last_frame_times.pop(printer_id, None)
_stream_start_times.pop(printer_id, None)
_release_printer_frame_state(printer_id)
if process:
await _terminate_ffmpeg(process, stream_id)
@@ -739,9 +769,11 @@ async def camera_stream(
# Check for external camera first
if printer.external_camera_enabled and printer.external_camera_url:
import time
import uuid
# NB: no `import time` / `import uuid` here, and don't reintroduce them.
# A local import anywhere in this function makes the name function-local
# for the WHOLE function, so the RTSP/chamber path below — which never
# executes this branch — would raise UnboundLocalError on any printer
# without an external camera. Both are imported at module level.
from backend.app.services.external_camera import generate_mjpeg_stream
# Limit external camera FPS to reduce browser load
@@ -836,8 +868,6 @@ async def camera_stream(
# attached — otherwise /camera/status would report stream_uptime jumping
# backward whenever a second viewer joins. The upstream generator's
# finally clears this entry when the upstream actually ends.
import time
_stream_start_times.setdefault(printer_id, time.time())
# Fan-out broadcaster (#1089): one upstream connection per printer, shared
@@ -850,7 +880,7 @@ async def camera_stream(
# broadcaster. Concurrent viewers share that rate; new viewers after
# teardown create a fresh broadcaster at their requested fps.
fanout_key = f"printer-{printer_id}"
upstream_stream_id = f"{printer_id}-fanout"
upstream_stream_id = _new_fanout_stream_id(printer_id)
def _factory(disconnect_event: asyncio.Event):
# Re-bind locals into the closure so the async generator below sees
@@ -0,0 +1,238 @@
"""A departing camera stream must not clean up its successor's state.
The fan-out stream id used to be ``f"{printer_id}-fanout"`` — constant per
printer, so every successive stream shared one registry key — and the
generator's ``finally`` popped the per-printer frame buffer unconditionally.
Teardown taking ~4s (the undrained-pipe deadlock, fixed separately) made the
overlap wide enough to hit by closing and reopening the camera:
12.221 stream A cancelled, begins teardown
12.324 new viewer attaches
16.223 A finishes killing
16.224 new generator registers _active_streams["1-fanout"]
...then A's finally pops that very entry
The damage is not cosmetic. ``is_stream_active()`` is what the #1348 / #1271
guards consult before deciding whether opening a second camera connection is
safe, so a printer with a viewer attached looked idle; the janitor's /proc scan
reaps any ffmpeg missing from ``_active_streams``, so it killed the live stream;
and ``/camera/stop`` reported ``Stopped 0``.
The external-camera path already solved this with a per-instance id (#2675).
These tests pin the same property for the fan-out path.
"""
from __future__ import annotations
import asyncio
import time
from contextlib import suppress
import pytest
from backend.app.api.routes import camera
pytestmark = pytest.mark.asyncio
PRINTER_ID = 7701
@pytest.fixture(autouse=True)
def _clean_registries():
"""These registries are module-global; leave them as we found them."""
def _purge():
for sid in [k for k in camera._active_streams if k.startswith(f"{PRINTER_ID}-")]:
camera._active_streams.pop(sid, None)
for sid in [k for k in camera._active_chamber_streams if k.startswith(f"{PRINTER_ID}-")]:
camera._active_chamber_streams.pop(sid, None)
for sid in [k for k in camera._stream_last_frame_times if k.startswith(f"{PRINTER_ID}-")]:
camera._stream_last_frame_times.pop(sid, None)
for sid in [k for k in camera._disconnect_events if k.startswith(f"{PRINTER_ID}-")]:
camera._disconnect_events.pop(sid, None)
camera._last_frames.pop(PRINTER_ID, None)
camera._last_frame_times.pop(PRINTER_ID, None)
camera._stream_start_times.pop(PRINTER_ID, None)
_purge()
yield
_purge()
def _seed_frame_state() -> None:
camera._last_frames[PRINTER_ID] = b"\xff\xd8live\xff\xd9"
camera._last_frame_times[PRINTER_ID] = time.time()
camera._stream_start_times[PRINTER_ID] = time.time()
# ---------------------------------------------------------------------------
# _new_fanout_stream_id — one key per stream, not per printer
# ---------------------------------------------------------------------------
async def test_fanout_stream_ids_are_unique_per_stream():
"""Two streams for one printer must never collide in the registries."""
ids = {camera._new_fanout_stream_id(PRINTER_ID) for _ in range(50)}
assert len(ids) == 50, "ids collide, so one stream can clean up another's entry"
async def test_camera_stream_has_no_function_local_module_imports():
"""A local ``import x`` anywhere in camera_stream shadows x for the WHOLE
function, including branches that never reach the import.
This is not hypothetical: an ``import uuid`` inside the external-camera
branch meant building the fan-out id on the RTSP path raised
UnboundLocalError, so the camera would not start on any printer without an
external camera configured. ``time`` and ``uuid`` are module-level now;
keep them that way.
"""
import ast
import inspect
tree = ast.parse(inspect.getsource(camera.camera_stream))
local_imports = [alias.name for node in ast.walk(tree) if isinstance(node, ast.Import) for alias in node.names]
assert local_imports == [], f"function-local imports shadow the whole function: {local_imports}"
async def test_fanout_stream_id_keeps_the_printer_prefix():
"""is_stream_active / stop_camera_stream / camera-status all scan for it."""
stream_id = camera._new_fanout_stream_id(PRINTER_ID)
assert stream_id.startswith(f"{PRINTER_ID}-")
camera._active_streams[stream_id] = object()
assert camera.is_stream_active(PRINTER_ID) is True
# ---------------------------------------------------------------------------
# _release_printer_frame_state — the ownership check itself
# ---------------------------------------------------------------------------
async def test_frame_state_survives_when_another_rtsp_stream_is_live():
_seed_frame_state()
camera._active_streams[f"{PRINTER_ID}-fanout-successor"] = object()
camera._release_printer_frame_state(PRINTER_ID)
assert PRINTER_ID in camera._last_frames, "successor's buffered frame was wiped"
assert PRINTER_ID in camera._last_frame_times
assert PRINTER_ID in camera._stream_start_times
async def test_frame_state_survives_when_a_chamber_stream_is_live():
"""A1/P1 models register in a different dict; ownership spans both."""
_seed_frame_state()
camera._active_chamber_streams[f"{PRINTER_ID}-fanout-successor"] = (None, None)
camera._release_printer_frame_state(PRINTER_ID)
assert PRINTER_ID in camera._last_frames
async def test_last_stream_out_releases_the_frame_state():
"""The other half: with nothing left running, stale state must not linger."""
_seed_frame_state()
camera._release_printer_frame_state(PRINTER_ID)
assert PRINTER_ID not in camera._last_frames
assert PRINTER_ID not in camera._last_frame_times
assert PRINTER_ID not in camera._stream_start_times
async def test_release_is_a_noop_without_a_printer_id():
_seed_frame_state()
camera._release_printer_frame_state(None)
assert PRINTER_ID in camera._last_frames
# ---------------------------------------------------------------------------
# The whole generator cleanup path, with a successor already registered
# ---------------------------------------------------------------------------
class _FakeServer:
def close(self) -> None:
pass
async def wait_closed(self) -> None:
pass
class _OneFrameThenEOF:
def __init__(self) -> None:
self._sent = False
async def read(self, _size: int = -1) -> bytes:
if self._sent:
return b""
self._sent = True
return b"\xff\xd8predecessor\xff\xd9"
class _Proc:
def __init__(self, pid: int = 77010) -> None:
self.pid = pid
self.returncode = None
self.stdout = _OneFrameThenEOF()
self.stderr = None
def terminate(self) -> None:
self.returncode = 0
def kill(self) -> None:
self.returncode = -9
async def wait(self) -> int:
if self.returncode is None:
self.returncode = 0
return self.returncode
async def test_departing_generator_leaves_its_successors_registry_entry_alone(monkeypatch):
"""End of the real cleanup path, with a second stream already registered."""
async def _fake_exec(*_args, **_kwargs):
return _Proc()
async def _fake_proxy(_ip: str, _port: int):
return 48999, _FakeServer()
monkeypatch.setattr(camera, "get_ffmpeg_path", lambda: "/fake/ffmpeg")
monkeypatch.setattr(camera, "create_tls_proxy", _fake_proxy)
monkeypatch.setattr(camera.asyncio, "create_subprocess_exec", _fake_exec)
predecessor_id = f"{PRINTER_ID}-fanout-aaaaaaaa"
successor_id = f"{PRINTER_ID}-fanout-bbbbbbbb"
stream = camera.generate_rtsp_mjpeg_stream(
ip_address="192.0.2.31",
access_code="test-code",
model="P2S",
fps=15,
stream_id=predecessor_id,
disconnect_event=asyncio.Event(),
printer_id=PRINTER_ID,
)
# Drive it far enough to buffer a frame, as a real viewer would.
chunk = await asyncio.wait_for(anext(stream), timeout=5.0)
assert b"predecessor" in chunk
assert camera._last_frames[PRINTER_ID].endswith(b"predecessor\xff\xd9")
# A viewer reopens the camera mid-teardown: a fresh stream registers under
# its own id and republishes the buffered frame.
camera._active_streams[successor_id] = object()
camera._last_frames[PRINTER_ID] = b"\xff\xd8successor\xff\xd9"
with suppress(Exception):
await asyncio.wait_for(stream.aclose(), timeout=5.0)
assert successor_id in camera._active_streams, "predecessor removed its successor's entry"
assert camera.is_stream_active(PRINTER_ID) is True, "a viewer is attached; guards must see it"
assert camera._last_frames[PRINTER_ID].endswith(b"successor\xff\xd9"), "successor's frame was wiped"
assert predecessor_id not in camera._active_streams, "predecessor must still clean up after itself"