r46786 - Merge hostname-endpoint-8014: HostnameEndpoint fix

glyph-TA+aISz0psMTMxyoc4vAAJOcrHinNvQL0E9HWUfgJXw@public.gmane.org
Newsgroups gmane.comp.python.twisted.commits
Message-ID <[email protected]>
Author: glyph
Date: Sun Feb 14 02:01:05 2016
New Revision: 46786

Added:
   trunk/twisted/topfiles/8014.bugfix
Modified:
   trunk/twisted/internet/endpoints.py
   trunk/twisted/internet/test/test_endpoints.py

Log:
Merge hostname-endpoint-8014: HostnameEndpoint fix

Author: glyph

Reviewer: herrwolfe

Fixes: #8014

twisted.internet.endpoints.HostnameEndpoint.connect no longer fails with an AlreadyCalledError when the Deferred it returns is cancelled after all outgoing connection attempts have been made but none have yet succeeded or failed.

Modified: trunk/twisted/internet/endpoints.py
==============================================================================
--- trunk/twisted/internet/endpoints.py	(original)
+++ trunk/twisted/internet/endpoints.py	Sun Feb 14 02:01:05 2016
@@ -635,25 +635,47 @@
 @implementer(interfaces.IStreamClientEndpoint)
 class HostnameEndpoint(object):
     """
-    A name-based endpoint that connects to the fastest amongst the
-    resolved host addresses.
+    A name-based endpoint that connects to the fastest amongst the resolved
+    host addresses.
 
     @ivar _getaddrinfo: A hook used for testing name resolution.
 
     @ivar _deferToThread: A hook used for testing deferToThread.
+
+    @cvar _DEFAULT_ATTEMPT_DELAY: The default time to use between attempts, in
+        seconds, when no C{attemptDelay} is given to
+        L{HostnameEndpoint.__init__}.
     """
     _getaddrinfo = staticmethod(socket.getaddrinfo)
     _deferToThread = staticmethod(threads.deferToThread)
+    _DEFAULT_ATTEMPT_DELAY = 0.3
 
-    def __init__(self, reactor, host, port, timeout=30, bindAddress=None):
+    def __init__(self, reactor, host, port, timeout=30, bindAddress=None,
+                 attemptDelay=None):
         """
+        Create a L{HostnameEndpoint}.
+
+        @param reactor: The reactor to use for connections and delayed calls.
+        @type reactor: provider of L{IReactorTCP} and L{IReactorTime}
+
         @param host: A hostname to connect to.
         @type host: L{bytes}
 
+        @param port: The port number to connect to.
+        @type port: L{int}
+
         @param timeout: For each individual connection attempt, the number of
             seconds to wait before assuming the connection has failed.
         @type timeout: L{int}
 
+        @param bindAddress: the local address of the network interface to make
+            the connections from.
+        @type bindAddress: L{bytes}
+
+        @param attemptDelay: The number of seconds to delay between connection
+            attempts.
+        @type attemptDelay: L{float}
+
         @see: L{twisted.internet.interfaces.IReactorTCP.connectTCP}
         """
         self._reactor = reactor
@@ -661,6 +683,9 @@
         self._port = port
         self._timeout = timeout
         self._bindAddress = bindAddress
+        if attemptDelay is None:
+            attemptDelay = self._DEFAULT_ATTEMPT_DELAY
+        self._attemptDelay = attemptDelay
 
 
     def connect(self, protocolFactory):
@@ -669,35 +694,14 @@
         connection which is established first.
         """
         wf = protocolFactory
-        pending = []
-
-        def _canceller(d):
-            """
-            The outgoing connection attempt was cancelled.  Fail that L{Deferred}
-            with an L{error.ConnectingCancelledError}.
-
-            @param d: The L{Deferred <defer.Deferred>} that was cancelled
-            @type d: L{Deferred <defer.Deferred>}
-
-            @return: C{None}
-            """
-            d.errback(error.ConnectingCancelledError(
-                HostnameAddress(self._host, self._port)))
-            for p in pending[:]:
-                p.cancel()
-
-        def errbackForGai(failure):
-            """
-            Errback for when L{_nameResolution} returns a Deferred that fires
-            with failure.
-            """
-            return defer.fail(error.DNSLookupError(
-                "Couldn't find the hostname '%s'" % (self._host,)))
-
-        def _endpoints(gaiResult):
+        d = self._nameResolution(self._host, self._port)
+        d.addErrback(lambda ignored: defer.fail(error.DNSLookupError(
+            "Couldn't find the hostname '%s'" % (self._host,))))
+        @d.addCallback
+        def gaiResultToEndpoints(gaiResult):
             """
             This method matches the host address family with an endpoint for
-            every address returned by GAI.
+            every address returned by C{getaddrinfo}.
 
             @param gaiResult: A list of 5-tuples as returned by GAI.
             @type gaiResult: list
@@ -709,72 +713,87 @@
                 elif family in [AF_INET]:
                     yield TCP4ClientEndpoint(self._reactor, sockaddr[0],
                             sockaddr[1], self._timeout, self._bindAddress)
-                        # Yields an endpoint for every address returned by GAI
+                    # Yields an endpoint for every address returned by GAI
+
+        def _canceller(d):
+            # This canceller must remain defined outside of
+            # `startConnectionAttempts`, because Deferred should not
+            # participate in cycles with their cancellers; that would create a
+            # potentially problematic circular reference and possibly
+            # gc.garbage.
+            d.errback(error.ConnectingCancelledError(
+                HostnameAddress(self._host, self._port)))
 
-        def attemptConnection(endpoints):
+        @d.addCallback
+        def startConnectionAttempts(endpoints):
             """
-            When L{endpoints} yields an endpoint, this method attempts to connect it.
+            Given a sequence of endpoints obtained via name resolution, start
+            connecting to a new one every C{self._attemptDelay} seconds until
+            one of the connections succeeds, all of them fail, or the attempt
+            is cancelled.
+
+            @param endpoints: an iterable of all the endpoints we might try to
+                connect to, as determined by name resolution.
+            @type endpoints: iterable of L{IStreamServerEndpoint}
+
+            @return: a Deferred that fires with the result of the
+                C{endpoint.connect} method that completes the fastest, or fails
+                with the first connection error it encountered if none of them
+                succeed.
+            @rtype: L{Deferred} failing with L{error.ConnectingCancelledError}
+                or firing with L{IProtocol}
             """
-            # The trial attempts for each endpoints, the recording of
-            # successful and failed attempts, and the algorithm to pick the
-            # winner endpoint goes here.
-            # Return a Deferred that fires with the endpoint that wins,
-            # or `failures` if none succeed.
-
-            endpointsListExhausted = []
-            successful = []
+            pending = []
             failures = []
             winner = defer.Deferred(canceller=_canceller)
 
-            def usedEndpointRemoval(connResult, connAttempt):
-                pending.remove(connAttempt)
-                return connResult
-
-            def afterConnectionAttempt(connResult):
-                if lc.running:
-                    lc.stop()
-
-                successful.append(True)
-                for p in pending[:]:
-                    p.cancel()
-                winner.callback(connResult)
-                return None
-
             def checkDone():
-                if endpointsListExhausted and not pending and not successful:
-                    winner.errback(failures.pop())
-
-            def connectFailed(reason):
-                failures.append(reason)
-                checkDone()
-                return None
+                if pending or checkDone.completed or checkDone.endpointsLeft:
+                    return
+                winner.errback(failures.pop())
+            checkDone.completed = False
+            checkDone.endpointsLeft = True
 
+            @LoopingCall
             def iterateEndpoint():
-                try:
-                    endpoint = next(endpoints)
-                except StopIteration:
+                endpoint = next(endpoints, None)
+                if endpoint is None:
                     # The list of endpoints ends.
-                    endpointsListExhausted.append(True)
-                    lc.stop()
+                    checkDone.endpointsLeft = False
+                    iterateEndpoint.stop()
+                    checkDone()
+                    return
+
+                eachAttempt = endpoint.connect(wf)
+                pending.append(eachAttempt)
+                @eachAttempt.addBoth
+                def noLongerPending(result):
+                    pending.remove(eachAttempt)
+                    return result
+                @eachAttempt.addCallback
+                def succeeded(result):
+                    if iterateEndpoint.running:
+                        iterateEndpoint.stop()
+                    winner.callback(result)
+                @eachAttempt.addErrback
+                def failed(reason):
+                    failures.append(reason)
                     checkDone()
-                else:
-                    dconn = endpoint.connect(wf)
-                    pending.append(dconn)
-                    dconn.addBoth(usedEndpointRemoval, dconn)
-                    dconn.addCallback(afterConnectionAttempt)
-                    dconn.addErrback(connectFailed)
-
-            lc = LoopingCall(iterateEndpoint)
-            lc.clock = self._reactor
-            lc.start(0.3)
+
+            iterateEndpoint.clock = self._reactor
+            iterateEndpoint.start(self._attemptDelay)
+
+            @winner.addBoth
+            def cancelRemainingPending(result):
+                checkDone.completed = True
+                for remaining in pending[:]:
+                    remaining.cancel()
+                return result
             return winner
 
-        d = self._nameResolution(self._host, self._port)
-        d.addErrback(errbackForGai)
-        d.addCallback(_endpoints)
-        d.addCallback(attemptConnection)
         return d
 
+
     def _nameResolution(self, host, port):
         """
         Resolve the hostname string into a tuple containig the host

Modified: trunk/twisted/internet/test/test_endpoints.py
==============================================================================
--- trunk/twisted/internet/test/test_endpoints.py	(original)
+++ trunk/twisted/internet/test/test_endpoints.py	Sun Feb 14 02:01:05 2016
@@ -1660,10 +1660,10 @@
         self.assertEqual(ep._getaddrinfo, socket.getaddrinfo)
 
 
-    def test_endpointConnectingCancelled(self):
+    def test_endpointConnectingCancelled(self, advance=None):
         """
         Calling L{Deferred.cancel} on the L{Deferred} returned from
-        L{IStreamClientEndpoint.connect} is errbacked with an expected
+        L{IStreamClientEndpoint.connect} will cause it to be errbacked with a
         L{ConnectingCancelledError} exception.
         """
         mreactor = MemoryReactor()
@@ -1675,6 +1675,8 @@
             mreactor, clientFactory)
 
         d = ep.connect(clientFactory)
+        if advance is not None:
+            mreactor.advance(advance)
         d.cancel()
         # When canceled, the connector will immediately notify its factory that
         # the connection attempt has failed due to a UserError.
@@ -1689,6 +1691,18 @@
         self.assertTrue(mreactor.tcpClients[0][2]._connector.stoppedConnecting)
 
 
+    def test_endpointConnectingCancelledAfterAllAttemptsStarted(self):
+        """
+        Calling L{Deferred.cancel} on the L{Deferred} returned from
+        L{IStreamClientEndpoint.connect} after enough time has passed that all
+        connection attempts have been initiated will cause it to be errbacked
+        with a L{ConnectingCancelledError} exception.
+        """
+        oneBetween = endpoints.HostnameEndpoint._DEFAULT_ATTEMPT_DELAY
+        advance = oneBetween + (oneBetween / 2.0)
+        self.test_endpointConnectingCancelled(advance=advance)
+
+
     def test_endpointConnectFailure(self):
         """
         If L{HostnameEndpoint.connect} is invoked and there is no server
@@ -1706,7 +1720,7 @@
             mreactor, clientFactory)
 
         d = ep.connect(clientFactory)
-        mreactor.advance(0.3)
+        mreactor.advance(endpoints.HostnameEndpoint._DEFAULT_ATTEMPT_DELAY)
         self.assertEqual(self.failureResultOf(d).value, expectedError)
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.