Files
Octop/tests/unit/mobile/test_stream_frames.py
XiaoChen e6e6328e08 fix(mobile): stop the JPEG stream after a run of failed captures (#1309)
`_stream_frames` (the mobile JPEG fallback) retried forever: on `TimeoutError`
it logged and `continue`d, and on `captured is None` it just `continue`d. A
phone unplugged mid-stream, or a dead adb bridge, makes every capture fail at
once, so the loop spun at the frame interval indefinitely — a warning per
iteration, a log flood, and a canvas frozen on the last frame with nothing ever
sent to explain it.

The desktop sibling `desktop/stream.py::_stream_loop` already counts consecutive
misses, warns once, and after 30 sends an error frame and breaks. This mirrors
that: timeouts and `None` both count as misses, the first one is logged once,
and at `_CAPTURE_MISS_LIMIT` (30) the stream sends the
`{"type": "error", "message": ...}` frame the rest of this router already uses
and returns. A successful frame resets the streak, so an occasional dropped
frame still does not tear the stream down.

Folding the timeout branch into the shared counter also removes the
per-iteration warning flood.
2026-09-29 16:42:55 +08:00

102 lines
3.2 KiB
Python

"""Tests for the mobile JPEG fallback stream (``_stream_frames``)."""
from __future__ import annotations
import asyncio
import json
from typing import Any
import pytest
from starlette.websockets import WebSocketState
from octop.api.routers.mobile import stream as mobile_stream
class _FakeWS:
"""Minimal WebSocket double: records frames, can drop itself after N frames."""
def __init__(self, *, stop_after_frames: int | None = None) -> None:
self.application_state = WebSocketState.CONNECTED
self.sent: list[dict[str, Any]] = []
self._stop_after_frames = stop_after_frames
async def send_text(self, payload: str) -> None:
message = json.loads(payload)
self.sent.append(message)
if self._stop_after_frames is None or message.get("type") != "frame":
return
frames = sum(1 for m in self.sent if m.get("type") == "frame")
if frames >= self._stop_after_frames:
self.application_state = WebSocketState.DISCONNECTED
@property
def types(self) -> list[str]:
return [m.get("type") for m in self.sent]
def _run(
ws: _FakeWS,
*,
frame_dims: list[int] | None = None,
timeout: float = 6.0,
) -> None:
"""Drive ``_stream_frames`` to completion; a stream that never ends is a failure."""
asyncio.run(
asyncio.wait_for(
mobile_stream._stream_frames(
ws,
device="emulator-5554",
quality=80,
max_fps=20.0,
max_side=1080,
frame_dims=frame_dims if frame_dims is not None else [0, 0],
),
timeout=timeout,
)
)
def test_stream_frames_stops_and_reports_after_persistent_failures(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A dead device must end the stream with one error frame, not spin forever.
``capture_jpeg_frame`` failing on every call is exactly what an unplugged
phone or a dead adb bridge looks like. The desktop stream stops after a run
of consecutive misses and tells the client; this JPEG fallback used to retry
at the frame interval indefinitely, flooding the log while the canvas stayed
frozen on its last frame with no explanation.
"""
monkeypatch.setattr(mobile_stream, "find_adb", lambda: None)
monkeypatch.setattr(mobile_stream, "capture_jpeg_frame", lambda *_a, **_k: None)
ws = _FakeWS()
_run(ws)
assert ws.types == ["error"]
assert "capture" in str(ws.sent[0]["message"])
def test_stream_frames_keeps_going_when_misses_are_not_consecutive(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An occasional dropped frame must not tear the stream down."""
monkeypatch.setattr(mobile_stream, "find_adb", lambda: None)
calls = {"n": 0}
def fake_capture(*_args: object, **_kwargs: object) -> tuple[bytes, int, int, int, int] | None:
calls["n"] += 1
if calls["n"] % 2 == 1:
return None
return (b"jpeg-bytes", 10, 20, 1080, 1920)
monkeypatch.setattr(mobile_stream, "capture_jpeg_frame", fake_capture)
frame_dims = [0, 0]
ws = _FakeWS(stop_after_frames=4)
_run(ws, frame_dims=frame_dims)
assert ws.types == ["frame"] * 4
assert frame_dims == [1080, 1920]