diff --git a/CHANGELOG.md b/CHANGELOG.md index aa4678b98..77df66606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ 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 +- **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. - **Timelapses that never got attached, and a Scan button that could not find them (#2704)** — Timelapse was on for the print, the video never arrived in the archive, and pressing **Scan for Timelapse** afterwards turned up nothing. Measured across 247 support bundles, this was not rare: of 457 automatic scans only 262 ever attached a video. **Root cause, part one.** The scan looked four times, at 5, 10, 20 and 30 seconds, then stopped. The printer writes the video only after the print ends and a long print makes a large file, so it often arrived after the last look — the attempt that found the video was the first one 272 times and then 17 / 13 / 13, a flat tail against the cutoff rather than a decaying one. What ran after those four attempts was a fallback that searched for the print's name inside the video filename; Bambu firmware only ever writes `video_`, so in 247 bundles it fired 159 times and matched exactly zero. **Root cause, part two.** The manual Scan button had no such snapshot to work from and matched by filename timestamp, by FTP modification time, or by there being exactly one video on the printer — all of which read a clock the printer cannot set, because a printer in LAN Only mode never reaches Bambu's time server. The reporter's P1S was six and a half days out, which defeats every one of those. **Fix.** The automatic scan now polls for several minutes instead of giving up after about a minute, and the name-match fallback is gone. The list of videos present when the print started is saved with the archive, so the comparison survives a Bambuddy restart mid-print and the manual Scan button can use it too — same clock-independent comparison, no timestamps anywhere. When a previous print's video lands late and two files look new, the one already attached to another archive is ruled out by name rather than by picking whichever the printer listed first, which could attach the wrong video. **Bambuddy now deletes a timelapse from the printer once it has been archived**, which keeps the printer's folder down to unclaimed videos and stops P1-series cards filling up with AVIs; your copy is in the archive, where you can watch, edit, download or remove it. That delete only happens after the transfer has been checked against the size the printer reported — which also fixes a silent truncation: an FTPS transfer that ended early produced a partial video that was attached as though it were complete. Because the first look happens seconds after the print ends — while the printer may still be writing the video — the file is also re-checked afterwards and only accepted once it has stopped growing, so a partial video is never mistaken for a finished one and the printer's copy is never removed on the strength of one. Wiki updated. Covered by tests for candidate selection, the download check gating the delete, the poll bounds, baseline persistence and the manual scan. diff --git a/backend/app/services/camera.py b/backend/app/services/camera.py index c961f7a43..1863fff01 100644 --- a/backend/app/services/camera.py +++ b/backend/app/services/camera.py @@ -6,6 +6,7 @@ Supports two camera protocols: """ import asyncio +import functools import logging import os import shutil @@ -34,6 +35,26 @@ _rtsp_socket_timeout_flag: str | None = None # The cleanup task in routes/camera.py checks this set to avoid killing active captures. _active_capture_pids: set[int] = set() +# In-flight one-shot captures, keyed by printer IP (#2705). +# +# Bambu firmware allows exactly one camera connection, and the existing guards +# (is_stream_active / try_get_active_buffered_frame, #1271 + #1348) only stop a +# capturer from competing with the fan-out BROADCASTER. They do nothing for +# capturer-vs-capturer with no viewer attached, where every consumer correctly +# concludes it isn't competing with a viewer and then collides with the others. +# Eight paths reach capture_camera_frame_bytes() independently — Obico polling, +# /camera/snapshot, the finish-photo moment and its disk-writing sibling, plate +# detection, the camera test and the diagnose tool — so the single-flight lives +# at the bottom of the stack and needs no call-site changes. +# +# Keyed by IP rather than printer_id because IP is what the firmware's one- +# connection limit applies to: two printer rows pointing at the same address +# still share one camera. (This function never sees a printer_id anyway.) The +# key deliberately excludes the timeout, or callers that disagree about it — +# and they all do, from 10s to 30s — would never coalesce, which is exactly +# the Obico-vs-snapshot pair from the report. +_inflight_captures: dict[str, asyncio.Task[bytes | None]] = {} + def get_ffmpeg_path() -> str | None: """Find the ffmpeg executable path. @@ -529,6 +550,38 @@ async def capture_camera_frame( return False +def capture_in_flight(ip_address: str) -> bool: + """Return True iff a one-shot capture for this IP is running right now. + + For callers that need to know whether they will JOIN someone else's + capture rather than perform their own — currently only the diagnose tool, + which reports on what it measured and so must not present a coalesced + frame as proof that it opened its own connection (see camera_diagnose). + + Ordinary consumers should ignore this: they want "a recent frame", and + capture_camera_frame_bytes() already does the right thing for them. + """ + task = _inflight_captures.get(ip_address) + return task is not None and not task.done() + + +def _discard_inflight_capture(ip_address: str, task: asyncio.Task) -> None: + """Done-callback: drop the finished task from the in-flight registry. + + Guarded on identity so a slow task that finishes after a newer capture + has registered can't evict its successor. + + Also retrieves the exception, if any. The leader normally awaits the task + and would surface it, but a leader whose own caller was cancelled leaves + nobody to collect it — and an unretrieved task exception is logged by + asyncio as a warning with a traceback at an arbitrary later point. + """ + if _inflight_captures.get(ip_address) is task: + del _inflight_captures[ip_address] + if not task.cancelled() and task.exception() is not None: + logger.debug("In-flight camera capture for %s ended in an exception", ip_address) + + async def capture_camera_frame_bytes( ip_address: str, access_code: str, @@ -537,18 +590,95 @@ async def capture_camera_frame_bytes( ) -> bytes | None: """Capture a single frame and return as JPEG bytes (no disk write). - Uses the same protocol selection as capture_camera_frame but returns - bytes directly instead of writing to disk. + Concurrent callers for the same printer share one capture (#2705): the + first opens the connection, everyone arriving while it is in flight awaits + the same result. Every consumer here wants "a recent frame" rather than + "a frame captured at exactly my timestamp", so handing identical bytes to + simultaneous callers is correct — and it is the only way to honour the + firmware's one-connection limit without serialising captures behind a lock + (which would just turn a collision into a queue). + + This coalesces; it does not cache. A call that arrives after the previous + capture finished always captures fresh. Two consumers of these frames — + plate detection and the finish-photo path — decide things about a running + print from them, and a stale frame there is worse than a slow one: the + whole of #1397 was a finish photo taken seconds late showing the bed + already lowered. Args: ip_address: Printer IP address access_code: Printer access code model: Printer model (X1, H2D, P1, A1, etc.) - timeout: Timeout in seconds for the capture operation + timeout: Timeout in seconds for the capture operation. Applies to this + caller's own wait, including when it joins another caller's + capture — the call sites disagree about the value (10s for plate + detection, 20s for Obico), and a follower must not silently + inherit the leader's deadline in either direction. Returns: JPEG bytes if capture was successful, None otherwise """ + # A follower whose leader fails takes a turn of its own rather than + # inheriting a failure it never had a chance to avoid — by then the leader + # has finished, so there is no socket left to compete with. Bounded at two + # rounds: if the capture we joined AND its replacement both failed, a third + # connection won't help, and this caller has already spent its patience. + for _ in range(2): + leader = _inflight_captures.get(ip_address) + if leader is None or leader.done(): + break + try: + frame = await asyncio.wait_for(asyncio.shield(leader), timeout=timeout) + except TimeoutError: + # shield() keeps the capture running for whoever else is still + # waiting on it — giving up is this caller's decision alone. + logger.warning( + "Gave up waiting %ss on the in-flight camera capture for %s", + timeout, + ip_address, + ) + return None + except asyncio.CancelledError: + # Distinguish "the capture I joined was cancelled" from "I was + # cancelled". Only the former is ours to recover from. + if not leader.cancelled(): + raise + logger.info("In-flight camera capture for %s was cancelled; capturing our own", ip_address) + continue + if frame is not None: + logger.info( + "Reusing in-flight camera capture for %s: %s bytes (no second connection opened)", + ip_address, + len(frame), + ) + return frame + logger.info("In-flight camera capture for %s failed; capturing our own", ip_address) + else: + return None + + task = asyncio.create_task(_capture_camera_frame_bytes_uncoalesced(ip_address, access_code, model, timeout)) + _inflight_captures[ip_address] = task + task.add_done_callback(functools.partial(_discard_inflight_capture, ip_address)) + # No wait_for here: this caller IS the capture, and the implementation + # already enforces `timeout` internally where it can also kill the ffmpeg + # process. A second deadline on top would abandon the subprocess instead. + # shield() so that a cancelled leader (a client navigating away mid- + # snapshot is routine) doesn't take the capture down with it — the + # followers already waiting on it still get their frame. + return await asyncio.shield(task) + + +async def _capture_camera_frame_bytes_uncoalesced( + ip_address: str, + access_code: str, + model: str | None, + timeout: int = 15, +) -> bytes | None: + """Open a connection and capture one frame. See capture_camera_frame_bytes. + + Callers want that wrapper, not this: it opens a socket unconditionally, + which is the collision #2705 is about. + """ # Chamber image models: A1/P1 - returns bytes directly if is_chamber_image_model(model): logger.info("Capturing camera frame bytes from %s using chamber image protocol (model: %s)", ip_address, model) diff --git a/backend/app/services/camera_diagnose.py b/backend/app/services/camera_diagnose.py index 77f8c9caa..33e809ba1 100644 --- a/backend/app/services/camera_diagnose.py +++ b/backend/app/services/camera_diagnose.py @@ -35,6 +35,13 @@ out broadcaster to prevent). When ``is_stream_active`` reports True AND a buffered frame is fresh (last 10 s), we short-circuit the test with ``live_stream_active`` and report success — the user is literally watching the camera right now, no test needed. + +The related case is another one-shot capture (Obico polling, the cam +wall) being in flight when the user hits Diagnose. There the capture +layer coalesces for us (#2705) and no competing socket is opened, but +the frame we get back was someone else's — so ``first_frame`` still +passes and carries a ``coalesced_capture`` code, because a diagnostic +that reports a connection it didn't open is worse than a slow one. """ from __future__ import annotations @@ -46,6 +53,7 @@ from dataclasses import dataclass, field from backend.app.services.camera import ( capture_camera_frame_bytes, + capture_in_flight, get_camera_port, is_chamber_image_model, ) @@ -69,8 +77,10 @@ class CameraDiagnoseStage: name: str # "tcp_reachable" | "first_frame" | "live_stream_active" status: str # "ok" | "failed" | "skipped" duration_ms: int = 0 - # Optional machine-readable code for failures so the frontend can - # render a stage-specific hint without parsing free-text errors. + # Optional machine-readable code so the frontend can render a stage- + # specific hint without parsing free-text errors. Usually a failure + # reason; "coalesced_capture" qualifies a PASS whose frame came from a + # capture already in flight, so duration_ms isn't a connection time. code: str | None = None @@ -166,6 +176,15 @@ async def _check_first_frame( """Stage 2 — capture one frame end-to-end. Combines auth + protocol handshake + first keyframe; either it works or it doesn't.""" started = time.monotonic() + # A capture already running for this printer (an Obico poll, the cam wall) + # means capture_camera_frame_bytes will hand us THAT capture's frame rather + # than opening its own connection (#2705). Good for the printer, but this + # stage exists to report what it measured: the frame would be real evidence + # the camera works, while duration_ms would be mostly time spent queueing, + # and a pass would be claimed for a connection we never opened. So the + # stage says so, the same way the live-stream shortcut above declares + # itself instead of quietly passing. + coalesced = capture_in_flight(ip_address) try: jpeg = await capture_camera_frame_bytes( ip_address=ip_address, @@ -190,7 +209,11 @@ async def _check_first_frame( name="first_frame", status="ok", duration_ms=int((time.monotonic() - started) * 1000), + code="coalesced_capture" if coalesced else None, ) + # No annotation on the failure path: a follower whose leader fails goes on + # to capture on its own, so a None here means this stage did get its own + # attempt (or watched two consecutive captures fail — same verdict). return CameraDiagnoseStage( name="first_frame", status="failed", diff --git a/backend/tests/unit/services/test_camera_capture_coalescing.py b/backend/tests/unit/services/test_camera_capture_coalescing.py new file mode 100644 index 000000000..ae91e8349 --- /dev/null +++ b/backend/tests/unit/services/test_camera_capture_coalescing.py @@ -0,0 +1,288 @@ +"""Single-flight coalescing of one-shot camera captures (#2705). + +Bambu firmware allows exactly one camera connection. The pre-existing guards +(``is_stream_active`` / ``try_get_active_buffered_frame``, #1271 + #1348) only +keep a one-shot capturer from competing with the fan-out broadcaster; nothing +kept the capturers from competing with EACH OTHER when no viewer was attached, +so an Obico poll and a ``/camera/snapshot`` 200 ms apart each opened their own +RTSP socket and knocked the other over. + +These tests drive ``capture_camera_frame_bytes`` 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. +""" + +import asyncio + +import pytest + +from backend.app.services import camera as camera_module +from backend.app.services.camera import capture_camera_frame_bytes, 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.""" + camera_module._inflight_captures.clear() + yield + camera_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 sockets. + """ + + def __init__(self, frames=(FRAME_A, FRAME_B), gate: asyncio.Event | None = None): + self.calls: list[tuple[str, int]] = [] + self._frames = list(frames) + self._gate = gate + self.started = asyncio.Event() + + async def __call__(self, ip_address, access_code, model, timeout=15): + self.calls.append((ip_address, 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(camera_module, "_capture_camera_frame_bytes_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_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=20)) + await _let_leader_start(capture) + follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", 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): + """Verified on live hardware in the report: 5 callers, 1 connection.""" + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(gate=gate)) + + first = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S")) + await _let_leader_start(capture) + rest = [asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S")) 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_printers_do_not_coalesce(patch_capture): + """The one-connection limit is per printer, so the key must be too.""" + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(gate=gate)) + + one = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S")) + await _let_leader_start(capture) + two = asyncio.create_task(capture_camera_frame_bytes("10.0.2.44", "code", "P2S")) + await asyncio.sleep(0) + gate.set() + + assert {await one, await two} == {FRAME_A, FRAME_B} + assert capture.count == 2 + assert {ip for ip, _ in capture.calls} == {"10.0.2.43", "10.0.2.44"} + + +@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_camera_frame_bytes("10.0.2.43", "code", "P2S") == FRAME_A + assert await capture_camera_frame_bytes("10.0.2.43", "code", "P2S") == 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_camera_frame_bytes("10.0.2.43", "code", "P2S") + await asyncio.sleep(0) # let the done-callback run + + assert camera_module._inflight_captures == {} + assert capture_in_flight("10.0.2.43") 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 socket 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_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=10)) + await _let_leader_start(capture) + follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", 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_camera_frame_bytes("10.0.2.43", "code", "P2S")) + await _let_leader_start(capture) + first = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S")) + await asyncio.sleep(0) + second = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S")) + 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. + + The call sites disagree about the timeout (10s plate detection, 20s Obico), + 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_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=30)) + await _let_leader_start(capture) + impatient = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", timeout=0.01)) + patient = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S", 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 requests get cancelled routinely (client navigates away). + + 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_camera_frame_bytes("10.0.2.43", "code", "P2S")) + await _let_leader_start(capture) + follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S")) + 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_camera_frame_bytes("10.0.2.43", "code", "P2S")) + await _let_leader_start(capture) + follower = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S")) + 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 the diagnose tool uses to know it will join, not measure.""" + gate = asyncio.Event() + capture = patch_capture(RecordingCapture(gate=gate)) + + assert capture_in_flight("10.0.2.43") is False + + leader = asyncio.create_task(capture_camera_frame_bytes("10.0.2.43", "code", "P2S")) + await _let_leader_start(capture) + + assert capture_in_flight("10.0.2.43") is True + assert capture_in_flight("10.0.2.44") is False # per printer + + gate.set() + await leader + await asyncio.sleep(0) + + assert capture_in_flight("10.0.2.43") is False diff --git a/backend/tests/unit/services/test_camera_diagnose.py b/backend/tests/unit/services/test_camera_diagnose.py index b566de6d7..d5c5a9adf 100644 --- a/backend/tests/unit/services/test_camera_diagnose.py +++ b/backend/tests/unit/services/test_camera_diagnose.py @@ -221,6 +221,79 @@ class TestFirstFrameStage: assert result.overall_status == "ok" assert result.summary_code == "all_ok" assert all(s.status == "ok" for s in result.stages) + assert result.stages[1].code is None # we opened our own connection + + @pytest.mark.asyncio + async def test_pass_riding_on_an_inflight_capture_says_so(self): + """#2705: a capture already running means we get its frame, not our own. + + The frame is real evidence the camera works, so the stage still passes — + but duration_ms is then mostly time spent queueing behind someone + else's capture, and claiming a connection we never opened is exactly + what a diagnostic must not do.""" + + async def _tcp_ok(*_a, **_kw): + writer = AsyncMock() + return AsyncMock(), writer + + with ( + patch( + "backend.app.services.camera_diagnose.asyncio.open_connection", + new=_tcp_ok, + ), + patch( + "backend.app.services.camera_diagnose.capture_in_flight", + return_value=True, + ), + patch( + "backend.app.services.camera_diagnose.capture_camera_frame_bytes", + new_callable=AsyncMock, + return_value=b"\xff\xd8\xff\xd9", + ), + ): + result = await diagnose_camera( + ip_address="192.0.2.1", + access_code="x", + model="P2S", + printer_id=1, + ) + assert result.overall_status == "ok" + assert result.summary_code == "all_ok" + assert result.stages[1].status == "ok" + assert result.stages[1].code == "coalesced_capture" + + @pytest.mark.asyncio + async def test_failure_is_not_annotated_as_coalesced(self): + """A follower whose leader fails goes on to capture on its own, so a + failure here was this stage's own attempt — no qualifier needed.""" + + async def _tcp_ok(*_a, **_kw): + writer = AsyncMock() + return AsyncMock(), writer + + with ( + patch( + "backend.app.services.camera_diagnose.asyncio.open_connection", + new=_tcp_ok, + ), + patch( + "backend.app.services.camera_diagnose.capture_in_flight", + return_value=True, + ), + patch( + "backend.app.services.camera_diagnose.capture_camera_frame_bytes", + new_callable=AsyncMock, + return_value=None, + ), + ): + result = await diagnose_camera( + ip_address="192.0.2.1", + access_code="x", + model="P2S", + printer_id=1, + ) + assert result.summary_code == "no_frame" + assert result.stages[1].code == "no_frame" class TestResultMetadata: