From 5c293f4f71f6188b446afd331afa47262a874f4f Mon Sep 17 00:00:00 2001 From: Sergei Date: Fri, 19 Jun 2026 01:32:17 +0200 Subject: [PATCH 1/3] Fix read timeout on a connection returned to the pool (#12954) --- CHANGES/12953.bugfix.rst | 6 ++++++ CHANGES/12954.bugfix.rst | 1 + CONTRIBUTORS.txt | 1 + aiohttp/client_proto.py | 4 +++- tests/test_client_functional.py | 37 +++++++++++++++++++++++++++++++++ 5 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 CHANGES/12953.bugfix.rst create mode 120000 CHANGES/12954.bugfix.rst diff --git a/CHANGES/12953.bugfix.rst b/CHANGES/12953.bugfix.rst new file mode 100644 index 00000000000..20edc2dda87 --- /dev/null +++ b/CHANGES/12953.bugfix.rst @@ -0,0 +1,6 @@ +Fixed the ``sock_read`` timeout being re-armed on a keep-alive connection after +it had been returned to the pool. An idle pooled connection could be left with a +pending read timeout that fired and poisoned it, so the next request reusing the +connection failed immediately with :exc:`aiohttp.SocketTimeoutError`. The read +timeout is now only rescheduled when resuming a transport that was actually +paused -- by :user:`daragok`. diff --git a/CHANGES/12954.bugfix.rst b/CHANGES/12954.bugfix.rst new file mode 120000 index 00000000000..baccfb94cc8 --- /dev/null +++ b/CHANGES/12954.bugfix.rst @@ -0,0 +1 @@ +12953.bugfix.rst \ No newline at end of file diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 267ac5a682d..65c20d023e7 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -343,6 +343,7 @@ Sebastian Hanula Sebastian Hüther Sebastien Geffroy SeongSoo Cho +Sergei Grachev Sergey Ninua Sergey Skripnick Serhii Charykov diff --git a/aiohttp/client_proto.py b/aiohttp/client_proto.py index 30b13950af7..e551914993e 100644 --- a/aiohttp/client_proto.py +++ b/aiohttp/client_proto.py @@ -196,8 +196,10 @@ def pause_reading(self) -> None: self._drop_timeout() def resume_reading(self, resume_parser: bool = True) -> None: + was_paused = self._reading_paused super().resume_reading(resume_parser) - self._reschedule_timeout() + if was_paused: + self._reschedule_timeout() def set_exception( self, diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py index 34ec8fa961b..44e6dc1ff1f 100644 --- a/tests/test_client_functional.py +++ b/tests/test_client_functional.py @@ -1255,6 +1255,43 @@ async def handler(request: web.Request) -> web.Response: assert result == b"foo" +async def test_sock_read_timeout_not_rearmed_on_pooled_connection( + aiohttp_client: AiohttpClient, +) -> None: + # Reading the buffered body of a completed response must not re-arm the + # sock_read timeout on a connection that has already been released to the + # keep-alive pool. Otherwise the timer fires while the connection sits idle + # in the pool, stamps SocketTimeoutError on it, and the next request that + # reuses it fails immediately (with no real read having stalled). + async def handler(request: web.Request) -> web.Response: + return web.json_response({"ok": True}) + + app = web.Application() + app.router.add_get("/", handler) + + timeout = aiohttp.ClientTimeout(total=30, sock_read=0.1) + client = await aiohttp_client(app, timeout=timeout) + + async with client.get("/") as resp: + assert resp.status == 200 + await resp.read() + + assert client.session.connector is not None + pooled = next(iter(client.session.connector._conns.values())) + proto = pooled[0][0] + # The pooled connection must carry no read-timeout handle, otherwise + # it could trigger an exception on the next request. + assert proto._read_timeout_handle is None + assert proto.exception() is None + + # The connection is still reusable. + async with client.get("/") as resp: + assert resp.status == 200 + assert await resp.json() == {"ok": True} + + assert next(iter(client.session.connector._conns.values()))[0][0] is proto + + async def test_request_exception_cleanup_with_no_total_timeout( aiohttp_client: AiohttpClient, ) -> None: From ea0c52e9982cce5da4e2e0ba90daba33b4a4189e Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Fri, 19 Jun 2026 01:00:19 +0100 Subject: [PATCH 2/3] Exclude .hash from builds (#12959) --- MANIFEST.in | 1 + 1 file changed, 1 insertion(+) diff --git a/MANIFEST.in b/MANIFEST.in index ea5d39d4722..c9d517222a1 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -17,5 +17,6 @@ global-exclude *.lib global-exclude *.dll global-exclude *.a global-exclude *.obj +global-exclude *.hash exclude aiohttp/*.html prune docs/_build From f5e63e856708076a5a5d0500cb19b798cb0a94c2 Mon Sep 17 00:00:00 2001 From: JSap0914 <116227558+JSap0914@users.noreply.github.com> Date: Fri, 19 Jun 2026 09:21:03 +0900 Subject: [PATCH 3/3] Fix IndexError in parse_content_disposition when param value is empty (#12948) --- CHANGES/12948.bugfix.rst | 3 +++ aiohttp/multipart.py | 2 +- tests/test_multipart_helpers.py | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 CHANGES/12948.bugfix.rst diff --git a/CHANGES/12948.bugfix.rst b/CHANGES/12948.bugfix.rst new file mode 100644 index 00000000000..8cd00cfe0a6 --- /dev/null +++ b/CHANGES/12948.bugfix.rst @@ -0,0 +1,3 @@ +Fixed ``IndexError: string index out of range`` in ``parse_content_disposition`` +when a header parameter has an empty value (e.g. ``filename=``). +-- by :user:`JSap0914`. diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py index 9aae6a4f87c..7e63e324779 100644 --- a/aiohttp/multipart.py +++ b/aiohttp/multipart.py @@ -81,7 +81,7 @@ def is_token(string: str) -> bool: return bool(string) and TOKEN >= set(string) def is_quoted(string: str) -> bool: - return string[0] == string[-1] == '"' + return len(string) >= 2 and string[0] == string[-1] == '"' def is_rfc5987(string: str) -> bool: return is_token(string) and string.count("'") == 2 diff --git a/tests/test_multipart_helpers.py b/tests/test_multipart_helpers.py index d4fb610a22c..3548feacd40 100644 --- a/tests/test_multipart_helpers.py +++ b/tests/test_multipart_helpers.py @@ -643,6 +643,22 @@ def test_bad_continuous_param(self) -> None: assert "attachment" == disptype assert {} == params + def test_empty_param_value_no_crash(self) -> None: + """Empty param value (e.g. filename=) must not raise IndexError.""" + with pytest.warns(aiohttp.BadContentDispositionHeader): + disptype, params = parse_content_disposition("attachment; filename=") + assert disptype is None + assert {} == params + + def test_empty_param_value_multiple(self) -> None: + """Multiple params where one has empty value must not raise IndexError.""" + with pytest.warns(aiohttp.BadContentDispositionHeader): + disptype, params = parse_content_disposition( + "attachment; name=foo; filename=" + ) + assert disptype is None + assert {} == params + class TestContentDispositionFilename: # http://greenbytes.de/tech/tc2231/