Repository: python-trio/trio

Issue:
serve_tcp() fails out-of-the-box on systems with disabled IPv6
I recently had occasion to run a Trio server on a Linux system that had `ipv6.disable=1` in the kernel command line. I don't know why it was configured this way, but some Googling suggests that this is not such a very uncommon configuration. In this configuration, `getaddrinfo(None, ...)` still returns an IPv6 option, but `socket(AF_INET6, ...)` fails with `[Errno 97] Address family not supported by protocol`. This also affects several tests in trio's test suite.

I can work around this by explicitly serving on `"0.0.0.0"`, but it feels like a rough edge. As an alternative, maybe trio could suppress `EAFNOSUPPORT` errors from IPv6 socket creation in `open_tcp_listeners` if the IPv4 socket was created OK. Thoughts?


Discussion:
Oh yeah, we should do that.

There are also increasing numbers of systems out there with only ipv6, no ipv4, so we should handle that too.

Resolving patch:
diff --git a/trio/_highlevel_open_tcp_listeners.py b/trio/_highlevel_open_tcp_listeners.py
--- a/trio/_highlevel_open_tcp_listeners.py
+++ b/trio/_highlevel_open_tcp_listeners.py
@@ -1,3 +1,4 @@
+import errno
 import sys
 from math import inf
 
@@ -100,9 +101,23 @@ async def open_tcp_listeners(port, *, host=None, backlog=None):
     )
 
     listeners = []
+    unsupported_address_families = []
     try:
         for family, type, proto, _, sockaddr in addresses:
-            sock = tsocket.socket(family, type, proto)
+            try:
+                sock = tsocket.socket(family, type, proto)
+            except OSError as ex:
+                if ex.errno == errno.EAFNOSUPPORT:
+                    # If a system only supports IPv4, or only IPv6, it
+                    # is still likely that getaddrinfo will return
+                    # both an IPv4 and an IPv6 address. As long as at
+                    # least one of the returned addresses can be
+                    # turned into a socket, we won't complain about a
+                    # failure to create the other.
+                    unsupported_address_families.append(ex)
+                    continue
+                else:
+                    raise
             try:
                 # See https://github.com/python-trio/trio/issues/39
                 if sys.platform == "win32":
@@ -131,6 +146,13 @@ async def open_tcp_listeners(port, *, host=None, backlog=None):
             listener.socket.close()
         raise
 
+    if unsupported_address_families and not listeners:
+        raise OSError(
+            errno.EAFNOSUPPORT,
+            "This system doesn't support any of the kinds of "
+            "socket that that address could use"
+        ) from trio.MultiError(unsupported_address_families)
+
     return listeners
 
 


Test patch:
diff --git a/trio/_core/tests/tutil.py b/trio/_core/tests/tutil.py
--- a/trio/_core/tests/tutil.py
+++ b/trio/_core/tests/tutil.py
@@ -12,15 +12,28 @@
 )
 
 try:
-    with stdlib_socket.socket(
+    s = stdlib_socket.socket(
         stdlib_socket.AF_INET6, stdlib_socket.SOCK_STREAM, 0
-    ) as s:
-        s.bind(('::1', 0))
-    have_ipv6 = True
-except OSError:
-    have_ipv6 = False
-
-need_ipv6 = pytest.mark.skipif(not have_ipv6, reason="need IPv6")
+    )
+except OSError:  # pragma: no cover
+    # Some systems don't even support creating an IPv6 socket, let alone
+    # binding it. (ex: Linux with 'ipv6.disable=1' in the kernel command line)
+    # We don't have any of those in our CI, and there's nothing that gets
+    # tested _only_ if can_create_ipv6 = False, so we'll just no-cover this.
+    can_create_ipv6 = False
+    can_bind_ipv6 = False
+else:
+    can_create_ipv6 = True
+    with s:
+        try:
+            s.bind(('::1', 0))
+        except OSError:
+            can_bind_ipv6 = False
+        else:
+            can_bind_ipv6 = True
+
+creates_ipv6 = pytest.mark.skipif(not can_create_ipv6, reason="need IPv6")
+binds_ipv6 = pytest.mark.skipif(not can_bind_ipv6, reason="need IPv6")
 
 
 def gc_collect_harder():
diff --git a/trio/tests/test_highlevel_open_tcp_listeners.py b/trio/tests/test_highlevel_open_tcp_listeners.py
--- a/trio/tests/test_highlevel_open_tcp_listeners.py
+++ b/trio/tests/test_highlevel_open_tcp_listeners.py
@@ -1,6 +1,7 @@
 import pytest
 
 import socket as stdlib_socket
+import errno
 
 import attr
 
@@ -10,7 +11,7 @@
 )
 from trio.testing import open_stream_to_socket_listener
 from .. import socket as tsocket
-from .._core.tests.tutil import slow, need_ipv6
+from .._core.tests.tutil import slow, creates_ipv6, binds_ipv6
 
 
 async def test_open_tcp_listeners_basic():
@@ -106,7 +107,7 @@ async def check_backlog(nominal, required_min, required_max):
     await check_backlog(nominal=11, required_min=11, required_max=20)
 
 
-@need_ipv6
+@binds_ipv6
 async def test_open_tcp_listeners_ipv6_v6only():
     # Check IPV6_V6ONLY is working properly
     (ipv6_listener,) = await open_tcp_listeners(0, host="::1")
@@ -192,9 +193,12 @@ def close(self):
 @attr.s
 class FakeSocketFactory:
     poison_after = attr.ib()
-    sockets = attr.ib(default=attr.Factory(list))
+    sockets = attr.ib(factory=list)
+    raise_on_family = attr.ib(factory=dict)  # family => errno
 
     def socket(self, family, type, proto):
+        if family in self.raise_on_family:
+            raise OSError(self.raise_on_family[family], "nope")
         sock = FakeSocket(family, type, proto)
         self.poison_after -= 1
         if self.poison_after == 0:
@@ -203,13 +207,14 @@ def socket(self, family, type, proto):
         return sock
 
 
+@attr.s
 class FakeHostnameResolver:
+    family_addr_pairs = attr.ib()
+
     async def getaddrinfo(self, host, port, family, type, proto, flags):
-        common = (tsocket.AF_INET, tsocket.SOCK_STREAM, 0, "")
         return [
-            common + (("1.1.1.1", port),),
-            common + (("2.2.2.2", port),),
-            common + (("3.3.3.3", port),),
+            (family, tsocket.SOCK_STREAM, 0, "",
+             (addr, port)) for family, addr in self.family_addr_pairs
         ]
 
 
@@ -218,7 +223,15 @@ async def test_open_tcp_listeners_multiple_host_cleanup_on_error():
     # call get cleaned up before returning
     fsf = FakeSocketFactory(3)
     tsocket.set_custom_socket_factory(fsf)
-    tsocket.set_custom_hostname_resolver(FakeHostnameResolver())
+    tsocket.set_custom_hostname_resolver(
+        FakeHostnameResolver(
+            [
+                (tsocket.AF_INET, "1.1.1.1"),
+                (tsocket.AF_INET, "2.2.2.2"),
+                (tsocket.AF_INET, "3.3.3.3"),
+            ]
+        )
+    )
 
     with pytest.raises(FakeOSError):
         await open_tcp_listeners(80, host="example.org")
@@ -248,3 +261,74 @@ async def handler(stream):
         async with stream:
             await stream.receive_some(1) == b"x"
             nursery.cancel_scope.cancel()
+
+
+@pytest.mark.parametrize(
+    "try_families", [
+        {tsocket.AF_INET},
+        {tsocket.AF_INET6},
+        {tsocket.AF_INET, tsocket.AF_INET6},
+    ]
+)
+@pytest.mark.parametrize(
+    "fail_families", [
+        {tsocket.AF_INET},
+        {tsocket.AF_INET6},
+        {tsocket.AF_INET, tsocket.AF_INET6},
+    ]
+)
+async def test_open_tcp_listeners_some_address_families_unavailable(
+    try_families, fail_families
+):
+    fsf = FakeSocketFactory(
+        10,
+        raise_on_family={
+            family: errno.EAFNOSUPPORT
+            for family in fail_families
+        }
+    )
+    tsocket.set_custom_socket_factory(fsf)
+    tsocket.set_custom_hostname_resolver(
+        FakeHostnameResolver([(family, "foo") for family in try_families])
+    )
+
+    should_succeed = try_families - fail_families
+
+    if not should_succeed:
+        with pytest.raises(OSError) as exc_info:
+            await open_tcp_listeners(80, host="example.org")
+
+        assert "This system doesn't support" in str(exc_info.value)
+        if isinstance(exc_info.value.__cause__, trio.MultiError):
+            for subexc in exc_info.value.__cause__.exceptions:
+                assert "nope" in str(subexc)
+        else:
+            assert isinstance(exc_info.value.__cause__, OSError)
+            assert "nope" in str(exc_info.value.__cause__)
+    else:
+        listeners = await open_tcp_listeners(80)
+        for listener in listeners:
+            should_succeed.remove(listener.socket.family)
+        assert not should_succeed
+
+
+async def test_open_tcp_listeners_socket_fails_not_afnosupport():
+    fsf = FakeSocketFactory(
+        10,
+        raise_on_family={
+            tsocket.AF_INET: errno.EAFNOSUPPORT,
+            tsocket.AF_INET6: errno.EINVAL,
+        }
+    )
+    tsocket.set_custom_socket_factory(fsf)
+    tsocket.set_custom_hostname_resolver(
+        FakeHostnameResolver(
+            [(tsocket.AF_INET, "foo"), (tsocket.AF_INET6, "bar")]
+        )
+    )
+
+    with pytest.raises(OSError) as exc_info:
+        await open_tcp_listeners(80, host="example.org")
+    assert exc_info.value.errno == errno.EINVAL
+    assert exc_info.value.__cause__ is None
+    assert "nope" in str(exc_info.value)
diff --git a/trio/tests/test_socket.py b/trio/tests/test_socket.py
--- a/trio/tests/test_socket.py
+++ b/trio/tests/test_socket.py
@@ -1,11 +1,12 @@
 import pytest
+import attr
 
 import os
 import socket as stdlib_socket
 import inspect
 import tempfile
 
-from .._core.tests.tutil import need_ipv6
+from .._core.tests.tutil import creates_ipv6, binds_ipv6
 from .. import _core
 from .. import _socket as _tsocket
 from .. import socket as tsocket
@@ -254,6 +255,9 @@ async def test_socket():
         assert isinstance(s, tsocket.SocketType)
         assert s.family == tsocket.AF_INET
 
+
+@creates_ipv6
+async def test_socket_v6():
     with tsocket.socket(tsocket.AF_INET6, tsocket.SOCK_DGRAM) as s:
         assert isinstance(s, tsocket.SocketType)
         assert s.family == tsocket.AF_INET6
@@ -357,7 +361,7 @@ async def test_SocketType_shutdown():
 @pytest.mark.parametrize(
     "address, socket_type", [
         ('127.0.0.1', tsocket.AF_INET),
-        pytest.param('::1', tsocket.AF_INET6, marks=need_ipv6)
+        pytest.param('::1', tsocket.AF_INET6, marks=binds_ipv6)
     ]
 )
 async def test_SocketType_simple_server(address, socket_type):
@@ -389,55 +393,82 @@ def gai_without_v4mapped_is_buggy():  # pragma: no cover
         return True
 
 
+@attr.s
+class Addresses:
+    bind_all = attr.ib()
+    localhost = attr.ib()
+    arbitrary = attr.ib()
+    broadcast = attr.ib()
+    extra = attr.ib()
+
+
 # Direct thorough tests of the implicit resolver helpers
-async def test_SocketType_resolve():
+@pytest.mark.parametrize(
+    "socket_type, addrs", [
+        (
+            tsocket.AF_INET,
+            Addresses(
+                bind_all="0.0.0.0",
+                localhost="127.0.0.1",
+                arbitrary="1.2.3.4",
+                broadcast="255.255.255.255",
+                extra=(),
+            ),
+        ),
+        pytest.param(
+            tsocket.AF_INET6,
+            Addresses(
+                bind_all="::",
+                localhost="::1",
+                arbitrary="1::2",
+                broadcast="::ffff:255.255.255.255",
+                extra=(0, 0),
+            ),
+            marks=creates_ipv6,
+        ),
+    ]
+)
+async def test_SocketType_resolve(socket_type, addrs):
+    v6 = (socket_type == tsocket.AF_INET6)
+
     # For some reason the stdlib special-cases "" to pass NULL to getaddrinfo
     # They also error out on None, but whatever, None is much more consistent,
     # so we accept it too.
     for null in [None, ""]:
-        sock4 = tsocket.socket(family=tsocket.AF_INET)
-        got = await sock4._resolve_local_address((null, 80))
-        assert got == ("0.0.0.0", 80)
-        got = await sock4._resolve_remote_address((null, 80))
-        assert got == ("127.0.0.1", 80)
-
-        sock6 = tsocket.socket(family=tsocket.AF_INET6)
-        got = await sock6._resolve_local_address((null, 80))
-        assert got == ("::", 80, 0, 0)
-
-        got = await sock6._resolve_remote_address((null, 80))
-        assert got == ("::1", 80, 0, 0)
+        sock = tsocket.socket(family=socket_type)
+        got = await sock._resolve_local_address((null, 80))
+        assert got == (addrs.bind_all, 80, *addrs.extra)
+        got = await sock._resolve_remote_address((null, 80))
+        assert got == (addrs.localhost, 80, *addrs.extra)
 
     # AI_PASSIVE only affects the wildcard address, so for everything else
     # _resolve_local_address and _resolve_remote_address should work the same:
-    for res in ["_resolve_local_address", "_resolve_remote_address"]:
-
-        async def s4res(*args):
-            return await getattr(sock4, res)(*args)
-
-        async def s6res(*args):
-            return await getattr(sock6, res)(*args)
+    for resolver in ["_resolve_local_address", "_resolve_remote_address"]:
 
-        assert await s4res(("1.2.3.4", "http")) == ("1.2.3.4", 80)
-        assert await s6res(("1::2", "http")) == ("1::2", 80, 0, 0)
+        async def res(*args):
+            return await getattr(sock, resolver)(*args)
 
-        assert await s6res(("1::2", 80, 1)) == ("1::2", 80, 1, 0)
-        assert await s6res(("1::2", 80, 1, 2)) == ("1::2", 80, 1, 2)
+        assert await res((addrs.arbitrary,
+                          "http")) == (addrs.arbitrary, 80, *addrs.extra)
+        if v6:
+            assert await res(("1::2", 80, 1)) == ("1::2", 80, 1, 0)
+            assert await res(("1::2", 80, 1, 2)) == ("1::2", 80, 1, 2)
 
-        # V4 mapped addresses resolved if V6ONLY is False
-        sock6.setsockopt(tsocket.IPPROTO_IPV6, tsocket.IPV6_V6ONLY, False)
-        assert await s6res(("1.2.3.4", "http")) == ("::ffff:1.2.3.4", 80, 0, 0)
+            # V4 mapped addresses resolved if V6ONLY is False
+            sock.setsockopt(tsocket.IPPROTO_IPV6, tsocket.IPV6_V6ONLY, False)
+            assert await res(("1.2.3.4",
+                              "http")) == ("::ffff:1.2.3.4", 80, 0, 0)
 
         # Check the <broadcast> special case, because why not
-        await s4res(("<broadcast>", 123)) == ("255.255.255.255", 123)
-        await s6res(("<broadcast>", 123)) == ("::ffff:255.255.255.255", 123)
+        assert await res(("<broadcast>",
+                          123)) == (addrs.broadcast, 123, *addrs.extra)
 
         # But not if it's true (at least on systems where getaddrinfo works
         # correctly)
-        if not gai_without_v4mapped_is_buggy():  # pragma: no branch
-            sock6.setsockopt(tsocket.IPPROTO_IPV6, tsocket.IPV6_V6ONLY, True)
+        if v6 and not gai_without_v4mapped_is_buggy():
+            sock.setsockopt(tsocket.IPPROTO_IPV6, tsocket.IPV6_V6ONLY, True)
             with pytest.raises(tsocket.gaierror) as excinfo:
-                await s6res(("1.2.3.4", 80))
+                await res(("1.2.3.4", 80))
             # Windows, macOS
             expected_errnos = {tsocket.EAI_NONAME}
             # Linux
@@ -455,20 +486,17 @@ async def s6res(*args):
         except (AttributeError, OSError):
             pass
         else:
-            assert await getattr(netlink_sock, res)("asdf") == "asdf"
+            assert await getattr(netlink_sock, resolver)("asdf") == "asdf"
 
         with pytest.raises(ValueError):
-            await s4res("1.2.3.4")
-        with pytest.raises(ValueError):
-            await s4res(("1.2.3.4",))
-        with pytest.raises(ValueError):
-            await s4res(("1.2.3.4", 80, 0, 0))
-        with pytest.raises(ValueError):
-            await s6res("1.2.3.4")
+            await res("1.2.3.4")
         with pytest.raises(ValueError):
-            await s6res(("1.2.3.4",))
+            await res(("1.2.3.4",))
         with pytest.raises(ValueError):
-            await s6res(("1.2.3.4", 80, 0, 0, 0))
+            if v6:
+                await res(("1.2.3.4", 80, 0, 0, 0))
+            else:
+                await res(("1.2.3.4", 80, 0, 0))
 
 
 async def test_SocketType_unresolved_names():
diff --git a/trio/tests/test_testing.py b/trio/tests/test_testing.py
--- a/trio/tests/test_testing.py
+++ b/trio/tests/test_testing.py
@@ -6,7 +6,7 @@
 
 import pytest
 
-from .._core.tests.tutil import have_ipv6
+from .._core.tests.tutil import can_bind_ipv6
 from .. import sleep
 from .. import _core
 from .._highlevel_generic import aclose_forcefully
@@ -822,7 +822,7 @@ async def check(listener):
     sock.listen(10)
     await check(SocketListener(sock))
 
-    if have_ipv6:
+    if can_bind_ipv6:
         # Listener bound to IPv6 wildcard (needs special handling)
         sock = tsocket.socket(family=tsocket.AF_INET6)
         await sock.bind(("::", 0))