From e2246761cb3bd99fbbc9b66f07a4c89782315834 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:32:39 +0100 Subject: [PATCH 1/4] Bump codecov/codecov-action from 6 to 7 (#12872) --- .github/workflows/ci-cd.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 962aa4c4bdb..9282c37037b 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -273,7 +273,7 @@ jobs: run: | python -m coverage xml - name: Upload coverage - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: ./coverage.xml flags: >- @@ -287,7 +287,7 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} - name: Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: ./junit.xml report_type: test_results @@ -424,14 +424,14 @@ jobs: run: | python -m coverage xml - name: Upload coverage - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: ./coverage.xml flags: Autobahn token: ${{ secrets.CODECOV_TOKEN }} - name: Upload test results to Codecov if: ${{ !cancelled() }} - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: ./junit.xml report_type: test_results @@ -529,7 +529,7 @@ jobs: run: | python -m coverage xml -o cython-coverage.xml --rcfile=.coveragerc-cython.toml - name: Upload coverage - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: files: ./cython-coverage.xml disable_search: true @@ -554,7 +554,7 @@ jobs: with: jobs: ${{ toJSON(needs) }} - name: Trigger codecov notification - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} fail_ci_if_error: true From e7d755d9a7a6220114df1ed4f6a879d597b823dc Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Mon, 8 Jun 2026 22:43:50 +0100 Subject: [PATCH 2/4] Fix gunicorn endless restarting (#12879) --- CHANGES/12879.bugfix.rst | 1 + aiohttp/worker.py | 24 ++++++++++++++++++------ tests/test_worker.py | 28 ++++++++++++++++++++++++++-- 3 files changed, 45 insertions(+), 8 deletions(-) create mode 100644 CHANGES/12879.bugfix.rst diff --git a/CHANGES/12879.bugfix.rst b/CHANGES/12879.bugfix.rst new file mode 100644 index 00000000000..9dc8057a64d --- /dev/null +++ b/CHANGES/12879.bugfix.rst @@ -0,0 +1 @@ +Fixed ``GunicornWebWorker`` endlessly reloading when app fails during startup -- by :user:`Dreamsorcerer`. diff --git a/aiohttp/worker.py b/aiohttp/worker.py index 25dfd284c6b..532b989f24f 100644 --- a/aiohttp/worker.py +++ b/aiohttp/worker.py @@ -49,14 +49,20 @@ def init_process(self) -> None: super().init_process() def run(self) -> None: - self._task = self.loop.create_task(self._run()) + # base.Worker.init_process() sets self.booted = True before + # invoking run(), but for the aiohttp worker the real boot work + # (factory call, runner setup, binding sockets) happens here. + # Reset until _run() reaches the serve loop so that the arbiter + # can tell a startup failure from a normal worker exit and + # halt instead of endlessly respawning workers. + self.booted = False - try: # ignore all finalization problems + self._task = self.loop.create_task(self._run()) + try: self.loop.run_until_complete(self._task) - except Exception: - self.log.exception("Exception in gunicorn worker") - self.loop.run_until_complete(self.loop.shutdown_asyncgens()) - self.loop.close() + finally: + self.loop.run_until_complete(self.loop.shutdown_asyncgens()) + self.loop.close() sys.exit(self.exit_code) @@ -106,6 +112,12 @@ async def _run(self) -> None: ) await site.start() + # Sockets are bound; tell the arbiter the worker is ready to + # accept requests. Any failure before this point propagates out + # of run() with self.booted=False so the arbiter exits with + # WORKER_BOOT_ERROR instead of treating this as a clean exit. + self.booted = True + # If our parent changed then we shut down. pid = os.getpid() try: diff --git a/tests/test_worker.py b/tests/test_worker.py index 561453fc34d..89c8ef55bef 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -123,9 +123,33 @@ def test_run_not_app( worker.loop = event_loop worker.wsgi = "not-app" worker.alive = False - with pytest.raises(SystemExit): + with pytest.raises(RuntimeError, match="wsgi app should be"): + worker.run() + assert not worker.booted + assert event_loop.is_closed() + + +def test_run_on_startup_raises( + worker: base_worker.GunicornWebWorker, event_loop: asyncio.AbstractEventLoop +) -> None: + worker.log = mock.Mock() + worker.cfg = mock.Mock() + worker.cfg.access_log_format = ACCEPTABLE_LOG_FORMAT + worker.cfg.is_ssl = False + worker.cfg.graceful_timeout = 100 + worker.sockets = [] + + app = web.Application() + + async def boom(app: web.Application) -> None: + raise RuntimeError("boom during startup") + + app.on_startup.append(boom) + worker.wsgi = app + worker.loop = event_loop + with pytest.raises(RuntimeError, match="boom during startup"): worker.run() - worker.log.exception.assert_called_with("Exception in gunicorn worker") + assert not worker.booted assert event_loop.is_closed() From 04f3a5fa2a786f26b30426a69c7a97d9d47c9e75 Mon Sep 17 00:00:00 2001 From: Taras Kozlov Date: Tue, 9 Jun 2026 00:01:02 +0200 Subject: [PATCH 3/4] Parameterize some codspeed benchmarks by connection type (tcp vs ssl) (#12823) --- CHANGES/12823.misc.rst | 2 + docs/spelling_wordlist.txt | 1 + tests/test_benchmarks_client.py | 85 +++++++++++++++++++---- tests/test_benchmarks_client_ws.py | 37 ++++++++-- tests/test_benchmarks_web_fileresponse.py | 46 +++++++++--- 5 files changed, 144 insertions(+), 27 deletions(-) create mode 100644 CHANGES/12823.misc.rst diff --git a/CHANGES/12823.misc.rst b/CHANGES/12823.misc.rst new file mode 100644 index 00000000000..747f5e1fbee --- /dev/null +++ b/CHANGES/12823.misc.rst @@ -0,0 +1,2 @@ +Parameterized some codspeed benchmarks by connection type (SSL + TCP). +Previously, benchmarks only ran for TCP connection type. -- by :user:`tarasko`. diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt index 1a30d25a3a2..d803e9f526c 100644 --- a/docs/spelling_wordlist.txt +++ b/docs/spelling_wordlist.txt @@ -77,6 +77,7 @@ cmd codecov codebase codec +codspeed Codings committer committers diff --git a/tests/test_benchmarks_client.py b/tests/test_benchmarks_client.py index 548cd34ef28..f675808a202 100644 --- a/tests/test_benchmarks_client.py +++ b/tests/test_benchmarks_client.py @@ -1,15 +1,17 @@ """codspeed benchmarks for HTTP client.""" import asyncio -from collections.abc import Iterator -from typing import TYPE_CHECKING, Any +import ssl +from collections.abc import Awaitable, Callable, Iterator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, TypedDict import pytest from pytest_aiohttp import AiohttpClient, AiohttpServer from yarl import URL from aiohttp import hdrs, request, web -from aiohttp.test_utils import TestServer +from aiohttp.test_utils import TestClient, TestServer if TYPE_CHECKING: from pytest_codspeed import BenchmarkFixture @@ -18,6 +20,56 @@ BenchmarkFixture = pytest_codspeed.BenchmarkFixture +@pytest.fixture +def aiohttp_client_sync( + event_loop: asyncio.AbstractEventLoop, +) -> Iterator[ + Callable[[web.Application], Awaitable[TestClient[web.Request, web.Application]]] +]: + clients = [] + + async def go( + app: web.Application, + *, + server_kwargs: dict[str, Any] | None = None, + ) -> TestClient[web.Request, web.Application]: + server = TestServer(app) + client = TestClient(server) + await server.start_server(**(server_kwargs or {})) + await client.start_server() + clients.append(client) + return client + + yield go + + while clients: + event_loop.run_until_complete(clients.pop().close()) + + +class _ConnArgs(TypedDict, total=False): + ssl: ssl.SSLContext + + +@dataclass(frozen=True) +class ConnectionType: + s_kwargs: _ConnArgs + c_kwargs: _ConnArgs + + +@pytest.fixture(params=("tcp", "ssl"), ids=("tcp", "ssl")) +def conn_type( + request: pytest.FixtureRequest, + ssl_ctx: ssl.SSLContext, + client_ssl_ctx: ssl.SSLContext, +) -> ConnectionType: + if request.param == "ssl": + return ConnectionType( + s_kwargs={"ssl": ssl_ctx}, + c_kwargs={"ssl": client_ssl_ctx}, + ) + return ConnectionType(s_kwargs={}, c_kwargs={}) + + @pytest.fixture def aiohttp_server_sync( event_loop: asyncio.AbstractEventLoop, @@ -45,8 +97,9 @@ async def go( def test_one_hundred_simple_get_requests( event_loop: asyncio.AbstractEventLoop, - aiohttp_client: AiohttpClient, + aiohttp_client_sync: AiohttpClient, benchmark: BenchmarkFixture, + conn_type: ConnectionType, ) -> None: """Benchmark 100 simple GET requests.""" message_count = 100 @@ -58,9 +111,9 @@ async def handler(request: web.Request) -> web.Response: app.router.add_route("GET", "/", handler) async def run_client_benchmark() -> None: - client = await aiohttp_client(app) + client = await aiohttp_client_sync(app, server_kwargs=conn_type.s_kwargs) for _ in range(message_count): - await client.get("/") + await client.get("/", **conn_type.c_kwargs) await client.close() @benchmark @@ -70,7 +123,7 @@ def _run() -> None: def test_one_hundred_simple_get_requests_alternating_clients( event_loop: asyncio.AbstractEventLoop, - aiohttp_client: AiohttpClient, + aiohttp_client_sync: AiohttpClient, benchmark: BenchmarkFixture, ) -> None: """Benchmark 100 simple GET requests with alternating clients.""" @@ -83,8 +136,8 @@ async def handler(request: web.Request) -> web.Response: app.router.add_route("GET", "/", handler) async def run_client_benchmark() -> None: - client1 = await aiohttp_client(app) - client2 = await aiohttp_client(app) + client1 = await aiohttp_client_sync(app) + client2 = await aiohttp_client_sync(app) for i in range(message_count): if i % 2 == 0: await client1.get("/") @@ -154,8 +207,9 @@ def _run() -> None: def test_one_hundred_get_requests_with_1024_chunked_payload( event_loop: asyncio.AbstractEventLoop, - aiohttp_client: AiohttpClient, + aiohttp_client_sync: AiohttpClient, benchmark: BenchmarkFixture, + conn_type: ConnectionType, ) -> None: """Benchmark 100 GET requests with a small payload of 1024 bytes.""" message_count = 100 @@ -170,9 +224,9 @@ async def handler(request: web.Request) -> web.Response: app.router.add_route("GET", "/", handler) async def run_client_benchmark() -> None: - client = await aiohttp_client(app) + client = await aiohttp_client_sync(app, server_kwargs=conn_type.s_kwargs) for _ in range(message_count): - resp = await client.get("/") + resp = await client.get("/", **conn_type.c_kwargs) await resp.read() await client.close() @@ -212,8 +266,9 @@ def _run() -> None: def test_one_hundred_get_requests_with_1mb_chunked_payload( event_loop: asyncio.AbstractEventLoop, - aiohttp_client: AiohttpClient, + aiohttp_client_sync: AiohttpClient, benchmark: BenchmarkFixture, + conn_type: ConnectionType, ) -> None: """Benchmark 100 GET requests with a 1 MiB chunked payload using read.""" message_count = 100 @@ -228,9 +283,9 @@ async def handler(request: web.Request) -> web.Response: app.router.add_route("GET", "/", handler) async def run_client_benchmark() -> None: - client = await aiohttp_client(app) + client = await aiohttp_client_sync(app, server_kwargs=conn_type.s_kwargs) for _ in range(message_count): - resp = await client.get("/") + resp = await client.get("/", **conn_type.c_kwargs) await resp.read() await client.close() diff --git a/tests/test_benchmarks_client_ws.py b/tests/test_benchmarks_client_ws.py index 0bcc409696a..8f83d32a38c 100644 --- a/tests/test_benchmarks_client_ws.py +++ b/tests/test_benchmarks_client_ws.py @@ -1,8 +1,10 @@ """codspeed benchmarks for websocket client.""" import asyncio +import ssl from collections.abc import Awaitable, Callable, Iterator -from typing import TYPE_CHECKING, Any +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, TypedDict import pytest from pytest_aiohttp import AiohttpClient @@ -34,10 +36,12 @@ async def go( server_kwargs: dict[str, Any] | None = None, **kwargs: Any, ) -> TestClient[web.Request, web.Application]: - server_kwargs = server_kwargs or {} + server_kwargs = dict(server_kwargs or {}) + server_ssl_context = server_kwargs.pop("ssl", None) server = TestServer(__param, **server_kwargs) client = aiohttp_client_cls(server, **kwargs) + await server.start_server(ssl=server_ssl_context) await client.start_server() clients.append(client) return client @@ -48,6 +52,30 @@ async def go( event_loop.run_until_complete(clients.pop().close()) +class _ConnArgs(TypedDict, total=False): + ssl: ssl.SSLContext + + +@dataclass(frozen=True) +class ConnectionType: + s_kwargs: _ConnArgs + c_kwargs: _ConnArgs + + +@pytest.fixture(params=("tcp", "ssl"), ids=("tcp", "ssl")) +def conn_type( + request: pytest.FixtureRequest, + ssl_ctx: ssl.SSLContext, + client_ssl_ctx: ssl.SSLContext, +) -> ConnectionType: + if request.param == "ssl": + return ConnectionType( + s_kwargs={"ssl": ssl_ctx}, + c_kwargs={"ssl": client_ssl_ctx}, + ) + return ConnectionType(s_kwargs={}, c_kwargs={}) + + def test_one_thousand_round_trip_websocket_text_messages( event_loop: asyncio.AbstractEventLoop, aiohttp_client_sync: AiohttpClient, @@ -84,6 +112,7 @@ def test_one_thousand_round_trip_websocket_binary_messages( event_loop: asyncio.AbstractEventLoop, aiohttp_client_sync: AiohttpClient, benchmark: BenchmarkFixture, + conn_type: ConnectionType, msg_size: int, ) -> None: """Benchmark round trip of 1000 WebSocket binary messages.""" @@ -102,8 +131,8 @@ async def handler(request: web.Request) -> web.WebSocketResponse: app.router.add_route("GET", "/", handler) async def run_websocket_benchmark() -> None: - client = await aiohttp_client_sync(app) - resp = await client.ws_connect("/") + client = await aiohttp_client_sync(app, server_kwargs=conn_type.s_kwargs) + resp = await client.ws_connect("/", **conn_type.c_kwargs) for _ in range(message_count): await resp.receive() await resp.close() diff --git a/tests/test_benchmarks_web_fileresponse.py b/tests/test_benchmarks_web_fileresponse.py index ba9bd1dfa67..45736893e72 100644 --- a/tests/test_benchmarks_web_fileresponse.py +++ b/tests/test_benchmarks_web_fileresponse.py @@ -2,8 +2,10 @@ import asyncio import pathlib +import ssl from collections.abc import Awaitable, Callable, Iterator -from typing import TYPE_CHECKING, Any +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, TypedDict import pytest from multidict import CIMultiDict @@ -35,10 +37,12 @@ async def go( server_kwargs: dict[str, Any] | None = None, **kwargs: Any, ) -> TestClient[web.Request, web.Application]: - server_kwargs = server_kwargs or {} + server_kwargs = dict(server_kwargs or {}) + server_ssl_context = server_kwargs.pop("ssl", None) server = TestServer(__param, **server_kwargs) client = aiohttp_client_cls(server, **kwargs) + await server.start_server(ssl=server_ssl_context) await client.start_server() clients.append(client) return client @@ -49,10 +53,35 @@ async def go( event_loop.run_until_complete(clients.pop().close()) +class _ConnArgs(TypedDict, total=False): + ssl: ssl.SSLContext + + +@dataclass(frozen=True) +class ConnectionType: + s_kwargs: _ConnArgs + c_kwargs: _ConnArgs + + +@pytest.fixture(params=("tcp", "ssl"), ids=("tcp", "ssl")) +def conn_type( + request: pytest.FixtureRequest, + ssl_ctx: ssl.SSLContext, + client_ssl_ctx: ssl.SSLContext, +) -> ConnectionType: + if request.param == "ssl": + return ConnectionType( + s_kwargs={"ssl": ssl_ctx}, + c_kwargs={"ssl": client_ssl_ctx}, + ) + return ConnectionType(s_kwargs={}, c_kwargs={}) + + def test_simple_web_file_response( event_loop: asyncio.AbstractEventLoop, - aiohttp_client: AiohttpClient, + aiohttp_client_sync: AiohttpClient, benchmark: BenchmarkFixture, + conn_type: ConnectionType, ) -> None: """Benchmark creating 100 simple web.FileResponse.""" response_count = 100 @@ -65,9 +94,9 @@ async def handler(request: web.Request) -> web.FileResponse: app.router.add_route("GET", "/", handler) async def run_file_response_benchmark() -> None: - client = await aiohttp_client(app) + client = await aiohttp_client_sync(app, server_kwargs=conn_type.s_kwargs) for _ in range(response_count): - await client.get("/") + await client.get("/", **conn_type.c_kwargs) await client.close() @benchmark @@ -77,8 +106,9 @@ def _run() -> None: def test_simple_web_file_sendfile_fallback_response( event_loop: asyncio.AbstractEventLoop, - aiohttp_client: AiohttpClient, + aiohttp_client_sync: AiohttpClient, benchmark: BenchmarkFixture, + conn_type: ConnectionType, ) -> None: """Benchmark creating 100 simple web.FileResponse without sendfile.""" response_count = 100 @@ -94,9 +124,9 @@ async def handler(request: web.Request) -> web.FileResponse: app.router.add_route("GET", "/", handler) async def run_file_response_benchmark() -> None: - client = await aiohttp_client(app) + client = await aiohttp_client_sync(app, server_kwargs=conn_type.s_kwargs) for _ in range(response_count): - await client.get("/") + await client.get("/", **conn_type.c_kwargs) await client.close() @benchmark From c67c9e4622d5a30a76e1b7b2e2d255eff6ec6640 Mon Sep 17 00:00:00 2001 From: Javid Khan Date: Tue, 9 Jun 2026 03:40:43 +0530 Subject: [PATCH 4/4] Reject non-digit Content-Length in multipart body parts (#12794) Co-authored-by: Sam Bull --- CHANGES/12794.bugfix.rst | 4 ++++ aiohttp/multipart.py | 5 +++++ tests/test_multipart.py | 7 +++++++ 3 files changed, 16 insertions(+) create mode 100644 CHANGES/12794.bugfix.rst diff --git a/CHANGES/12794.bugfix.rst b/CHANGES/12794.bugfix.rst new file mode 100644 index 00000000000..ec72efc1f3c --- /dev/null +++ b/CHANGES/12794.bugfix.rst @@ -0,0 +1,4 @@ +Rejected multipart body parts whose ``Content-Length`` header is not a +plain sequence of digits (e.g. ``+5``, ``-1``, ``1_0``), matching the +strictness of the main request parser per :rfc:`9110#section-8.6` +-- by :user:`dxbjavid`. diff --git a/aiohttp/multipart.py b/aiohttp/multipart.py index f5ff30804d1..9aae6a4f87c 100644 --- a/aiohttp/multipart.py +++ b/aiohttp/multipart.py @@ -283,6 +283,11 @@ def __init__( self._is_form_data = subtype == "form-data" # https://datatracker.ietf.org/doc/html/rfc7578#section-4.8 length = None if self._is_form_data else self.headers.get(CONTENT_LENGTH, None) + if length is not None and not (length.isascii() and length.isdigit()): + # Reject sign prefixes, underscores, whitespace and non-ASCII + # digits that int() would otherwise accept. + # https://www.rfc-editor.org/rfc/rfc9110#section-8.6 + raise ValueError(f"invalid Content-Length: {length!r}") self._length = int(length) if length is not None else None self._read_bytes = 0 self._unread: deque[bytes] = deque() diff --git a/tests/test_multipart.py b/tests/test_multipart.py index 1a912c7c439..43452741942 100644 --- a/tests/test_multipart.py +++ b/tests/test_multipart.py @@ -293,6 +293,13 @@ async def test_multi_read_chunk(self) -> None: assert b"" == result assert obj.at_eof() + @pytest.mark.parametrize("value", ["-1", "+5", "1_0", " 5", "0x5", "5"]) + async def test_rejects_malformed_content_length(self, value: str) -> None: + h = HeadersDictProxy(CIMultiDict({"CONTENT-LENGTH": value})) + with Stream(b"Hello, world!\r\n--:--") as stream: + with pytest.raises(ValueError, match="Content-Length"): + aiohttp.BodyPartReader(BOUNDARY, h, stream) + async def test_read_chunk_properly_counts_read_bytes(self) -> None: expected = b"." * 10 size = len(expected)