Repository: agronholm/anyio

Issue:
TCP listener handler does not disconnect on EOF (netcat -N)
### Things to check first

- [X] I have searched the existing issues and didn't find my bug already reported there

- [X] I have checked that my bug is still present in the latest release


### AnyIO version

4.3.0

### Python version

3.11.8

### What happened?

While trying to implement a Python TCP receiver for a line based protocol using anyio, I have issues closing the stream with an EOF on the client side with netcat. This works with a pure Python synchronous implementation. The code uses the anyio backend. 

### How can we reproduce the bug?

```python
import anyio
from anyio.streams.text import TextReceiveStream

async def read_lines(port: int):
    """Read lines from TCP socket and output."""
    
    # FIXME: GNU netcat does not deterministically shutdown after sending EOF (-N option), one has to use -w 0 to close on idle
    async def handle(client):
        async with client:
            receive_stream = TextReceiveStream(client)
            async for chunk in receive_stream:
                for line in chunk.splitlines():
                    print(f"Line: {line.rstrip()}")
    
    listener = await anyio.create_tcp_listener(local_port=port)
    await listener.serve(handle)


anyio.run(read_lines, 1234)
```

Now, the following command hangs on most, but not all executions (it might have to do with buffering):
`echo 'hello world' | netcat -N localhost 1234`

The following works, but uses an idle workaround
`echo 'hello world' | netcat -N -w 0 localhost 1234`

Discussion:

Patch:
diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst
index c813367..f07c289 100644
--- a/docs/versionhistory.rst
+++ b/docs/versionhistory.rst
@@ -9,6 +9,8 @@ This library adheres to `Semantic Versioning 2.0 <http://semver.org/>`_.
   ``KeyError``
 - Fixed the asyncio backend not respecting the ``PYTHONASYNCIODEBUG`` environment
   variable when setting the ``debug`` flag in ``anyio.run()``
+- Fixed ``SocketStream.receive()`` not detecting EOF on asyncio if there is also data in
+  the read buffer (`#701 <https://github.com/agronholm/anyio/issues/701>`_)
 
 **4.3.0**
 
diff --git a/src/anyio/_backends/_asyncio.py b/src/anyio/_backends/_asyncio.py
index dcf48cc..d64e553 100644
--- a/src/anyio/_backends/_asyncio.py
+++ b/src/anyio/_backends/_asyncio.py
@@ -1047,6 +1047,7 @@ class StreamProtocol(asyncio.Protocol):
     read_event: asyncio.Event
     write_event: asyncio.Event
     exception: Exception | None = None
+    is_at_eof: bool = False
 
     def connection_made(self, transport: asyncio.BaseTransport) -> None:
         self.read_queue = deque()
@@ -1068,6 +1069,7 @@ class StreamProtocol(asyncio.Protocol):
         self.read_event.set()
 
     def eof_received(self) -> bool | None:
+        self.is_at_eof = True
         self.read_event.set()
         return True
 
@@ -1123,15 +1125,16 @@ class SocketStream(abc.SocketStream):
 
     async def receive(self, max_bytes: int = 65536) -> bytes:
         with self._receive_guard:
-            await AsyncIOBackend.checkpoint()
-
             if (
                 not self._protocol.read_event.is_set()
                 and not self._transport.is_closing()
+                and not self._protocol.is_at_eof
             ):
                 self._transport.resume_reading()
                 await self._protocol.read_event.wait()
                 self._transport.pause_reading()
+            else:
+                await AsyncIOBackend.checkpoint()
 
             try:
                 chunk = self._protocol.read_queue.popleft()


Test patch:
diff --git a/tests/test_sockets.py b/tests/test_sockets.py
index a35b500..43c7058 100644
--- a/tests/test_sockets.py
+++ b/tests/test_sockets.py
@@ -28,6 +28,7 @@ from anyio import (
     BrokenResourceError,
     BusyResourceError,
     ClosedResourceError,
+    EndOfStream,
     Event,
     TypedAttributeLookupError,
     connect_tcp,
@@ -681,6 +682,29 @@ class TestTCPListener:
 
             tg.cancel_scope.cancel()
 
+    async def test_eof_after_send(self, family: AnyIPAddressFamily) -> None:
+        """Regression test for #701."""
+        received_bytes = b""
+
+        async def handle(stream: SocketStream) -> None:
+            nonlocal received_bytes
+            async with stream:
+                received_bytes = await stream.receive()
+                with pytest.raises(EndOfStream), fail_after(1):
+                    await stream.receive()
+
+            tg.cancel_scope.cancel()
+
+        multi = await create_tcp_listener(family=family, local_host="localhost")
+        async with multi, create_task_group() as tg:
+            with socket.socket(family) as client:
+                client.connect(multi.extra(SocketAttribute.local_address))
+                client.send(b"Hello")
+                client.shutdown(socket.SHUT_WR)
+                await multi.serve(handle)
+
+        assert received_bytes == b"Hello"
+
     @skip_ipv6_mark
     @pytest.mark.skipif(
         sys.platform == "win32",