From 22b23faf568688ad5e60d8d4aff644c76f7ee15f Mon Sep 17 00:00:00 2001 From: styu12 Date: Mon, 13 Apr 2026 18:43:58 -0700 Subject: [PATCH] fix(adapters): recreate decode iterator after EAGAIN so stream resumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix caught BlockingIOError / OSError(EAGAIN) and retried, but next() was called on the SAME decode iterator. PyAV's decode() is a Python generator, and generators become exhausted after any exception propagates out of them — so every subsequent next() raised StopIteration. The capture thread then hit 'except StopIteration: break' and exited silently (no health event), leaving latest_frame frozen on whatever was set last (usually the very first frame). This matched the reported symptom: 'health events show connected but preview is frozen on the first frame.' Fix: track frame_iter explicitly, set it to None on any transient- errno path, and recreate via iter(self._input.decode(video=0)) at the top of the next loop iteration. PyAV's decode() is a fresh generator per call that shares the underlying container state, so recreation resumes packet reading without losing the session. StopIteration now cleanly exits (live cameras only emit it on true end-of-stream / device disconnect). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/syncfield/adapters/uvc_webcam.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/syncfield/adapters/uvc_webcam.py b/src/syncfield/adapters/uvc_webcam.py index bf7dd78..3bf6f21 100644 --- a/src/syncfield/adapters/uvc_webcam.py +++ b/src/syncfield/adapters/uvc_webcam.py @@ -268,8 +268,17 @@ def _capture_loop(self) -> None: # "not ready" conditions. _TRANSIENT_ERRNOS = {4, 11, 35} - frame_iter = iter(self._input.decode(video=0)) + # PyAV's container.decode() is a generator that DIES when any + # exception (including BlockingIOError/EAGAIN) propagates out of + # it — subsequent next() calls raise StopIteration against a + # dead iterator. For live camera inputs where EAGAIN is routine + # during warmup and between frames, we therefore track the + # iterator explicitly and recreate it after every EAGAIN so the + # capture thread keeps pulling fresh packets from the container. + frame_iter = None while not self._stop_event.is_set(): + if frame_iter is None: + frame_iter = iter(self._input.decode(video=0)) try: frame = next(frame_iter) capture_ns = time.monotonic_ns() @@ -298,7 +307,9 @@ def _capture_loop(self) -> None: ) ) except StopIteration: - # Device exhausted (explicit end-of-stream). + # The decode generator ended cleanly. For a live capture + # device this means the container was closed / the device + # was disconnected. Exit the loop. break except OSError as exc: # AVFoundation / V4L2 can surface "not ready" as: @@ -306,7 +317,9 @@ def _capture_loop(self) -> None: # - bare OSError(35) on macOS, OSError(11) on Linux # - FFmpegError with errno=None # All are transient and must not kill the capture loop. + # The generator is dead after any exception — recreate. if exc.errno in _TRANSIENT_ERRNOS or exc.errno is None: + frame_iter = None time.sleep(0.001) continue # Real OSError (EIO, ENODEV, etc.) — treat as fatal.