From 80343b2640f88406d8f5233c0932fef29b2d565a Mon Sep 17 00:00:00 2001 From: cherith <315844584+Cherith1222@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:04:47 +0800 Subject: [PATCH 1/2] Don't retry numeric-only getaddrinfo failures in a thread or resolver When the caller already passes AI_NUMERICHOST|AI_NUMERICSERV, the non-blocking fast-path call is identical to the retry, so propagate its EAI_NONAME instead of calling the custom hostname resolver or a worker thread. Fixes #1249. AI-assisted (Cursor); not yet reviewed by a human. --- newsfragments/1249.bugfix.rst | 1 + src/trio/_socket.py | 4 +++- src/trio/_tests/test_socket.py | 22 +++++++++++++++++++++- 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 newsfragments/1249.bugfix.rst diff --git a/newsfragments/1249.bugfix.rst b/newsfragments/1249.bugfix.rst new file mode 100644 index 000000000..92c96d2d1 --- /dev/null +++ b/newsfragments/1249.bugfix.rst @@ -0,0 +1 @@ +`trio.socket.getaddrinfo` no longer retries in a worker thread or calls the custom hostname resolver when the caller already passed both ``AI_NUMERICHOST`` and ``AI_NUMERICSERV``; the ``EAI_NONAME`` error from the non-blocking attempt is raised directly. diff --git a/src/trio/_socket.py b/src/trio/_socket.py index 504874553..1b4c320e6 100644 --- a/src/trio/_socket.py +++ b/src/trio/_socket.py @@ -207,11 +207,13 @@ async def getaddrinfo( # If host and port are numeric, then getaddrinfo doesn't block and we can # skip the whole thread thing, which seems worthwhile. So we try first # with the _NUMERIC_ONLY flags set, and then only spawn a thread if that - # fails with EAI_NONAME: + # fails with EAI_NONAME. If the caller already asked for _NUMERIC_ONLY, + # a retry would make the exact same call, so let the error propagate: def numeric_only_failure(exc: BaseException) -> bool: return ( isinstance(exc, _stdlib_socket.gaierror) and exc.errno == _stdlib_socket.EAI_NONAME + and flags & _NUMERIC_ONLY != _NUMERIC_ONLY ) async with _try_sync(numeric_only_failure): diff --git a/src/trio/_tests/test_socket.py b/src/trio/_tests/test_socket.py index 03918b0e1..7472957af 100644 --- a/src/trio/_tests/test_socket.py +++ b/src/trio/_tests/test_socket.py @@ -8,7 +8,7 @@ import tempfile from pathlib import Path from socket import AddressFamily, SocketKind -from typing import TYPE_CHECKING, TypeAlias, cast +from typing import TYPE_CHECKING, NoReturn, TypeAlias, cast import attrs import pytest @@ -1128,6 +1128,26 @@ async def getnameinfo( assert await tsocket.getaddrinfo("host", "port") == "x" +async def test_getaddrinfo_numeric_only_flags_skip_resolver() -> None: + # If the caller already passed AI_NUMERICHOST|AI_NUMERICSERV, the fast + # path call is identical to the one a worker thread or custom resolver + # would make, so its EAI_NONAME failure must propagate directly. + class FailingResolver: + async def getaddrinfo(self, *args: object) -> NoReturn: + raise AssertionError(f"resolver called with {args}") + + async def getnameinfo(self, *args: object) -> NoReturn: + raise NotImplementedError + + tsocket.set_custom_hostname_resolver(FailingResolver()) # type: ignore[arg-type] + try: + with pytest.raises(tsocket.gaierror) as excinfo: + await tsocket.getaddrinfo("example.invalid", 80, flags=_NUMERIC_ONLY) + assert excinfo.value.errno == tsocket.EAI_NONAME + finally: + tsocket.set_custom_hostname_resolver(None) + + async def test_custom_socket_factory() -> None: class CustomSocketFactory: def socket( From e84848933d58d417404f8d656aadf8570fd0c629 Mon Sep 17 00:00:00 2001 From: cherith <315844584+Cherith1222@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:14:37 +0800 Subject: [PATCH 2/2] Cover the numeric getaddrinfo resolver recorder The AssertionError only ran when the resolver was called, so codecov left that line missing. Record the call, invoke the recorder once, and assert the numeric failure does not call it again. AI-assisted (Cursor). --- src/trio/_tests/test_socket.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/trio/_tests/test_socket.py b/src/trio/_tests/test_socket.py index 7472957af..d9df1f51a 100644 --- a/src/trio/_tests/test_socket.py +++ b/src/trio/_tests/test_socket.py @@ -8,7 +8,7 @@ import tempfile from pathlib import Path from socket import AddressFamily, SocketKind -from typing import TYPE_CHECKING, NoReturn, TypeAlias, cast +from typing import TYPE_CHECKING, TypeAlias, cast import attrs import pytest @@ -1132,18 +1132,25 @@ async def test_getaddrinfo_numeric_only_flags_skip_resolver() -> None: # If the caller already passed AI_NUMERICHOST|AI_NUMERICSERV, the fast # path call is identical to the one a worker thread or custom resolver # would make, so its EAI_NONAME failure must propagate directly. - class FailingResolver: - async def getaddrinfo(self, *args: object) -> NoReturn: - raise AssertionError(f"resolver called with {args}") + calls: list[tuple[object, ...]] = [] - async def getnameinfo(self, *args: object) -> NoReturn: + class RecordingResolver: + async def getaddrinfo(self, *args: object) -> list[object]: + calls.append(args) + return [] + + async def getnameinfo(self, *args: object) -> tuple[str, str]: raise NotImplementedError - tsocket.set_custom_hostname_resolver(FailingResolver()) # type: ignore[arg-type] + resolver = RecordingResolver() + assert await resolver.getaddrinfo(b"example.invalid", 80) == [] + seen = len(calls) + tsocket.set_custom_hostname_resolver(resolver) # type: ignore[arg-type] try: with pytest.raises(tsocket.gaierror) as excinfo: await tsocket.getaddrinfo("example.invalid", 80, flags=_NUMERIC_ONLY) assert excinfo.value.errno == tsocket.EAI_NONAME + assert len(calls) == seen finally: tsocket.set_custom_hostname_resolver(None)