From 308fbc4fb430f988fedafd183f320cb77b314e20 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 14:00:09 +0400 Subject: [PATCH] =?UTF-8?q?fix(files):=20=D1=82=D0=B8=D0=BF=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20send=20=D0=B8=20=D1=81=D0=B1=D1=80?= =?UTF-8?q?=D0=BE=D1=81=20=D0=BF=D1=80=D0=B5=D1=84=D0=B5=D1=82=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=BE=D0=B9=20=D0=BF=D0=BE=D1=80=D1=86=D0=B8=D0=B8?= =?UTF-8?q?=20=D0=B2=20ChainedStream.aclose=20(PB.1,=20PB.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/common/streaming.py | 7 +++++-- api/tests/test_api_files_content.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/api/src/common/streaming.py b/api/src/common/streaming.py index 119f222..93e0edc 100644 --- a/api/src/common/streaming.py +++ b/api/src/common/streaming.py @@ -10,6 +10,7 @@ from collections.abc import AsyncGenerator import anyio from fastapi.responses import StreamingResponse +from starlette.types import Send class ChainedStream: @@ -30,7 +31,9 @@ class ChainedStream: return await anext(self._rest) async def aclose(self) -> None: - """Закрыть исходный поток (идемпотентно: у завершённого генератора `aclose` — no-op).""" + """Закрыть исходный поток и сбросить префетченную порцию: после закрытия `__anext__` её не отдаёт + (идемпотентно: у завершённого генератора `aclose` — no-op).""" + self._first = b"" await self._rest.aclose() @@ -40,7 +43,7 @@ class ClosingStreamingResponse(StreamingResponse): Закрытие идёт под `CancelScope(shield=True)`: при обрыве клиента задача отдачи отменяется, а освобождение соединения S3 требует `await`.""" - async def stream_response(self, send) -> None: # type: ignore[no-untyped-def] # noqa: ANN001 + async def stream_response(self, send: Send) -> None: try: await super().stream_response(send) finally: diff --git a/api/tests/test_api_files_content.py b/api/tests/test_api_files_content.py index 06b05e6..40b6eea 100644 --- a/api/tests/test_api_files_content.py +++ b/api/tests/test_api_files_content.py @@ -178,6 +178,19 @@ async def test_chain_aclose_closes_source_when_never_iterated() -> None: assert source.closed +async def test_chain_does_not_yield_prefetched_chunk_after_aclose() -> None: + """PB.2: закрытая обёртка не отдаёт префетченную порцию (иначе потребитель читал бы данные закрытого потока).""" + source = _Source() + first = await anext(source.iterator, b"") + assert first == b"x" + chain = _chain(first, source.iterator) + await chain.aclose() + assert source.closed + with pytest.raises(StopAsyncIteration): + await chain.__anext__() + await chain.aclose() # повторное закрытие безопасно + + async def test_response_closes_stream_when_client_drops_after_first_chunk() -> None: """T9.1: `send` падает на втором теле (обрыв клиента) — `body_iterator` закрыт, S3-поток освобождён.""" source = _Source()