start_tls() returns a closed transport instead of raising when the client disconnects before the handshake (for eager tasks) #784
Description
Arch Linux, Python 3.14.8, uvloop 0.23.0
When using an eager task factory, if a client closes the connection before the TLS handshake, the server's StreamWriter.start_tls returns normally with a closed transport, and get_extra_info('socket') is None.
With plain asyncio, or with uvloop without eager tasks, start_tls raises ConnectionResetError, so uvloop should presumably do the same.
Here's a reproduction script for (disclosure, AI-generated, but I've run it and it does demonstrate the issue). This is for Python 3.13+ as it requires PSK functions in the ssl module, but the issue also occurs on Python 3.12 using a certificate instead of a PSK, so it's not PSK-specific.
(note this reproducer also contains a workaround for #779, otherwise one can't actually enable eager tasks at all)
import asyncio
import socket
import ssl
import sys
import uvloop
def eager_task_factory(loop, coro, *, eager_start=None, **kwargs):
# uvloop passes eager_start=None, which asyncio.eager_task_factory treats as False
if sys.version_info >= (3, 14):
return asyncio.Task(coro, loop=loop, eager_start=True, **kwargs)
return asyncio.eager_task_factory(loop, coro, **kwargs)
async def main(eager):
if eager:
asyncio.get_running_loop().set_task_factory(eager_task_factory)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_3
context.set_psk_server_callback(lambda identity: b"k" * 32)
result = asyncio.get_running_loop().create_future()
async def on_connection(reader, writer):
try:
await writer.start_tls(context)
except Exception as e:
result.set_result(f"start_tls raised {type(e).__name__}")
else:
result.set_result(
f"start_tls returned, is_closing() is {writer.is_closing()}, "
f"get_extra_info('socket') is {writer.get_extra_info('socket')}"
)
server = await asyncio.start_server(on_connection, "127.0.0.1", 0)
# The client connects and closes the connection without sending anything:
socket.create_connection(server.sockets[0].getsockname()).close()
print(await asyncio.wait_for(result, 5))
server.close()
print(f"Python {sys.version.split()[0]}, uvloop {uvloop.__version__}")
for name, loop_factory in [
("asyncio", asyncio.new_event_loop),
("uvloop", uvloop.new_event_loop),
]:
for eager in [False, True]:
print(f"{name}, eager tasks {eager}: ", end="")
asyncio.run(main(eager), loop_factory=loop_factory)On my system:
Python 3.14.8, uvloop 0.23.0
asyncio, eager tasks False: start_tls raised ConnectionResetError
asyncio, eager tasks True: start_tls raised ConnectionResetError
uvloop, eager tasks False: start_tls raised ConnectionResetError
uvloop, eager tasks True: start_tls returned, is_closing() is True, get_extra_info('socket') is None
Activity
hamarshehmhmd commentedon Oct 31, 2026
I reproduced this with the same output as yours (Python 3.14.9, uvloop 0.23.0, built from source). The underlying bug isn't really TLS-specific: uvloop ignores
transport.pause_reading()when it's called fromconnection_made().UVBaseTransport._call_connection_made(uvloop/handles/basetransport.pyx, ~L120-154) callsprotocol.connection_made(self)first and only then callsself._start_reading(). Duringconnection_madethe stream isn't reading yet, sopause_reading()(handles/stream.pyxL717) seesnot self._is_reading()and does nothing. Reading then starts anyway. You can see it without TLS:With an eager task factory, the
start_servercallback runs insideconnection_made, becauseStreamReaderProtocol.connection_madecreates the task and it starts eagerly.start_tls()(loop.pyxL1618) callspause_reading(), which is a no-op here, and swaps in theSSLProtocol. The transport then starts reading, sees EOF, and closes beforeSSLProtocol.connection_maderuns.SSLProtocol.connection_lost(None)is reached in the UNWRAPPED state and resolves the waiter withNone, sostart_tlsreturns a dead transport.Fix: start reading before calling
connection_made. No read callback can fire until control returns to the loop, so the ordering stays the same, and a pause fromconnection_madenow actually applies:Tested: I rebuilt 0.23.0 with Cython (regenerated
loop.c) and applied the patch. Your script now printsstart_tls raised ConnectionResetErrorin all four configurations, and the protocol snippet above no longer receives data while paused.test_tcp,test_unix,test_pipes,test_udp,test_base,test_contextandtest_sockets: 316 passed / 28 skipped, the same as an unpatched build.basetransport.pyxis identical on master.