Repository: aio-libs/aiohttp

Issue:
StreamResponse write() does not raise an exception if client disconnects
### Describe the bug

Hi!

I have an application that uses a `StreamResponse` to continuously write data to the client by calling the `write()` method. The server stops sending data when the client disconnects. In version 3.8.1 the `write()` method raises an exception when the client disconnects, but in later version it does not. The commit that introduces the bug is 20c93656d18b50c17c85b7a1e234276fb1444f27.

I've tested on Python 3.7, 3.8 and 3.10.

### To Reproduce

Add later if really needed.

### Expected behavior

An exception is raised when the client disconnects.

### Logs/tracebacks

```python-traceback
-
```


### Python Version

```console
$ python --version
Python 3.10.7
```


### aiohttp Version

```console
$ python -m pip show aiohttp
Name: aiohttp
Version: 3.8.3
...
```


### multidict Version

```console
-
```


### yarl Version

```console
-
```


### OS

Linux

### Related component

Server

### Additional context

_No response_

### Code of Conduct

- [X] I agree to follow the aio-libs Code of Conduct


Discussion:
What exception are you expecting? That code only removes the task cancellation. So, if you're expecting a CancelledError, then yes, that's the expected behaviour, it won't be cancelled anymore.

There was some confusion with the change being made in 3.7, but accidentally getting reverted somewhere. In 3.9 the behaviour is configurable. You can see the old and new behaviour (and how to configure them in 3.9) documented at:
https://docs.aiohttp.org/en/latest/web_advanced.html#peer-disconnection

The behaviour is properly tested now, so mistaken changes shouldn't happen again.
Further testing shows that the problem only exists when using SSL/TLS. Without `ssl_context` the `ConnectionResetError` exception is raised when the client disconnects, which is what I expect to happen when using SSL/TLS as well.

Here is a program that has the problem:

```python
import ssl
import asyncio
from aiohttp import web


async def handle_stream(request):
    print('request')
    response = web.StreamResponse()
    await response.prepare(request)

    try:
        while True:
            await response.write(b"1")
            await asyncio.sleep(0.1)
    finally:
        print('done')


app = web.Application()
app.add_routes([web.get('/', handle_stream)])

ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_context.load_cert_chain('cert.pem', 'key.pem')
web.run_app(app, ssl_context=ssl_context)

# web.run_app(app)
```

Run it in one terminal. In another terminal, run `wget --no-check-certificate https://localhost:8443` and then press CTRL-C disconnect the client. The `ConnectionResetError` exception is *not* raised.

Create `cert.pem` and `key.pem` with:

```
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=example.com"
```
OK, that's definitely a problem. Will try to find some time soon to take a look, thanks for the reproducer.
Although we generally recommend deploying behind a proxy, so we expect most installs to not be using TLS on their application.
This report explains why we had users run into this, but I was unable to reproduce it locally. Some of our users use SSL, others don't. Thanks for the ping @Dreamsorcerer 
Don't have time to look properly yet, but I think the issue may be in asyncio.

Where the exception should be raised:
https://github.com/aio-libs/aiohttp/blob/master/aiohttp/http_writer.py#L75

The http transport is _SelectorSocketTransport and .is_closing() returns `True` when the connection is severed:
https://github.com/python/cpython/blob/main/Lib/asyncio/selector_events.py#L907
The https transport is _SSLProtocolTransport and .is_closing() continues to return `False` after the connection is severed:
https://github.com/python/cpython/blob/main/Lib/asyncio/sslproto.py#L78

It's not obvious to me why the SSL one is not marked as  closing.

Resolving patch:
diff --git a/aiohttp/base_protocol.py b/aiohttp/base_protocol.py
--- a/aiohttp/base_protocol.py
+++ b/aiohttp/base_protocol.py
@@ -18,11 +18,15 @@ def __init__(self, loop: asyncio.AbstractEventLoop) -> None:
         self._loop: asyncio.AbstractEventLoop = loop
         self._paused = False
         self._drain_waiter: Optional[asyncio.Future[None]] = None
-        self._connection_lost = False
         self._reading_paused = False
 
         self.transport: Optional[asyncio.Transport] = None
 
+    @property
+    def connected(self) -> bool:
+        """Return True if the connection is open."""
+        return self.transport is not None
+
     def pause_writing(self) -> None:
         assert not self._paused
         self._paused = True
@@ -59,7 +63,6 @@ def connection_made(self, transport: asyncio.BaseTransport) -> None:
         self.transport = tr
 
     def connection_lost(self, exc: Optional[BaseException]) -> None:
-        self._connection_lost = True
         # Wake up the writer if currently paused.
         self.transport = None
         if not self._paused:
@@ -76,7 +79,7 @@ def connection_lost(self, exc: Optional[BaseException]) -> None:
             waiter.set_exception(exc)
 
     async def _drain_helper(self) -> None:
-        if self._connection_lost:
+        if not self.connected:
             raise ConnectionResetError("Connection lost")
         if not self._paused:
             return
diff --git a/aiohttp/http_writer.py b/aiohttp/http_writer.py
--- a/aiohttp/http_writer.py
+++ b/aiohttp/http_writer.py
@@ -35,7 +35,6 @@ def __init__(
         on_headers_sent: _T_OnHeadersSent = None,
     ) -> None:
         self._protocol = protocol
-        self._transport = protocol.transport
 
         self.loop = loop
         self.length = None
@@ -52,7 +51,7 @@ def __init__(
 
     @property
     def transport(self) -> Optional[asyncio.Transport]:
-        return self._transport
+        return self._protocol.transport
 
     @property
     def protocol(self) -> BaseProtocol:
@@ -71,10 +70,10 @@ def _write(self, chunk: bytes) -> None:
         size = len(chunk)
         self.buffer_size += size
         self.output_size += size
-
-        if self._transport is None or self._transport.is_closing():
+        transport = self.transport
+        if not self._protocol.connected or transport is None or transport.is_closing():
             raise ConnectionResetError("Cannot write to closing transport")
-        self._transport.write(chunk)
+        transport.write(chunk)
 
     async def write(
         self, chunk: bytes, *, drain: bool = True, LIMIT: int = 0x10000
@@ -159,7 +158,6 @@ async def write_eof(self, chunk: bytes = b"") -> None:
         await self.drain()
 
         self._eof = True
-        self._transport = None
 
     async def drain(self) -> None:
         """Flush the write buffer.


Test patch:
diff --git a/tests/test_base_protocol.py b/tests/test_base_protocol.py
--- a/tests/test_base_protocol.py
+++ b/tests/test_base_protocol.py
@@ -45,10 +45,10 @@ async def test_connection_lost_not_paused() -> None:
     pr = BaseProtocol(loop=loop)
     tr = mock.Mock()
     pr.connection_made(tr)
-    assert not pr._connection_lost
+    assert pr.connected
     pr.connection_lost(None)
     assert pr.transport is None
-    assert pr._connection_lost
+    assert not pr.connected
 
 
 async def test_connection_lost_paused_without_waiter() -> None:
@@ -56,11 +56,11 @@ async def test_connection_lost_paused_without_waiter() -> None:
     pr = BaseProtocol(loop=loop)
     tr = mock.Mock()
     pr.connection_made(tr)
-    assert not pr._connection_lost
+    assert pr.connected
     pr.pause_writing()
     pr.connection_lost(None)
     assert pr.transport is None
-    assert pr._connection_lost
+    assert not pr.connected
 
 
 async def test_drain_lost() -> None:
diff --git a/tests/test_client_proto.py b/tests/test_client_proto.py
--- a/tests/test_client_proto.py
+++ b/tests/test_client_proto.py
@@ -136,3 +136,17 @@ async def test_eof_received(loop: Any) -> None:
     assert proto._read_timeout_handle is not None
     proto.eof_received()
     assert proto._read_timeout_handle is None
+
+
+async def test_connection_lost_sets_transport_to_none(loop: Any, mocker: Any) -> None:
+    """Ensure that the transport is set to None when the connection is lost.
+
+    This ensures the writer knows that the connection is closed.
+    """
+    proto = ResponseHandler(loop=loop)
+    proto.connection_made(mocker.Mock())
+    assert proto.transport is not None
+
+    proto.connection_lost(OSError())
+
+    assert proto.transport is None
diff --git a/tests/test_http_writer.py b/tests/test_http_writer.py
--- a/tests/test_http_writer.py
+++ b/tests/test_http_writer.py
@@ -262,6 +262,23 @@ async def test_write_to_closing_transport(
         await msg.write(b"After closing")
 
 
+async def test_write_to_closed_transport(
+    protocol: Any, transport: Any, loop: Any
+) -> None:
+    """Test that writing to a closed transport raises ConnectionResetError.
+
+    The StreamWriter checks to see if protocol.transport is None before
+    writing to the transport. If it is None, it raises ConnectionResetError.
+    """
+    msg = http.StreamWriter(protocol, loop)
+
+    await msg.write(b"Before transport close")
+    protocol.transport = None
+
+    with pytest.raises(ConnectionResetError, match="Cannot write to closing transport"):
+        await msg.write(b"After transport closed")
+
+
 async def test_drain(protocol: Any, transport: Any, loop: Any) -> None:
     msg = http.StreamWriter(protocol, loop)
     await msg.drain()