From 82260da73a3c36c5d0b6c31463d5aeed189dbb8b Mon Sep 17 00:00:00 2001 From: oppnc <2673379594@qq.com> Date: Wed, 8 Jul 2026 02:38:44 +0800 Subject: [PATCH] Fix Windows localhost resolution without active network (cherry picked from commit e3c05cf2f2c6809bab4277c775ffbcf289205940) Backport of #12700 to 3.14. --- CHANGES/5357.bugfix.rst | 3 ++ CONTRIBUTORS.txt | 1 + aiohttp/resolver.py | 56 ++++++++++++++++------ tests/test_resolver.py | 102 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 CHANGES/5357.bugfix.rst diff --git a/CHANGES/5357.bugfix.rst b/CHANGES/5357.bugfix.rst new file mode 100644 index 00000000000..dd0125cb9ba --- /dev/null +++ b/CHANGES/5357.bugfix.rst @@ -0,0 +1,3 @@ +Fixed resolving ``localhost`` on Windows to fall back without ``AI_ADDRCONFIG`` +when the first lookup fails, so ``localhost`` still works without an active +network. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 6ce89d092ae..9d36c7edfd2 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -288,6 +288,7 @@ Nándor Mátravölgyi Oisin Aylward Olaf Conradi Omkar Kabde +oppnc Pahaz Blinov Panagiotis Kolokotronis Pankaj Pandey diff --git a/aiohttp/resolver.py b/aiohttp/resolver.py index 84b5ffb667e..28a5cbba0fe 100644 --- a/aiohttp/resolver.py +++ b/aiohttp/resolver.py @@ -1,5 +1,6 @@ import asyncio import socket +import sys import weakref from typing import Any, Final, Optional @@ -22,6 +23,11 @@ _AI_ADDRCONFIG = socket.AI_ADDRCONFIG if hasattr(socket, "AI_MASK"): _AI_ADDRCONFIG &= socket.AI_MASK +_IS_WINDOWS = sys.platform == "win32" + + +def _is_windows_localhost(host: str) -> bool: + return _IS_WINDOWS and host.rstrip(".").casefold() == "localhost" class ThreadedResolver(AbstractResolver): @@ -37,13 +43,24 @@ def __init__(self, loop: asyncio.AbstractEventLoop | None = None) -> None: async def resolve( self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET ) -> list[ResolveResult]: - infos = await self._loop.getaddrinfo( - host, - port, - type=socket.SOCK_STREAM, - family=family, - flags=_AI_ADDRCONFIG, - ) + try: + infos = await self._loop.getaddrinfo( + host, + port, + type=socket.SOCK_STREAM, + family=family, + flags=_AI_ADDRCONFIG, + ) + except socket.gaierror: + if not _is_windows_localhost(host): + raise + infos = await self._loop.getaddrinfo( + host, + port, + type=socket.SOCK_STREAM, + family=family, + flags=0, + ) hosts: list[ResolveResult] = [] for family, _, proto, _, address in infos: @@ -114,13 +131,24 @@ async def resolve( self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET ) -> list[ResolveResult]: try: - resp = await self._resolver.getaddrinfo( - host, - port=port, - type=socket.SOCK_STREAM, - family=family, - flags=_AI_ADDRCONFIG, - ) + try: + resp = await self._resolver.getaddrinfo( + host, + port=port, + type=socket.SOCK_STREAM, + family=family, + flags=_AI_ADDRCONFIG, + ) + except aiodns.error.DNSError: + if not _is_windows_localhost(host): + raise + resp = await self._resolver.getaddrinfo( + host, + port=port, + type=socket.SOCK_STREAM, + family=family, + flags=0, + ) except aiodns.error.DNSError as exc: msg = exc.args[1] if len(exc.args) >= 1 else "DNS lookup failed" raise OSError(None, msg) from exc diff --git a/tests/test_resolver.py b/tests/test_resolver.py index 02a222d0d82..2b4fb9414ad 100644 --- a/tests/test_resolver.py +++ b/tests/test_resolver.py @@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable, Collection, Generator from ipaddress import ip_address from typing import Any, NamedTuple -from unittest.mock import Mock, create_autospec, patch +from unittest.mock import AsyncMock, Mock, call, create_autospec, patch import pytest @@ -233,6 +233,44 @@ async def test_async_resolver_negative_lookup(loop: asyncio.AbstractEventLoop) - await resolver.close() +@pytest.mark.skipif(not getaddrinfo, reason="aiodns >=3.2.0 required") +@pytest.mark.usefixtures("check_no_lingering_resolvers") +async def test_async_resolver_retries_localhost_without_addrconfig_on_windows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("aiohttp.resolver._IS_WINDOWS", True) + with patch("aiodns.DNSResolver") as mock: + mock().getaddrinfo.side_effect = [ + aiodns.error.DNSError(1, "addrconfig failed"), + fake_aiodns_getaddrinfo_ipv4_result(["127.0.0.1"]), + ] + resolver = AsyncResolver() + try: + real = await resolver.resolve("localhost", family=socket.AF_UNSPEC) + finally: + await resolver.close() + + assert real[0]["host"] == "127.0.0.1" + mock().getaddrinfo.assert_has_calls( + [ + call( + "localhost", + family=socket.AF_UNSPEC, + flags=socket.AI_ADDRCONFIG, + port=0, + type=socket.SOCK_STREAM, + ), + call( + "localhost", + family=socket.AF_UNSPEC, + flags=0, + port=0, + type=socket.SOCK_STREAM, + ), + ] + ) + + @pytest.mark.skipif(not getaddrinfo, reason="aiodns >=3.2.0 required") @pytest.mark.usefixtures("check_no_lingering_resolvers") async def test_async_resolver_no_hosts_in_getaddrinfo( @@ -255,6 +293,68 @@ async def test_threaded_resolver_positive_lookup() -> None: ipaddress.ip_address(real[0]["host"]) +async def test_threaded_resolver_retries_localhost_without_addrconfig_on_windows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("aiohttp.resolver._IS_WINDOWS", True) + loop = Mock() + loop.getaddrinfo = AsyncMock( + side_effect=[ + socket.gaierror(), + [(socket.AF_INET, None, socket.SOCK_STREAM, None, ("127.0.0.1", 0))], + ] + ) + resolver = ThreadedResolver() + resolver._loop = loop + + real = await resolver.resolve("localhost", family=socket.AF_UNSPEC) + + assert real[0]["host"] == "127.0.0.1" + loop.getaddrinfo.assert_has_calls( + [ + call( + "localhost", + 0, + type=socket.SOCK_STREAM, + family=socket.AF_UNSPEC, + flags=socket.AI_ADDRCONFIG, + ), + call( + "localhost", + 0, + type=socket.SOCK_STREAM, + family=socket.AF_UNSPEC, + flags=0, + ), + ] + ) + + +@pytest.mark.parametrize( + ("host", "is_windows"), + (("localhost", False), ("www.python.org", True)), +) +async def test_threaded_resolver_keeps_addrconfig_without_localhost_windows_fallback( + host: str, is_windows: bool, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr("aiohttp.resolver._IS_WINDOWS", is_windows) + loop = Mock() + loop.getaddrinfo = AsyncMock(side_effect=socket.gaierror()) + resolver = ThreadedResolver() + resolver._loop = loop + + with pytest.raises(socket.gaierror): + await resolver.resolve(host, family=socket.AF_UNSPEC) + + loop.getaddrinfo.assert_called_once_with( + host, + 0, + type=socket.SOCK_STREAM, + family=socket.AF_UNSPEC, + flags=socket.AI_ADDRCONFIG, + ) + + async def test_threaded_resolver_positive_ipv6_link_local_lookup() -> None: loop = Mock() loop.getaddrinfo = fake_ipv6_addrinfo(["fe80::1"])