diff --git a/pyatv/protocols/raop/audio_source.py b/pyatv/protocols/raop/audio_source.py index ea97859e5..729cbd842 100644 --- a/pyatv/protocols/raop/audio_source.py +++ b/pyatv/protocols/raop/audio_source.py @@ -485,6 +485,8 @@ def read(self, num_bytes: int) -> bytes: # TODO: Should not be based on polling while len(self._buffer) < num_bytes and not self._stop_stream: + if not self._buffer.fits(1): + break if time.monotonic() - start_time > DEFAULT_TIMEOUT: raise OperationTimeoutError("timed out reading from stream") diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 9b6594d3e..b748a6e1e 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -4,7 +4,7 @@ cryptography==46.0.3 chacha20poly1305-reuseable==0.13.2 ifaddr==0.2.0 ifaddr==0.2.0 -miniaudio==1.61 +miniaudio==1.71 protobuf==6.33.2 pydantic==2.12.5 requests==2.32.5 diff --git a/tests/data/audio_long.mp3 b/tests/data/audio_long.mp3 new file mode 100644 index 000000000..8092e5d23 Binary files /dev/null and b/tests/data/audio_long.mp3 differ diff --git a/tests/protocols/raop/test_audio_source.py b/tests/protocols/raop/test_audio_source.py new file mode 100644 index 000000000..42f7a9edd --- /dev/null +++ b/tests/protocols/raop/test_audio_source.py @@ -0,0 +1,50 @@ +"""Tests for pyatv.protocols.raop.audio_source. + +Includes a regression test for #2849: streaming a media file larger than +PatchedIceCastClient's internal BUFFER_SIZE must not deadlock during +miniaudio's decoder init. +""" + +import pytest +from pytest_httpserver import HTTPServer + +from pyatv.protocols.raop.audio_source import ( + BUFFER_SIZE, + InternetSource, +) + +from tests.utils import data_path + +pytestmark = pytest.mark.asyncio + +SHORT_FIXTURE = "static_3sec.ogg" # existing, ~4 KiB +LONG_FIXTURE = "audio_long.mp3" # new, just over BUFFER_SIZE + + +async def _serve_and_open(httpserver: HTTPServer, body: bytes, name: str) -> None: + httpserver.expect_request("/" + name).respond_with_data( + body, content_type="application/octet-stream" + ) + url = httpserver.url_for("/" + name) + src = await InternetSource.open(url, sample_rate=44100, channels=2, sample_size=2) + try: + frames = await src.readframes(352) + assert frames, "InternetSource returned no audio frames after init" + finally: + await src.close() + + +async def test_internet_source_short_stream(httpserver: HTTPServer): + """Streaming a small file over HTTP works (fast path, no deadlock window).""" + with open(data_path(SHORT_FIXTURE), "rb") as fh: + body = fh.read() + assert len(body) < BUFFER_SIZE + await _serve_and_open(httpserver, body, SHORT_FIXTURE) + + +async def test_internet_source_long_stream_no_deadlock(httpserver: HTTPServer): + """Regression test for #2849: streaming a file > BUFFER_SIZE must not deadlock.""" + with open(data_path(LONG_FIXTURE), "rb") as fh: + body = fh.read() + assert len(body) > BUFFER_SIZE + await _serve_and_open(httpserver, body, LONG_FIXTURE)