Skip to content

start_tls() returns a closed transport instead of raising when the client disconnects before the handshake (for eager tasks) #784

Open

Description

@chrisjbillington

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

  1. hamarshehmhmd commented on Oct 31, 2026

    @hamarshehmhmd

    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 from connection_made().

    UVBaseTransport._call_connection_made (uvloop/handles/basetransport.pyx, ~L120-154) calls protocol.connection_made(self) first and only then calls self._start_reading(). During connection_made the stream isn't reading yet, so pause_reading() (handles/stream.pyx L717) sees not self._is_reading() and does nothing. Reading then starts anyway. You can see it without TLS:

    class P(asyncio.Protocol):
        def connection_made(self, t): t.pause_reading()
        def data_received(self, d): print("got", d)   # uvloop prints this, asyncio doesn't

    With an eager task factory, the start_server callback runs inside connection_made, because StreamReaderProtocol.connection_made creates the task and it starts eagerly. start_tls() (loop.pyx L1618) calls pause_reading(), which is a no-op here, and swaps in the SSLProtocol. The transport then starts reading, sees EOF, and closes before SSLProtocol.connection_made runs. SSLProtocol.connection_lost(None) is reached in the UNWRAPPED state and resolves the waiter with None, so start_tls returns 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 from connection_made now actually applies:

    --- a/uvloop/handles/basetransport.pyx
    +++ b/uvloop/handles/basetransport.pyx
    @@ -138,9 +138,13 @@
             self._protocol_connected = 1
     
    +        # Start reading before connection_made() so a pause_reading()
    +        # issued from it (e.g. start_tls() in an eager task) is honoured.
    +        self._start_reading()
    +
             try:
                 self._protocol.connection_made(self)
             except BaseException:
    +            if self._is_alive():
    +                self._stop_reading()
                 self._wakeup_waiter()
                 raise
    @@ -150,7 +154,6 @@
                 return
     
    -        self._start_reading()
             self._wakeup_waiter()

    Tested: I rebuilt 0.23.0 with Cython (regenerated loop.c) and applied the patch. Your script now prints start_tls raised ConnectionResetError in 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_context and test_sockets: 316 passed / 28 skipped, the same as an unpatched build. basetransport.pyx is identical on master.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions