Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: >-
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGES/12794.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 2 additions & 0 deletions CHANGES/12823.misc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Parameterized some codspeed benchmarks by connection type (SSL + TCP).
Previously, benchmarks only ran for TCP connection type. -- by :user:`tarasko`.
1 change: 1 addition & 0 deletions CHANGES/12879.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed ``GunicornWebWorker`` endlessly reloading when app fails during startup -- by :user:`Dreamsorcerer`.
5 changes: 5 additions & 0 deletions aiohttp/multipart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
24 changes: 18 additions & 6 deletions aiohttp/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions docs/spelling_wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ cmd
codecov
codebase
codec
codspeed
Codings
committer
committers
Expand Down
85 changes: 70 additions & 15 deletions tests/test_benchmarks_client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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."""
Expand All @@ -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("/")
Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand All @@ -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()

Expand Down
37 changes: 33 additions & 4 deletions tests/test_benchmarks_client_ws.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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."""
Expand All @@ -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()
Expand Down
Loading
Loading