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
582 changes: 51 additions & 531 deletions AGENTS.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions CHANGES/12597.breaking.rst
8 changes: 8 additions & 0 deletions CHANGES/12603.contrib.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Fixed flaky ``test_handler_returns_not_response`` and
``test_handler_returns_none`` by routing ``loop.set_debug(True)``
through a new ``loop_debug_mode`` fixture that disables debug
mode before the ``aiohttp_client`` fixture finalizes. Leaving
debug on through teardown let PyPy 3.11's asyncio slow-callback
logger walk into ``Task.__repr__`` during connector close,
surfacing a spurious ``RuntimeWarning: coroutine was never
awaited`` -- by :user:`bdraco`.
12 changes: 12 additions & 0 deletions CHANGES/9308.breaking.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Stopped calling :func:`socket.getfqdn` as the fallback for
:attr:`aiohttp.web.BaseRequest.host`. :func:`socket.getfqdn`
performs blocking reverse DNS resolution on the event loop
thread and can stall a worker for many seconds when the system
resolver is slow, and could be triggered remotely by an HTTP/1.0
request that omits the ``Host`` header. The fallback when no
``Host`` header is present is now the local socket address the
request arrived on (transport ``sockname``), or an empty string
if no transport information is available. Code that relied on
the FQDN being returned must now read it from
:func:`socket.getfqdn` directly, off the event loop
-- by :user:`bdraco`.
8 changes: 5 additions & 3 deletions aiohttp/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,11 +550,10 @@ def set_dict(app: Any, key: str, value: Any) -> None:
def _create_transport(sslcontext: SSLContext | None = None) -> mock.Mock:
transport = mock.Mock()

def get_extra_info(key: str) -> SSLContext | None:
def get_extra_info(key: str) -> SSLContext | tuple[str, int] | None:
if key == "sslcontext":
return sslcontext
else:
return None
return ("127.0.0.1", 80) if key == "sockname" else None

transport.get_extra_info.side_effect = get_extra_info
return transport
Expand Down Expand Up @@ -635,6 +634,9 @@ def make_mocked_request(
type(protocol).peername = mock.PropertyMock(
return_value=transport.get_extra_info("peername")
)
type(protocol).sockname = mock.PropertyMock(
return_value=transport.get_extra_info("sockname")
)
type(protocol).ssl_context = mock.PropertyMock(return_value=sslcontext)

if writer is None:
Expand Down
11 changes: 11 additions & 0 deletions aiohttp/web_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,17 @@ def peername(
else self.transport.get_extra_info("peername")
)

@under_cached_property
def sockname(
self,
) -> str | tuple[str, int, int, int] | tuple[str, int] | None:
"""Return sockname if available."""
return (
None
if self.transport is None
else self.transport.get_extra_info("sockname")
)

@property
def keepalive_timeout(self) -> float:
return self._keepalive_timeout
Expand Down
18 changes: 15 additions & 3 deletions aiohttp/web_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import datetime
import io
import re
import socket
import string
import sys
import tempfile
Expand Down Expand Up @@ -165,6 +164,7 @@ def __init__(

self._transport_sslcontext = protocol.ssl_context
self._transport_peername = protocol.peername
self._transport_sockname = protocol.sockname

if remote is not None:
self._cache["remote"] = remote
Expand Down Expand Up @@ -372,7 +372,9 @@ def host(self) -> str:

- overridden value by .clone(host=new_host) call.
- HOST HTTP header
- socket.getfqdn() value
- local socket address the request arrived on
(transport ``sockname``)
- empty string if no transport information is available

For example, 'example.com' or 'localhost:8080'.

Expand All @@ -381,7 +383,17 @@ def host(self) -> str:
host = self._message.headers.get(hdrs.HOST)
if host is not None:
return host
return socket.getfqdn()
sockname = self._transport_sockname
if sockname is None:
return ""
if isinstance(sockname, tuple):
# AF_INET6 returns a 4-tuple (host, port, flowinfo, scopeid);
# bracket the bare address so it matches the Host-header shape
# and is a valid URL authority component.
if len(sockname) == 4:
return f"[{sockname[0]}]"
return str(sockname[0])
return str(sockname)

@reify
def remote(self) -> str | None:
Expand Down
11 changes: 10 additions & 1 deletion docs/web_reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@ and :ref:`aiohttp-web-signals` handlers.

- Overridden value by :meth:`~BaseRequest.clone` call.
- *Host* HTTP header
- :func:`socket.getfqdn`
- local socket address the request arrived on
(transport ``sockname``)
- empty string if no transport information is available

Read-only :class:`str` property.

Expand All @@ -130,6 +132,13 @@ and :ref:`aiohttp-web-signals` handlers.
Call ``.clone(host=new_host)`` for setting up the value
explicitly.

.. versionchanged:: 3.13

The fallback when no ``Host`` header is present no longer
calls :func:`socket.getfqdn`, which performed blocking
reverse-DNS resolution on the event loop. The local socket
address (transport ``sockname``) is used instead.

.. seealso:: :ref:`aiohttp-web-forwarded-support`

.. attribute:: remote
Expand Down
2 changes: 1 addition & 1 deletion requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ typing-extensions==4.15.0 ; python_version < "3.13"
# -r requirements/runtime-deps.in
# aiosignal
# multidict
uvloop==0.21.0 ; platform_system != "Windows" and implementation_name == "cpython"
uvloop==0.22.1 ; platform_system != "Windows" and implementation_name == "cpython"
# via -r requirements/base.in
yarl==1.22.0
# via -r requirements/runtime-deps.in
2 changes: 1 addition & 1 deletion requirements/constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ typing-inspection==0.4.2
# via pydantic
urllib3==2.7.0
# via requests
uvloop==0.21.0 ; platform_system != "Windows"
uvloop==0.22.1 ; platform_system != "Windows"
# via
# -r requirements/base.in
# -r requirements/lint.in
Expand Down
2 changes: 1 addition & 1 deletion requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ typing-inspection==0.4.2
# via pydantic
urllib3==2.7.0
# via requests
uvloop==0.21.0 ; platform_system != "Windows" and implementation_name == "cpython"
uvloop==0.22.1 ; platform_system != "Windows" and implementation_name == "cpython"
# via
# -r requirements/base.in
# -r requirements/lint.in
Expand Down
2 changes: 1 addition & 1 deletion requirements/lint.txt
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ typing-extensions==4.15.0
# virtualenv
typing-inspection==0.4.2
# via pydantic
uvloop==0.21.0 ; platform_system != "Windows"
uvloop==0.22.1 ; platform_system != "Windows"
# via -r requirements/lint.in
valkey==6.1.1
# via -r requirements/lint.in
Expand Down
2 changes: 1 addition & 1 deletion requirements/test.txt
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ typing-extensions==4.15.0 ; python_version < "3.13"
# typing-inspection
typing-inspection==0.4.2
# via pydantic
uvloop==0.21.0 ; platform_system != "Windows" and implementation_name == "cpython"
uvloop==0.22.1 ; platform_system != "Windows" and implementation_name == "cpython"
# via -r requirements/base.in
wait-for-it==2.3.0
# via -r requirements/test-common.in
Expand Down
18 changes: 18 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,24 @@ def _proto_factory() -> Any:
yield _proto_factory


@pytest.fixture
async def loop_debug_mode() -> AsyncIterator[None]:
"""Enable asyncio debug mode for the duration of the test.

Disables debug before teardown so PyPy 3.11's recursive
``Task.__repr__``, triggered by the asyncio slow-callback
logger during connector close, does not surface a spurious
``RuntimeWarning: coroutine ... was never awaited`` while
the ``aiohttp_client`` fixture finalizes.
"""
loop = asyncio.get_running_loop()
loop.set_debug(True)
try:
yield
finally:
loop.set_debug(False)


@pytest.fixture
def unix_sockname(
tmp_path: Path, tmp_path_factory: pytest.TempPathFactory
Expand Down
5 changes: 4 additions & 1 deletion tests/test_run_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1340,6 +1340,7 @@ def test_shutdown_handler_cancellation_suppressed(
port = sock.getsockname()[1]
actions = []
t = None
suppressed = asyncio.Event()

async def test() -> None:
async def test_resp(sess: ClientSession) -> None:
Expand All @@ -1351,7 +1352,8 @@ async def test_resp(sess: ClientSession) -> None:

async with ClientSession() as sess:
t = asyncio.create_task(test_resp(sess))
await asyncio.sleep(0.5)
# Wait until the handler has observed its cancellation.
await asyncio.wait_for(suppressed.wait(), timeout=3)
# Handler is in-progress while we trigger server shutdown.
actions.append("PRESTOP")
async with sess.get(f"http://127.0.0.1:{port}/stop"):
Expand All @@ -1372,6 +1374,7 @@ async def handler(request: web.Request) -> web.Response:
await asyncio.sleep(5)
except asyncio.CancelledError:
actions.append("SUPPRESSED")
suppressed.set()
await asyncio.sleep(2)
actions.append("DONE")
return web.Response(text="FOO")
Expand Down
10 changes: 6 additions & 4 deletions tests/test_web_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,10 @@ async def handler(request: web.Request) -> web.Response:


async def test_handler_returns_not_response(
aiohttp_server: AiohttpServer, aiohttp_client: AiohttpClient
aiohttp_server: AiohttpServer,
aiohttp_client: AiohttpClient,
loop_debug_mode: None,
) -> None:
asyncio.get_running_loop().set_debug(True)
logger = mock.Mock()

async def handler(request: web.Request) -> str:
Expand All @@ -113,9 +114,10 @@ async def handler(request: web.Request) -> str:


async def test_handler_returns_none(
aiohttp_server: AiohttpServer, aiohttp_client: AiohttpClient
aiohttp_server: AiohttpServer,
aiohttp_client: AiohttpClient,
loop_debug_mode: None,
) -> None:
asyncio.get_running_loop().set_debug(True)
logger = mock.Mock()

async def handler(request: web.Request) -> None:
Expand Down
69 changes: 59 additions & 10 deletions tests/test_web_request.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import asyncio
import datetime
import logging
import socket
import ssl
import sys
import time
Expand Down Expand Up @@ -44,16 +43,19 @@ def test_base_ctor() -> None:
URL("/path/to?a=1&b=2"),
)

protocol = mock.Mock()
protocol.ssl_context = None
protocol.peername = None
protocol.sockname = ("127.0.0.1", 80)
req = web.BaseRequest(
message, mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock(), mock.Mock()
message, mock.Mock(), protocol, mock.Mock(), mock.Mock(), mock.Mock()
)

assert "GET" == req.method
assert HttpVersion(1, 1) == req.version
# MacOS may return CamelCased host name, need .lower()
# FQDN can be wider than host, e.g.
# 'fv-az397-495' in 'fv-az397-495.internal.cloudapp.net'
assert req.host.lower() in socket.getfqdn().lower()
# No Host header in this request, so host falls back to the
# local socket address the request arrived on.
assert req.host == "127.0.0.1"
assert "/path/to?a=1&b=2" == req.path_qs
assert "/path/to" == req.path
assert "a=1&b=2" == req.query_string
Expand All @@ -75,10 +77,10 @@ def test_ctor() -> None:

assert "GET" == req.method
assert HttpVersion(1, 1) == req.version
# MacOS may return CamelCased host name, need .lower()
# FQDN can be wider than host, e.g.
# 'fv-az397-495' in 'fv-az397-495.internal.cloudapp.net'
assert req.host.lower() in socket.getfqdn().lower()
# No Host header in this request, so host falls back to the
# local socket address the request arrived on (the default
# sockname configured by make_mocked_request).
assert req.host == "127.0.0.1"
assert "/path/to?a=1&b=2" == req.path_qs
assert "/path/to" == req.path
assert "a=1&b=2" == req.query_string
Expand Down Expand Up @@ -114,6 +116,53 @@ def test_ctor() -> None:
assert req.task is req._task


def test_host_falls_back_to_sockname_not_dns() -> None:
"""Regression: request.host must not call socket.getfqdn().

socket.getfqdn() does blocking reverse DNS resolution on the
event loop thread and can stall a worker for many seconds when
the system resolver is slow. The fallback for a request with no
Host header is the local socket address the request arrived on,
not the system FQDN.
"""
req = make_mocked_request("GET", "/")
assert req.host == "127.0.0.1"
assert str(req.url).startswith("http://127.0.0.1")


def test_host_with_ipv6_sockname() -> None:
"""AF_INET6 sockname is bracketed to form a valid URL authority.

A bare IPv6 string would cause ``URL.build(authority=...)`` to
raise ``ValueError``.
"""
transport = mock.Mock()
transport.get_extra_info.side_effect = lambda key: (
("::1", 80, 0, 0) if key == "sockname" else None
)
req = make_mocked_request("GET", "/", transport=transport)
assert req.host == "[::1]"
assert str(req.url) == "http://[::1]/"


def test_host_with_unix_socket_sockname() -> None:
"""Unix-socket transports expose sockname as a str path."""
transport = mock.Mock()
transport.get_extra_info.side_effect = lambda key: (
"/tmp/aiohttp.sock" if key == "sockname" else None
)
req = make_mocked_request("GET", "/", transport=transport)
assert req.host == "/tmp/aiohttp.sock"


def test_host_with_no_transport_sockname() -> None:
"""An empty string is returned when no sockname is available."""
transport = mock.Mock()
transport.get_extra_info.return_value = None
req = make_mocked_request("GET", "/", transport=transport)
assert req.host == ""


def test_doubleslashes() -> None:
# NB: //foo/bar is an absolute URL with foo netloc and /bar path
req = make_mocked_request("GET", "/bar//foo/")
Expand Down
Loading