Repository navigation
asyncio.sslproto._SSLProtocolTransport can experience invalid state, leading to silent failures. #118950
Description
Bug report
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 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 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)
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
Linked PRs
- gh-118950: Fix SSLProtocol.connection_lost not being called when OSError is thrown on asyncio.write. #118960
- [3.13] gh-118950: Fix SSLProtocol.connection_lost not being called when OSError is thrown (GH-118960) #125931
- [3.12] gh-118950: Fix SSLProtocol.connection_lost not being called when OSError is thrown (GH-118960) #125932
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsDone
Activity
cjavad commentedon May 11, 2024
The current "mitigation" i've implemented looks something like this:
I am not sure what is intended when handling the error, it seems like it should propegate the issue with the connection_lost callback on the protocol, but that seems to not be the SSLProtocol class, and how the SSL transport should deal with a broken pipe for instance is beyond me at this time, hence the issue and not a pull request.
[-]asyncio.sslproto._SSLProtocolTransport can experience invalid state, leading to silent failures.[/-][+]`asyncio.sslproto._SSLProtocolTransport` can experience invalid state, leading to silent failures.[/+][-]`asyncio.sslproto._SSLProtocolTransport` can experience invalid state, leading to silent failures.[/-][+]asyncio.sslproto._SSLProtocolTransport can experience invalid state, leading to silent failures.[/+][-]asyncio.sslproto._SSLProtocolTransport can experience invalid state, leading to silent failures.[/-][+]`asyncio.sslproto._SSLProtocolTransport` can experience invalid state, leading to silent failures.[/+]gvanrossum commentedon May 11, 2024
Hi Javad, thanks for the detailed bug report, and especially for providing a stand-alone repro. Unfortunately SSL support in asyncio is an esoteric area where I don't seem to have much luck finding experts. So it may be some time before we have a fix. If you come up with a PR, don't be shy, and ping me directly on the PR.
cjavad commentedon May 12, 2024
After some further investigation the code is technically sane, the connection_lost does end up setting the top level transport as closed when the next async context switch happens and it is allowed to run the connection_lost callback, simply adding a
await asyncio.sleep(0)before my last prints resolves the issue. Something in the end library application code must be interfering with this, or some other condition is required to be met preventing the connection_lost function from working as intended.Here is another example, using the
aiohttplibrary directly, the difference maker between the issue here and it working as intended is theawait asyncio.sleep(0)I can see a similar approach is used in
asyncio/streams.pywith a comment:So something similar to this might be required. This logic is only triggered when the top transport is
is_closingso it would require it to already be marked as closed before it would wait and run the function that sets it to close. So either it would have to respect the inner transport in the SSL Transport, or this logic should also exist another place.pythongh-118950: Let _SSLProtocolTransport.is_closing reflect SSLProt…
pythongh-118950: Let _SSLProtocolTransport.is_closing reflect SSLProt…
kumaraditya303 commentedon May 29, 2024
Can you try to check if this issue only exists in asyncio or in uvloop too?
@cjavad
cjavad commentedon May 29, 2024
Hi @kumaraditya303, i should be able to test it.
Have you seen the suggested PR, iirc you are on as a reviewer.
cjavad commentedon May 31, 2024
Hi again @kumaraditya303, a similar issue also exists in uvloop, where it does not correctly handle the underlying socket being closed, i've generalized my first example to work in all 3 cases, libc only.
Assuming the previous file holds as
issue_118950.pywe can also write the same test with aiohttp, and the same fix applies to all of it.Enforce using ASYNCIO for the websocket connection, as uvloop is bugg…
Add test for pythongh-118950
Address python/cpython#118950 in uvloop by porting fix and adding tes…
Address python/cpython#118950 in uvloop by porting fix and adding tes…
gh-118950: Fix SSLProtocol.connection_lost not being called when OSEr…
pythongh-118950: Fix SSLProtocol.connection_lost not being called whe…
pythongh-118950: Fix SSLProtocol.connection_lost not being called whe…
[3.13] gh-118950: Fix SSLProtocol.connection_lost not being called wh…
[3.12] gh-118950: Fix SSLProtocol.connection_lost not being called wh…
cjavad commentedon Oct 26, 2024
Thank you for the swift work @kumaraditya303 😄
pythongh-118950: Fix SSLProtocol.connection_lost not being called whe…