From d413f4c4bad0f6138f3eecfa2bb2778bbe2d338c Mon Sep 17 00:00:00 2001 From: Jeffrey Larson Date: Thu, 1 Oct 2026 09:27:45 -0700 Subject: [PATCH] Fix Supervisor connection leak when leaving an app's log page (#183772) --- homeassistant/components/hassio/http.py | 31 ++++++++++++------------ tests/components/hassio/test_http.py | 32 +++++++++++++++++++++++-- tests/test_util/aiohttp.py | 1 + 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index f057744128c6..d4ff391267c1 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -178,29 +178,28 @@ class HassIOView(HomeAssistantView): headers[RANGE] = request.headers[RANGE] try: - client = await self._websession.request( + async with self._websession.request( method=request.method, url=f"http://{self._host}/{quote(path)}", params=request.query, data=request.content if request.method != "GET" else None, headers=headers, timeout=_get_timeout(path), - ) + ) as client: + # Stream response + response = web.StreamResponse( + status=client.status, headers=_response_header(client) + ) + response.content_type = client.content_type - # Stream response - response = web.StreamResponse( - status=client.status, headers=_response_header(client) - ) - response.content_type = client.content_type - - if should_compress(response.content_type, path): - response.enable_compression() - await response.prepare(request) - # In testing iter_chunked, iter_any, and iter_chunks: - # iter_chunks was the best performing option since - # it does not have to do as much re-assembly - async for data, _ in client.content.iter_chunks(): - await response.write(data) + if should_compress(response.content_type, path): + response.enable_compression() + await response.prepare(request) + # In testing iter_chunked, iter_any, and iter_chunks: + # iter_chunks was the best performing option since + # it does not have to do as much re-assembly + async for data, _ in client.content.iter_chunks(): + await response.write(data) except aiohttp.ClientError as err: _LOGGER.error("Client error on api %s request %s", path, err) diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index 4126ae326dea..2cb87d7258cd 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -1,13 +1,16 @@ """The tests for the hassio component.""" +import asyncio +from contextlib import suppress from http import HTTPStatus +from unittest.mock import Mock, PropertyMock, patch -from aiohttp import StreamReader +from aiohttp import ClientError, StreamReader from aiohttp.test_utils import TestClient import pytest from tests.common import MockUser -from tests.test_util.aiohttp import AiohttpClientMocker +from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse @pytest.fixture @@ -478,3 +481,28 @@ async def test_forward_range_header_for_logs( assert aioclient_mock.mock_calls[3][-1].get("Range") == test_range assert aioclient_mock.mock_calls[4][-1].get("Range") == test_range assert aioclient_mock.mock_calls[5][-1].get("Range") is None + + +async def test_supervisor_response_released_when_stream_cancelled( + hassio_client: TestClient, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the response from the Supervisor is released when the stream is cancelled.""" + # A frontend disconnect cancels the handler, simulated here from the stream. + stream = StreamReader(Mock(), limit=2**16) + stream.feed_data(b"line 1\n") + stream.set_exception(asyncio.CancelledError()) + + aioclient_mock.get("http://127.0.0.1/addons/bl_b392/logs/follow") + + with ( + patch.object( + AiohttpClientMockResponse, "content", new_callable=PropertyMock + ) as mock_content, + patch.object(AiohttpClientMockResponse, "release") as mock_release, + ): + mock_content.return_value = stream + resp = await hassio_client.get("/api/hassio/addons/bl_b392/logs/follow") + with suppress(ClientError): + await resp.read() + + mock_release.assert_called_once() diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 5a62ae1672e7..b426c905e26e 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -336,6 +336,7 @@ class AiohttpClientMockResponse: exc_tb: TracebackType | None, ) -> None: """Exit the context manager.""" + self.release() @contextmanager