Repository: twisted/tubes

Issue:
_ProtocolPlumbing does not implement IHalfCloseableProtocol, leading to the misleading appearance of horrifying race conditions during testing
If you were to make a script of JSON commands for `fanchat` and use the `stdio` endpoint to hook them together into a little test, then do something like

```console
$ cat fcscript.txt | python fanchat.py
```

you would get very weird non-deterministic behavior where half of the output would vanish.

This is because, in the absence of a protocol providing `IHalfCloseableProtocol` as the protocol, "EOF" means "shut down both read *and write* halves of the connection".

Understandably, further writes to the `StandardIO` would be lost.

To remedy this, `IHalfCloseableProtocol` should be implemented - which, by the way, would also allow us to separate the `fount` and `drain` termination on transports which support such a distinction.

Comments:

Patch:
diff --git a/tubes/protocol.py b/tubes/protocol.py
index a5212fb..ec82031 100644
--- a/tubes/protocol.py
+++ b/tubes/protocol.py
@@ -20,8 +20,11 @@ from .itube import StopFlowCalled, IDrain, IFount, ISegment
 from .listening import Flow
 
 from twisted.python.failure import Failure
-from twisted.internet.interfaces import IPushProducer, IListeningPort
+from twisted.internet.interfaces import (
+    IPushProducer, IListeningPort, IHalfCloseableProtocol
+)
 from twisted.internet.protocol import Protocol as _Protocol
+from twisted.internet.error import ConnectionDone
 
 if 0:
     # Workaround for inability of pydoctor to resolve references.
@@ -206,6 +209,7 @@ class _TransportFount(object):
 
 
 
+@implementer(IHalfCloseableProtocol)
 class _ProtocolPlumbing(_Protocol):
     """
     An adapter between an L{ITransport} and L{IFount} / L{IDrain} interfaces.
@@ -274,6 +278,22 @@ class _ProtocolPlumbing(_Protocol):
             self._drain.fount.stopFlow()
 
 
+    def readConnectionLost(self):
+        """
+        An end-of-file was received.
+        """
+        self._fount.drain.flowStopped(Failure(ConnectionDone()))
+        self._fount.drain = None
+
+
+    def writeConnectionLost(self):
+        """
+        The write output was closed.
+        """
+        self._drain.fount.stopFlow()
+        self._drain.fount = None
+
+
 
 def _factoryFromFlow(flow):
     """


Test patch:
diff --git a/tubes/test/test_protocol.py b/tubes/test/test_protocol.py
index 7eb4b3e..5450278 100644
--- a/tubes/test/test_protocol.py
+++ b/tubes/test/test_protocol.py
@@ -7,12 +7,16 @@ Tests for L{tubes.protocol}.
 """
 
 from zope.interface import implementer
+from zope.interface.verify import verifyObject
 
 from twisted.trial.unittest import SynchronousTestCase as TestCase
 
 from twisted.python.failure import Failure
 from twisted.test.proto_helpers import StringTransport
-from twisted.internet.interfaces import IStreamServerEndpoint
+from twisted.internet.interfaces import (
+    IStreamServerEndpoint, IHalfCloseableProtocol
+)
+from twisted.internet.error import ConnectionDone
 
 from ..protocol import flowFountFromEndpoint, flowFromEndpoint
 from ..tube import tube, series
@@ -404,6 +408,72 @@ class FlowListenerTests(TestCase):
         self.assertEqual(ports[0].currentlyProducing, True)
 
 
+    def test_readConnectionLost(self):
+        """
+        The protocol created by L{flowFountFromEndpoint} provides half-close
+        support, and when it receives an EOF (i.e.: C{readConnectionLost}) it
+        will signal the end of the flow to its fount's drain, but not to its
+        drain's fount.
+        """
+        endpoint, ports = fakeEndpointWithPorts()
+        fffep = flowFountFromEndpoint(endpoint)
+        fffep.callback(None)
+        flowFount = self.successResultOf(fffep)
+        protocol = ports[0].factory.buildProtocol(None)
+        verifyObject(IHalfCloseableProtocol, protocol)
+        aTransport = StringTransport()
+        protocol.makeConnection(aTransport)
+        accepted = FakeDrain()
+        flowFount.flowTo(accepted)
+        [flow] = accepted.received
+        receivedData = FakeDrain()
+        dataSender = FakeFount()
+        flow.fount.flowTo(receivedData)
+        dataSender.flowTo(flow.drain)
+        self.assertEqual(len(receivedData.stopped), 0)
+        self.assertEqual(dataSender.flowIsStopped, False)
+        protocol.readConnectionLost()
+        self.assertEqual(len(receivedData.stopped), 1)
+        self.assertIsInstance(receivedData.stopped[0], Failure)
+        receivedData.stopped[0].trap(ConnectionDone)
+        self.assertEqual(dataSender.flowIsStopped, False)
+        protocol.connectionLost(ConnectionDone())
+        self.assertEqual(len(receivedData.stopped), 1)
+        self.assertEqual(dataSender.flowIsStopped, True)
+
+
+    def test_writeConnectionLost(self):
+        """
+        The protocol created by L{flowFountFromEndpoint} provides half-close
+        support, and when it receives an EOF (i.e.: C{writeConnectionLost}) it
+        will signal the end of the flow to its drain's fount, but not to its
+        fount's drain.
+        """
+        endpoint, ports = fakeEndpointWithPorts()
+        fffep = flowFountFromEndpoint(endpoint)
+        fffep.callback(None)
+        flowFount = self.successResultOf(fffep)
+        protocol = ports[0].factory.buildProtocol(None)
+        verifyObject(IHalfCloseableProtocol, protocol)
+        aTransport = StringTransport()
+        protocol.makeConnection(aTransport)
+        accepted = FakeDrain()
+        flowFount.flowTo(accepted)
+        [flow] = accepted.received
+        receivedData = FakeDrain()
+        dataSender = FakeFount()
+        flow.fount.flowTo(receivedData)
+        dataSender.flowTo(flow.drain)
+        self.assertEqual(len(receivedData.stopped), 0)
+        self.assertEqual(dataSender.flowIsStopped, False)
+        protocol.writeConnectionLost()
+        self.assertEqual(len(receivedData.stopped), 0)
+        self.assertEqual(dataSender.flowIsStopped, 1)
+        protocol.connectionLost(ConnectionDone())
+        self.assertEqual(len(receivedData.stopped), 1)
+        self.assertEqual(dataSender.flowIsStopped, 1)
+
+
     def test_backpressure(self):
         """
         When the L{IFount} returned by L{flowFountFromEndpoint} is paused, it
diff --git a/tubes/test/util.py b/tubes/test/util.py
index 054a928..9e291d1 100644
--- a/tubes/test/util.py
+++ b/tubes/test/util.py
@@ -137,6 +137,7 @@ class FakeFount(object):
 
     flowIsPaused = 0
     flowIsStopped = False
+
     def __init__(self, outputType=None):
         self._pauser = Pauser(self._actuallyPause, self._actuallyResume)
         self.outputType = outputType
@@ -170,9 +171,9 @@ class FakeFount(object):
 
     def stopFlow(self):
         """
-        Record that the flow was stopped by setting C{flowIsStopped}.
+        Record that the flow was stopped by incrementing C{flowIsStopped}.
         """
-        self.flowIsStopped = True
+        self.flowIsStopped += 1
 
 
     def _actuallyPause(self):