Repository: twisted/twisted

Issue:
Support named systemd sockets
|[<img alt="jml's avatar" src="https://avatars.githubusercontent.com/u/125674?s=50" width="50" height="50">](https://github.com/jml)| @jml reported|
|-|-|
|Trac ID|trac#8147|
|Type|enhancement|
|Created|2015-12-18 12:39:15Z|

systemd 227 added support for named sockets: https://github.com/systemd/systemd/blob/master/NEWS#L316-L322

These are documented at http://www.freedesktop.org/software/systemd/man/sd_listen_fds.html

They are exported as the `LISTEN_FDNAMES` environment variables, "which may contain a colon-separated list of names".

This could be a useful thing to support, esp. to mitigate the limitations in [#8146](https://github.com/twisted/twisted/issues/8146).


<details><summary>Searchable metadata</summary>

```
trac-id__8147 8147
type__enhancement enhancement
reporter__jml jml
priority__normal normal
milestone__None None
branch__ 
branch_author__ 
status__new new
resolution__None None
component__core core
keywords__None None
time__1450442355443783 1450442355443783
changetime__1450442355443783 1450442355443783
version__None None
owner__None None

```
</details>



Discussion:

Resolving patch:
diff --git a/src/twisted/internet/endpoints.py b/src/twisted/internet/endpoints.py
--- a/src/twisted/internet/endpoints.py
+++ b/src/twisted/internet/endpoints.py
@@ -17,6 +17,7 @@
 import re
 import socket
 import warnings
+from typing import Optional
 from unicodedata import normalize
 
 from zope.interface import directlyProvides, implementer, provider
@@ -35,6 +36,7 @@
 from twisted.internet.interfaces import (
     IHostnameResolver,
     IReactorPluggableNameResolver,
+    IReactorSocket,
     IResolutionReceiver,
     IStreamClientEndpointStringParserWithReactor,
     IStreamServerEndpointStringParser,
@@ -1504,7 +1506,13 @@ class _SystemdParser:
 
     prefix = "systemd"
 
-    def _parseServer(self, reactor, domain, index):
+    def _parseServer(
+        self,
+        reactor: IReactorSocket,
+        domain: str,
+        index: Optional[str] = None,
+        name: Optional[str] = None,
+    ) -> AdoptedStreamServerEndpoint:
         """
         Internal parser function for L{_parseServer} to convert the string
         arguments for a systemd server endpoint into structured arguments for
@@ -1513,21 +1521,34 @@ def _parseServer(self, reactor, domain, index):
         @param reactor: An L{IReactorSocket} provider.
 
         @param domain: The domain (or address family) of the socket inherited
-            from systemd.  This is a string like C{"INET"} or C{"UNIX"}, ie the
-            name of an address family from the L{socket} module, without the
-            C{"AF_"} prefix.
-        @type domain: C{str}
-
-        @param index: An offset into the list of file descriptors inherited from
-            systemd.
-        @type index: C{str}
+            from systemd.  This is a string like C{"INET"} or C{"UNIX"}, ie
+            the name of an address family from the L{socket} module, without
+            the C{"AF_"} prefix.
+
+        @param index: If given, the decimal representation of an integer
+            giving the offset into the list of file descriptors inherited from
+            systemd.  Since the order of descriptors received from systemd is
+            hard to predict, this option should only be used if only one
+            descriptor is being inherited.  Even in that case, C{name} is
+            probably a better idea.  Either C{index} or C{name} must be given.
+
+        @param name: If given, the name (as defined by C{FileDescriptorName}
+            in the C{[Socket]} section of a systemd service definition) of an
+            inherited file descriptor.  Either C{index} or C{name} must be
+            given.
+
+        @return: An L{AdoptedStreamServerEndpoint} which will adopt the
+            inherited listening port when it is used to listen.
+        """
+        if (index is None) == (name is None):
+            raise ValueError("Specify exactly one of descriptor index or name")
+
+        if index is not None:
+            fileno = self._sddaemon.inheritedDescriptors()[int(index)]
+        else:
+            assert name is not None
+            fileno = self._sddaemon.inheritedNamedDescriptors()[name]
 
-        @return: A two-tuple of parsed positional arguments and parsed keyword
-            arguments (a tuple and a dictionary).  These can be used to
-            construct an L{AdoptedStreamServerEndpoint}.
-        """
-        index = int(index)
-        fileno = self._sddaemon.inheritedDescriptors()[index]
         addressFamily = getattr(socket, "AF_" + domain)
         return AdoptedStreamServerEndpoint(reactor, fileno, addressFamily)
 
diff --git a/src/twisted/python/systemd.py b/src/twisted/python/systemd.py
--- a/src/twisted/python/systemd.py
+++ b/src/twisted/python/systemd.py
@@ -13,9 +13,12 @@
 __all__ = ["ListenFDs"]
 
 from os import getpid
-from typing import Iterable, List, Mapping, Optional
+from typing import Dict, List, Mapping, Optional, Sequence
 
+from attrs import Factory, define
 
+
+@define
 class ListenFDs:
     """
     L{ListenFDs} provides access to file descriptors inherited from systemd.
@@ -30,16 +33,15 @@ class ListenFDs:
 
     @ivar _descriptors: A C{list} of C{int} giving the descriptors which were
         inherited.
+
+    @ivar _names: A L{Sequence} of C{str} giving the names of the descriptors
+        which were inherited.
     """
 
-    _START = 3
+    _descriptors: Sequence[int]
+    _names: Sequence[str] = Factory(tuple)
 
-    def __init__(self, descriptors: List[int]) -> None:
-        """
-        @param descriptors: The descriptors which will be returned from calls to
-            C{inheritedDescriptors}.
-        """
-        self._descriptors = descriptors
+    _START = 3
 
     @classmethod
     def fromEnvironment(
@@ -68,26 +70,85 @@ def fromEnvironment(
         if start is None:
             start = cls._START
 
-        descriptors: List[int] = []
-
-        try:
-            pid = int(environ["LISTEN_PID"])
-        except (KeyError, ValueError):
-            pass
+        if str(getpid()) == environ.get("LISTEN_PID"):
+            descriptors: List[int] = _parseDescriptors(start, environ)
+            names: Sequence[str] = _parseNames(environ)
         else:
-            if pid == getpid():
-                try:
-                    count = int(environ["LISTEN_FDS"])
-                except (KeyError, ValueError):
-                    pass
-                else:
-                    descriptors = list(range(start, start + count))
-                    del environ["LISTEN_PID"], environ["LISTEN_FDS"]
-
-        return cls(descriptors)
-
-    def inheritedDescriptors(self) -> Iterable[int]:
+            descriptors = []
+            names = ()
+
+        # They may both be missing (consistent with not running under systemd
+        # at all) or they may both be present (consistent with running under
+        # systemd 227 or newer).  It is not allowed for only one to be present
+        # or for the values to disagree with each other (per
+        # systemd.socket(5), systemd will use a default value based on the
+        # socket unit name if the socket unit doesn't explicitly define a name
+        # with FileDescriptorName).
+        if len(names) != len(descriptors):
+            return cls([], ())
+
+        return cls(descriptors, names)
+
+    def inheritedDescriptors(self) -> List[int]:
         """
         @return: The configured descriptors.
         """
         return list(self._descriptors)
+
+    def inheritedNamedDescriptors(self) -> Dict[str, int]:
+        """
+        @return: A mapping from the names of configured descriptors to
+            their integer values.
+        """
+        return dict(zip(self._names, self._descriptors))
+
+
+def _parseDescriptors(start: int, environ: Mapping[str, str]) -> List[int]:
+    """
+    Parse the I{LISTEN_FDS} environment variable supplied by systemd.
+
+    @param start: systemd provides only a count of the number of descriptors
+        that have been inherited.  This is the integer value of the first
+        inherited descriptor.  Subsequent inherited descriptors are numbered
+        counting up from here.  See L{ListenFDs._START}.
+
+    @param environ: The environment variable mapping in which to look for the
+        value to parse.
+
+    @return: The integer values of the inherited file descriptors, in order.
+    """
+    try:
+        count = int(environ["LISTEN_FDS"])
+    except (KeyError, ValueError):
+        return []
+    else:
+        descriptors = list(range(start, start + count))
+
+        # Remove the information from the environment so that a second
+        # `ListenFDs` cannot find the same information.  This is a precaution
+        # against some application code accidentally trying to handle the same
+        # inherited descriptor more than once - which probably wouldn't work.
+        #
+        # This precaution is perhaps somewhat questionable since it is up to
+        # the application itself to know whether its handling of the file
+        # descriptor will actually be safe.  Also, nothing stops an
+        # application from getting the same descriptor more than once using
+        # multiple calls to `ListenFDs.inheritedDescriptors()` on the same
+        # `ListenFDs` instance.
+        del environ["LISTEN_PID"], environ["LISTEN_FDS"]
+        return descriptors
+
+
+def _parseNames(environ: Mapping[str, str]) -> Sequence[str]:
+    """
+    Parse the I{LISTEN_FDNAMES} environment variable supplied by systemd.
+
+    @param environ: The environment variable mapping in which to look for the
+        value to parse.
+
+    @return: The names of the inherited descriptors, in order.
+    """
+    names = environ.get("LISTEN_FDNAMES", "")
+    if len(names) > 0:
+        return tuple(names.split(":"))
+    return ()


Test patch:
diff --git a/src/twisted/internet/test/test_endpoints.py b/src/twisted/internet/test/test_endpoints.py
--- a/src/twisted/internet/test/test_endpoints.py
+++ b/src/twisted/internet/test/test_endpoints.py
@@ -8,7 +8,7 @@
 """
 
 from errno import EPERM
-from socket import AF_INET, AF_INET6, IPPROTO_TCP, SOCK_STREAM, gaierror
+from socket import AF_INET, AF_INET6, IPPROTO_TCP, SOCK_STREAM, AddressFamily, gaierror
 from types import FunctionType
 from unicodedata import normalize
 from unittest import skipIf
@@ -3786,10 +3786,12 @@ def test_interface(self):
             verifyObject(interfaces.IStreamServerEndpointStringParser, parser)
         )
 
-    def _parseStreamServerTest(self, addressFamily, addressFamilyString):
+    def _parseIndexStreamServerTest(
+        self, addressFamily: AddressFamily, addressFamilyString: str
+    ) -> None:
         """
-        Helper for unit tests for L{endpoints._SystemdParser.parseStreamServer}
-        for different address families.
+        Helper for tests for L{endpoints._SystemdParser.parseStreamServer}
+        for different address families with a descriptor identified by index.
 
         Handling of the address family given will be verify.  If there is a
         problem a test-failing exception will be raised.
@@ -3802,10 +3804,11 @@ def _parseStreamServerTest(self, addressFamily, addressFamilyString):
         """
         reactor = object()
         descriptors = [5, 6, 7, 8, 9]
+        names = ["5.socket", "6.socket", "foo", "8.socket", "9.socket"]
         index = 3
 
         parser = self._parserClass()
-        parser._sddaemon = ListenFDs(descriptors)
+        parser._sddaemon = ListenFDs(descriptors, names)
 
         server = parser.parseStreamServer(
             reactor, domain=addressFamilyString, index=str(index)
@@ -3814,19 +3817,66 @@ def _parseStreamServerTest(self, addressFamily, addressFamilyString):
         self.assertEqual(server.addressFamily, addressFamily)
         self.assertEqual(server.fileno, descriptors[index])
 
-    def test_parseStreamServerINET(self):
+    def _parseNameStreamServerTest(
+        self, addressFamily: AddressFamily, addressFamilyString: str
+    ) -> None:
+        """
+        Like L{_parseIndexStreamServerTest} but for descriptors identified by
+        name.
+        """
+        reactor = object()
+        descriptors = [5, 6, 7, 8, 9]
+        names = ["5.socket", "6.socket", "foo", "8.socket", "9.socket"]
+        name = "foo"
+
+        parser = self._parserClass()
+        parser._sddaemon = ListenFDs(descriptors, names)
+
+        server = parser.parseStreamServer(
+            reactor,
+            domain=addressFamilyString,
+            name=name,
+        )
+        self.assertIs(server.reactor, reactor)
+        self.assertEqual(server.addressFamily, addressFamily)
+        self.assertEqual(server.fileno, descriptors[names.index(name)])
+
+    def test_parseIndexStreamServerINET(self) -> None:
+        """
+        IPv4 can be specified using the string C{"INET"}.
+        """
+        self._parseIndexStreamServerTest(AF_INET, "INET")
+
+    def test_parseIndexStreamServerINET6(self) -> None:
+        """
+        IPv6 can be specified using the string C{"INET6"}.
+        """
+        self._parseIndexStreamServerTest(AF_INET6, "INET6")
+
+    def test_parseIndexStreamServerUNIX(self) -> None:
+        """
+        A UNIX domain socket can be specified using the string C{"UNIX"}.
+        """
+        try:
+            from socket import AF_UNIX
+        except ImportError:
+            raise unittest.SkipTest("Platform lacks AF_UNIX support")
+        else:
+            self._parseIndexStreamServerTest(AF_UNIX, "UNIX")
+
+    def test_parseNameStreamServerINET(self) -> None:
         """
         IPv4 can be specified using the string C{"INET"}.
         """
-        self._parseStreamServerTest(AF_INET, "INET")
+        self._parseNameStreamServerTest(AF_INET, "INET")
 
-    def test_parseStreamServerINET6(self):
+    def test_parseNameStreamServerINET6(self) -> None:
         """
         IPv6 can be specified using the string C{"INET6"}.
         """
-        self._parseStreamServerTest(AF_INET6, "INET6")
+        self._parseNameStreamServerTest(AF_INET6, "INET6")
 
-    def test_parseStreamServerUNIX(self):
+    def test_parseNameStreamServerUNIX(self) -> None:
         """
         A UNIX domain socket can be specified using the string C{"UNIX"}.
         """
@@ -3835,7 +3885,16 @@ def test_parseStreamServerUNIX(self):
         except ImportError:
             raise unittest.SkipTest("Platform lacks AF_UNIX support")
         else:
-            self._parseStreamServerTest(AF_UNIX, "UNIX")
+            self._parseNameStreamServerTest(AF_UNIX, "UNIX")
+
+    def test_indexAndNameMutuallyExclusive(self) -> None:
+        """
+        The endpoint cannot be defined using both C{index} and C{name}.
+        """
+        parser = self._parserClass()
+        parser._sddaemon = ListenFDs([], ())
+        with self.assertRaises(ValueError):
+            parser.parseStreamServer(reactor, domain="INET", index=0, name="foo")
 
 
 class TCP6ServerEndpointPluginTests(unittest.TestCase):
diff --git a/src/twisted/python/test/strategies.py b/src/twisted/python/test/strategies.py
new file mode 100644
--- /dev/null
+++ b/src/twisted/python/test/strategies.py
@@ -0,0 +1,33 @@
+# Copyright (c) Twisted Matrix Laboratories.
+# See LICENSE for details.
+
+"""
+Hypothesis strategies for values related to L{twisted.python}.
+"""
+
+from hypothesis.strategies import SearchStrategy, characters, text
+
+
+def systemdDescriptorNames() -> SearchStrategy[str]:
+    """
+    Build strings that are legal values for the systemd
+    I{FileDescriptorName} field.
+    """
+    # systemd.socket(5) says:
+    #
+    # > Names may contain any ASCII character, but must exclude control
+    # > characters and ":", and must be at most 255 characters in length.
+    return text(
+        # The docs don't say there is a min size so I'm guessing...
+        min_size=1,
+        max_size=255,
+        alphabet=characters(
+            # These constraints restrict us to ASCII.
+            min_codepoint=0,
+            max_codepoint=127,
+            # This one excludes control characters.
+            blacklist_categories=("Cc",),
+            # And this excludes the separator.
+            blacklist_characters=(":",),
+        ),
+    )
diff --git a/src/twisted/python/test/test_systemd.py b/src/twisted/python/test/test_systemd.py
--- a/src/twisted/python/test/test_systemd.py
+++ b/src/twisted/python/test/test_systemd.py
@@ -7,158 +7,174 @@
 
 
 import os
+from typing import Dict, Mapping, Sequence
+
+from hamcrest import assert_that, equal_to, not_
+from hypothesis import given
+from hypothesis.strategies import dictionaries, integers, lists
 
 from twisted.python.systemd import ListenFDs
-from twisted.trial.unittest import TestCase
+from twisted.trial.unittest import SynchronousTestCase
+from .strategies import systemdDescriptorNames
 
 
-class InheritedDescriptorsMixin:
-    """
-    Mixin for a L{TestCase} subclass which defines test methods for some kind of
-    systemd sd-daemon class.  In particular, it defines tests for a
-    C{inheritedDescriptors} method.
+def buildEnvironment(count: int, pid: object) -> Dict[str, str]:
     """
+    @param count: The number of file descriptors to indicate as inherited.
 
-    def test_inheritedDescriptors(self):
-        """
-        C{inheritedDescriptors} returns a list of integers giving the file
-        descriptors which were inherited from systemd.
-        """
-        sddaemon = self.getDaemon(7, 3)
-        self.assertEqual([7, 8, 9], sddaemon.inheritedDescriptors())
-
-    def test_repeated(self):
-        """
-        Any subsequent calls to C{inheritedDescriptors} return the same list.
-        """
-        sddaemon = self.getDaemon(7, 3)
-        self.assertEqual(
-            sddaemon.inheritedDescriptors(), sddaemon.inheritedDescriptors()
-        )
-
+    @param pid: The pid of the inheriting process to indicate.
 
-class MemoryOnlyMixin:
-    """
-    Mixin for a L{TestCase} subclass which creates creating a fake, in-memory
-    implementation of C{inheritedDescriptors}.  This provides verification that
-    the fake behaves in a compatible way with the real implementation.
+    @return: A copy of the current process environment with the I{systemd}
+        file descriptor inheritance-related environment variables added to it.
     """
-
-    def getDaemon(self, start, count):
-        """
-        Invent C{count} new I{file descriptors} (actually integers, attached to
-        no real file description), starting at C{start}.  Construct and return a
-        new L{ListenFDs} which will claim those integers represent inherited
-        file descriptors.
-        """
-        return ListenFDs(range(start, start + count))
+    result = os.environ.copy()
+    result["LISTEN_FDS"] = str(count)
+    result["LISTEN_FDNAMES"] = ":".join([f"{n}.socket" for n in range(count)])
+    result["LISTEN_PID"] = str(pid)
+    return result
 
 
-class EnvironmentMixin:
+class ListenFDsTests(SynchronousTestCase):
     """
-    Mixin for a L{TestCase} subclass which creates a real implementation of
-    C{inheritedDescriptors} which is based on the environment variables set by
-    systemd.  To facilitate testing, this mixin will also create a fake
-    environment dictionary and add keys to it to make it look as if some
-    descriptors have been inherited.
+    Apply tests to L{ListenFDs}, constructed based on an environment dictionary.
     """
 
-    def initializeEnvironment(self, count, pid):
-        """
-        Create a copy of the process environment and add I{LISTEN_FDS} and
-        I{LISTEN_PID} (the environment variables set by systemd) to it.
-        """
-        result = os.environ.copy()
-        result["LISTEN_FDS"] = str(count)
-        result["LISTEN_PID"] = str(pid)
-        return result
+    @given(lists(systemdDescriptorNames(), min_size=0, max_size=10))
+    def test_fromEnvironmentEquivalence(self, names: Sequence[str]) -> None:
+        """
+        The L{ListenFDs} and L{ListenFDs.fromEnvironment} constructors are
+        equivalent for their respective representations of the same
+        information.
+
+        @param names: The names of the file descriptors to represent as
+            inherited in the test environment given to the parser.  The number
+            of descriptors represented will equal the length of this list.
+        """
+        numFDs = len(names)
+        descriptors = list(range(ListenFDs._START, ListenFDs._START + numFDs))
+        fds = ListenFDs.fromEnvironment(
+            {
+                "LISTEN_PID": str(os.getpid()),
+                "LISTEN_FDS": str(numFDs),
+                "LISTEN_FDNAMES": ":".join(names),
+            }
+        )
+        assert_that(fds, equal_to(ListenFDs(descriptors, tuple(names))))
 
-    def getDaemon(self, start, count):
+    def test_defaultEnviron(self) -> None:
         """
-        Create a new L{ListenFDs} instance, initialized with a fake environment
-        dictionary which will be set up as systemd would have set it up if
-        C{count} descriptors were being inherited.  The descriptors will also
-        start at C{start}.
+        If the process environment is not explicitly passed to
+        L{ListenFDs.fromEnvironment}, the real process environment dictionary
+        is used.
         """
-        fakeEnvironment = self.initializeEnvironment(count, os.getpid())
-        return ListenFDs.fromEnvironment(environ=fakeEnvironment, start=start)
-
-
-class MemoryOnlyTests(MemoryOnlyMixin, InheritedDescriptorsMixin, TestCase):
-    """
-    Apply tests to L{ListenFDs}, explicitly constructed with some fake file
-    descriptors.
-    """
-
-
-class EnvironmentTests(EnvironmentMixin, InheritedDescriptorsMixin, TestCase):
-    """
-    Apply tests to L{ListenFDs}, constructed based on an environment dictionary.
-    """
+        self.patch(os, "environ", buildEnvironment(5, os.getpid()))
+        sddaemon = ListenFDs.fromEnvironment()
+        self.assertEqual(list(range(3, 3 + 5)), sddaemon.inheritedDescriptors())
 
-    def test_secondEnvironment(self):
+    def test_secondEnvironment(self) -> None:
         """
-        Only a single L{Environment} can extract inherited file descriptors.
+        L{ListenFDs.fromEnvironment} removes information about the
+        inherited file descriptors from the environment mapping so that the
+        same inherited file descriptors cannot be handled repeatedly from
+        multiple L{ListenFDs} instances.
         """
-        fakeEnvironment = self.initializeEnvironment(3, os.getpid())
-        first = ListenFDs.fromEnvironment(environ=fakeEnvironment)
-        second = ListenFDs.fromEnvironment(environ=fakeEnvironment)
+        env = buildEnvironment(3, os.getpid())
+        first = ListenFDs.fromEnvironment(environ=env)
+        second = ListenFDs.fromEnvironment(environ=env)
         self.assertEqual(list(range(3, 6)), first.inheritedDescriptors())
         self.assertEqual([], second.inheritedDescriptors())
 
-    def test_mismatchedPID(self):
+    def test_mismatchedPID(self) -> None:
         """
-        If the current process PID does not match the PID in the environment, no
-        inherited descriptors are reported.
+        If the current process PID does not match the PID in the
+        environment then the systemd variables in the environment were set for
+        a different process (perhaps our parent) and the inherited descriptors
+        are not intended for this process so L{ListenFDs.inheritedDescriptors}
+        returns an empty list.
         """
-        fakeEnvironment = self.initializeEnvironment(3, os.getpid() + 1)
-        sddaemon = ListenFDs.fromEnvironment(environ=fakeEnvironment)
+        env = buildEnvironment(3, os.getpid() + 1)
+        sddaemon = ListenFDs.fromEnvironment(environ=env)
         self.assertEqual([], sddaemon.inheritedDescriptors())
 
-    def test_missingPIDVariable(self):
+    def test_missingPIDVariable(self) -> None:
         """
-        If the I{LISTEN_PID} environment variable is not present, no inherited
-        descriptors are reported.
+        If the I{LISTEN_PID} environment variable is not present then
+        there is no clear indication that any file descriptors were inherited
+        by this process so L{ListenFDs.inheritedDescriptors} returns an empty
+        list.
         """
-        fakeEnvironment = self.initializeEnvironment(3, os.getpid())
-        del fakeEnvironment["LISTEN_PID"]
-        sddaemon = ListenFDs.fromEnvironment(environ=fakeEnvironment)
+        env = buildEnvironment(3, os.getpid())
+        del env["LISTEN_PID"]
+        sddaemon = ListenFDs.fromEnvironment(environ=env)
         self.assertEqual([], sddaemon.inheritedDescriptors())
 
-    def test_nonIntegerPIDVariable(self):
+    def test_nonIntegerPIDVariable(self) -> None:
         """
         If the I{LISTEN_PID} environment variable is set to a string that cannot
         be parsed as an integer, no inherited descriptors are reported.
         """
-        fakeEnvironment = self.initializeEnvironment(3, "hello, world")
-        sddaemon = ListenFDs.fromEnvironment(environ=fakeEnvironment)
+        env = buildEnvironment(3, "hello, world")
+        sddaemon = ListenFDs.fromEnvironment(environ=env)
         self.assertEqual([], sddaemon.inheritedDescriptors())
 
-    def test_missingFDSVariable(self):
+    def test_missingFDSVariable(self) -> None:
         """
-        If the I{LISTEN_FDS} environment variable is not present, no inherited
-        descriptors are reported.
+        If the I{LISTEN_FDS} and I{LISTEN_FDNAMES} environment variables
+        are not present, no inherited descriptors are reported.
         """
-        fakeEnvironment = self.initializeEnvironment(3, os.getpid())
-        del fakeEnvironment["LISTEN_FDS"]
-        sddaemon = ListenFDs.fromEnvironment(environ=fakeEnvironment)
+        env = buildEnvironment(3, os.getpid())
+        del env["LISTEN_FDS"]
+        del env["LISTEN_FDNAMES"]
+        sddaemon = ListenFDs.fromEnvironment(environ=env)
         self.assertEqual([], sddaemon.inheritedDescriptors())
 
-    def test_nonIntegerFDSVariable(self):
+    def test_nonIntegerFDSVariable(self) -> None:
         """
         If the I{LISTEN_FDS} environment variable is set to a string that cannot
         be parsed as an integer, no inherited descriptors are reported.
         """
-        fakeEnvironment = self.initializeEnvironment("hello, world", os.getpid())
-        sddaemon = ListenFDs.fromEnvironment(environ=fakeEnvironment)
+        env = buildEnvironment(3, os.getpid())
+        env["LISTEN_FDS"] = "hello, world"
+        sddaemon = ListenFDs.fromEnvironment(environ=env)
         self.assertEqual([], sddaemon.inheritedDescriptors())
 
-    def test_defaultEnviron(self):
+    @given(lists(integers(min_value=0, max_value=10), unique=True))
+    def test_inheritedDescriptors(self, descriptors: Sequence[int]) -> None:
         """
-        If the process environment is not explicitly passed to
-        L{Environment.__init__}, the real process environment dictionary is
-        used.
+        L{ListenFDs.inheritedDescriptors} returns a copy of the inherited
+        descriptors list.
         """
-        self.patch(os, "environ", {"LISTEN_PID": str(os.getpid()), "LISTEN_FDS": "5"})
-        sddaemon = ListenFDs.fromEnvironment()
-        self.assertEqual(list(range(3, 3 + 5)), sddaemon.inheritedDescriptors())
+        names = tuple(map(str, descriptors))
+        fds = ListenFDs(descriptors, names)
+        fdsCopy = fds.inheritedDescriptors()
+        assert_that(descriptors, equal_to(fdsCopy))
+        fdsCopy.append(1)
+        assert_that(descriptors, not_(equal_to(fdsCopy)))
+
+    @given(dictionaries(systemdDescriptorNames(), integers(min_value=0), max_size=10))
+    def test_inheritedNamedDescriptors(self, expected: Mapping[str, int]) -> None:
+        """
+        L{ListenFDs.inheritedNamedDescriptors} returns a mapping from the
+        descriptor names to their integer values, with items formed by
+        pairwise combination of the input descriptors and names.
+        """
+        items = list(expected.items())
+        names = [name for name, _ in items]
+        descriptors = [fd for _, fd in items]
+        fds = ListenFDs(descriptors, names)
+        assert_that(fds.inheritedNamedDescriptors(), equal_to(expected))
+
+    @given(lists(integers(min_value=0, max_value=10), unique=True))
+    def test_repeated(self, descriptors: Sequence[int]) -> None:
+        """
+        Any subsequent calls to C{inheritedDescriptors} and
+        C{inheritedNamedDescriptors} return the same list.
+        """
+        names = tuple(map(str, descriptors))
+        sddaemon = ListenFDs(descriptors, names)
+        self.assertEqual(
+            sddaemon.inheritedDescriptors(), sddaemon.inheritedDescriptors()
+        )
+        self.assertEqual(
+            sddaemon.inheritedNamedDescriptors(), sddaemon.inheritedNamedDescriptors()
+        )