Skip to content
Draft
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
6 changes: 6 additions & 0 deletions docs/versionhistory.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.

**UNRELEASED**

- Fixed ``connect_tcp()`` blindly prioritizing IPv6 even when the host has no usable
IPv6 connection. The connection attempts now follow the order returned by the
resolver (RFC 6724 destination address selection), which prefers IPv6 when the host
has a working IPv6 connection and IPv4 otherwise, avoiding needless delays on hosts
whose only IPv6 addresses are link-local or loopback
(`#1230 <https://github.com/agronholm/anyio/issues/1230>`_)
- Added ``--anyio-mode`` command-line option as an alternative to the ``anyio_mode``
ini setting, and fix the pytest plugin's auto mode detection to recognize the mode
when set via either mechanism(e.g: ``pytest_asyncio``).
Expand Down
25 changes: 10 additions & 15 deletions src/anyio/_core/_sockets.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,9 @@ async def connect_tcp(
6555). If ``remote_host`` is a host name that resolves to multiple IP addresses,
each one is tried until one connection attempt succeeds. If the first attempt does
not connected within 250 milliseconds, a second attempt is started using the next
address in the list, and so on. On IPv6 enabled systems, an IPv6 address (if
available) is tried first.
address in the list, and so on. The addresses are tried in the order returned by
the resolver, which follows RFC 6724 destination address selection (IPv6 is
preferred when the host has a usable IPv6 connection).

When the connection has been established, a TLS handshake will be done if either
``ssl_context`` or ``tls_hostname`` is not ``None``, or if ``tls`` is ``True``.
Expand Down Expand Up @@ -233,19 +234,13 @@ async def try_connect(remote_host: str, event: Event) -> None:
target_host, remote_port, family=family, type=socket.SOCK_STREAM
)

# Organize the list so that the first address is an IPv6 address (if available)
# and the second one is an IPv4 addresses. The rest can be in whatever order.
v6_found = v4_found = False
target_addrs = []
for af, *_, sa in gai_res:
if af == socket.AF_INET6 and not v6_found:
v6_found = True
target_addrs.insert(0, (af, sa[0]))
elif af == socket.AF_INET and not v4_found and v6_found:
v4_found = True
target_addrs.insert(1, (af, sa[0]))
else:
target_addrs.append((af, sa[0]))
# Use the addresses in the order returned by the resolver, which
# implements RFC 6724 destination address selection: IPv6 is preferred
# when the host has a usable IPv6 connection, and IPv4 comes first (or
# IPv6 is omitted entirely) when it does not. Reordering to blindly put
# IPv6 first caused unnecessary delays on hosts whose only IPv6
# addresses are link-local or loopback (see #1230).
target_addrs = [(af, sa[0]) for af, *_, sa in gai_res]

oserrors: list[OSError] = []
try:
Expand Down
46 changes: 45 additions & 1 deletion tests/test_sockets.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,18 @@ def fake_getaddrinfo(*args: Any, **kwargs: Any) -> object:
monkeypatch.setattr("socket.getaddrinfo", fake_getaddrinfo)


@pytest.fixture
def fake_localhost_dns_ipv6_first(monkeypatch: MonkeyPatch) -> None:
def fake_getaddrinfo(*args: Any, **kwargs: Any) -> object:
# Make it return IPv6 addresses first, as a resolver does on hosts with
# a usable IPv6 connection
results = real_getaddrinfo(*args, **kwargs)
return sorted(results, key=lambda item: item[0], reverse=True)

real_getaddrinfo = socket.getaddrinfo
monkeypatch.setattr("socket.getaddrinfo", fake_getaddrinfo)


@pytest.fixture(
params=[
pytest.param(AddressFamily.AF_INET, id="ipv4"),
Expand Down Expand Up @@ -355,7 +367,11 @@ async def test_socket_options(
@pytest.mark.parametrize(
"local_addr, expected_client_addr",
[
pytest.param("", "::1", id="dualstack"),
# With the resolver returning IPv4 first, the connection follows the
# resolver order (RFC 6724): IPv4 for the dual-stack server, with
# IPv6 still reached via the Happy Eyeballs fallback when the IPv4
# attempt fails (the "ipv6" case below).
pytest.param("", "::ffff:127.0.0.1", id="dualstack"),
pytest.param("127.0.0.1", "127.0.0.1", id="ipv4"),
pytest.param("::1", "::1", id="ipv6"),
],
Expand Down Expand Up @@ -389,6 +405,34 @@ def serve() -> None:
server_sock.close()
assert client_addr[0] == expected_client_addr

@skip_ipv6_mark
async def test_happy_eyeballs_prefers_ipv6_in_resolver_order(
self, fake_localhost_dns_ipv6_first: None
) -> None:
# When the resolver returns IPv6 first (as it does on hosts with a
# usable IPv6 connection), connect_tcp honors that order and connects
# via IPv6 rather than blindly reordering. Refs: #1230
client_addr = None, None

def serve() -> None:
nonlocal client_addr
client, client_addr = server_sock.accept()
client.close()

server_sock = socket.socket(AddressFamily.AF_INET6)
server_sock.bind(("", 0))
server_sock.listen()
port = server_sock.getsockname()[1]
thread = Thread(target=serve, daemon=True)
thread.start()

async with await connect_tcp("localhost", port):
pass

thread.join()
server_sock.close()
assert client_addr[0] == "::1"

async def test_connect_tcp_with_local_port(
self,
server_sock: socket.socket,
Expand Down
Loading