diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index b2993a474..cd1e76b6e 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -5,6 +5,12 @@ This library adheres to `Semantic Versioning 2.0 `_. **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 `_) - 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``). diff --git a/src/anyio/_core/_sockets.py b/src/anyio/_core/_sockets.py index 14ee6e41e..ac178af3e 100644 --- a/src/anyio/_core/_sockets.py +++ b/src/anyio/_core/_sockets.py @@ -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``. @@ -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: diff --git a/tests/test_sockets.py b/tests/test_sockets.py index f47567627..92aabef7c 100644 --- a/tests/test_sockets.py +++ b/tests/test_sockets.py @@ -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"), @@ -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"), ], @@ -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,