New GitHub issue #118950 from cjavad:<br>
<hr>
<pre>
### Bug description:
## TL;DR
`_SSLProtocolTransport.is_closing` should match its inner `_SelectorTransport.is_closing`, indicating to the user that the transport is actually closed instead of silently logging an error.
## Description
I've been using the aio-libs library `aiohttp` in production together with its WebSocket client implementation, and found an interesting issue that sometimes occured on certain devices (Specifically M-series macbooks).
The logs i've been seeing looks something like this:
```
( Indication that we are sending messages over websocket successfully )
...
[2024-05-07 09:34:13,403] WARNING asyncio.write: socket.send() raised exception.
...
( Sucessfully sent a message over websocket )
...
[2024-05-07 09:34:13,553] WARNING asyncio.write: socket.send() raised exception.
( No more indication that we're sending or recieving messages over websocket )
```
Digging deeper the issue occurs when the connection has been lost due to an exception when invoking `socket.send`, this normally will result in the Transports `is_closing()` function returning `True`.
The issue occurs when using TLS, which now uses the transport `_SSLProtocolTransport` which implements its own `is_closing` logic.
When [`_SocketSelectorTransport.write`](https://github.com/python/cpython/blob/7e894c2f38f64aed9b259c8fd31880f1142a259d/Lib/asyncio/selector_events.py#L1073) gets an OSError such as `Broken Pipe` (which is the issue i've experienced in the wild) it sets its inner transport state as closed but when a library such as aiohttp checks its transport [`is_closing`](https://github.com/aio-libs/aiohttp/blob/eb432238ffaea0f435343913dcfb35f70379e3ce/aiohttp/http_websocket.py#L685C27-L685C37) it returns `False` leading to it silently assuming that it is still connected.
I've been able to recreate the flow by raising a different exception (by manually closing the socket) but the error source and flow is the same in both cases as far as i can tell.
## Full example (out of the box + SSL cert generation)
```python
import asyncio
import contextlib
import logging
import socket
import ssl
import subprocess
import tempfile
@contextlib.contextmanager
def server_ssl_context(host):
with tempfile.NamedTemporaryFile() as keyfile, tempfile.NamedTemporaryFile() as certfile:
subprocess.run([
'openssl', 'req', '-new', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:prime256v1',
'-keyout', keyfile.name, '-nodes', '-x509', '-days', '365', '-subj', f'/CN={host}',
'-out', certfile.name,
], check=True, shell=False)
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(certfile.name, keyfile.name)
yield context
@contextlib.contextmanager
def client_ssl_context():
try:
context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
yield context
finally:
pass
async def client_handle(reader, writer):
...
async def main(host, port):
with server_ssl_context(host) as server_context, client_ssl_context() as client_context:
await asyncio.start_server(client_handle, host, port, ssl=server_context)
reader, writer = await asyncio.open_connection(host, port, ssl=client_context)
transport = writer._transport
from asyncio.sslproto import _SSLProtocolTransport
assert isinstance(transport, _SSLProtocolTransport)
inner_transport = transport._ssl_protocol._transport
from asyncio.selector_events import _SelectorSocketTransport
assert isinstance(inner_transport, _SelectorSocketTransport)
sock: socket.socket = inner_transport._sock
assert isinstance(sock, socket.socket)
# Simulate a broken pipe, this invokes the OS error "Bad file descriptor"
# but triggers the same flow as "Broken Pipe"
sock.close()
# Invoke write so socket returns an OSError
print('[Client] Sending x6: %r' % 'Hello, world!')
# Increment _conn_lost to more than 5 to trigger the logging.
# This silently fails, but the logging is triggered.
for i in range(6):
writer.write('Hello, world!'.encode())
await writer.drain()
print(f"{inner_transport._conn_lost=}, {transport.is_closing()=}, {inner_transport.is_closing()=}")
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG)
asyncio.run(main('localhost', 8443), debug=True)
```
### CPython versions tested on:
3.12
### Operating systems tested on:
Linux, macOS
</pre>
<hr>
<a href="https://github.com/python/cpython/issues/118950">View on GitHub</a>
<p>Labels: type-bug</p>
<p>Assignee: </p>