Skip to content

asyncio: after an SSL connection is lost, transport.close() leaves a transport whose write(), set_protocol(), … raise AttributeError #159875

Open

Description

@cornopaez

Bug description

When the connection under an SSL transport is lost, and the application then
closes the transport (typically from its protocol's connection_lost()), the
transport is left without its SSL protocol. After that, every method that
reaches the SSL protocol raises AttributeError, where a closed TCP
transport drops the data or answers quietly:

A server whose protocol, in connection_lost(), calls close() and then
write(); the client sends a few bytes and resets (needs openssl for a
throwaway certificate):

repro_write_after_close.py
"""After an SSL connection is lost, close() then write(): AttributeError, or a quiet drop?

Control: the same on a plain TCP transport.
"""
import asyncio, os, socket, ssl, struct, subprocess, sys, tempfile

d = tempfile.mkdtemp()
cert, key = os.path.join(d, "c.pem"), os.path.join(d, "k.pem")
subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1",
                "-subj", "/CN=localhost", "-keyout", key, "-out", cert], check=True, capture_output=True)

async def trial(use_ssl: bool) -> str:
    loop = asyncio.get_running_loop()
    lost = loop.create_future()
    result = loop.create_future()

    class P(asyncio.Protocol):
        def connection_made(self, t): self.t = t
        def data_received(self, data): pass
        def connection_lost(self, exc):
            self.t.close()                      # what aiohttp's connection_lost does
            try:
                self.t.write(b"after close")    # documented: dropped (with a warning past a threshold)
                n = self.t.get_write_buffer_size()
                result.set_result(f"write() and get_write_buffer_size() ok ({n})")
            except Exception as e:
                result.set_result(f"{type(e).__name__}: {e}")
            lost.set_result(None)

    sctx = None
    if use_ssl:
        sctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER); sctx.load_cert_chain(cert, key)
    server = await loop.create_server(P, "127.0.0.1", 0, ssl=sctx)
    port = server.sockets[0].getsockname()[1]

    def client():
        raw = socket.create_connection(("127.0.0.1", port))
        raw.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
        s = raw
        if use_ssl:
            c = ssl.create_default_context(); c.check_hostname = False; c.verify_mode = ssl.CERT_NONE
            s = c.wrap_socket(raw, server_hostname="localhost")
        s.sendall(b"hello")
        s.close()  # RST, no close_notify
    await loop.run_in_executor(None, client)
    await asyncio.wait_for(lost, 5)
    server.close(); await server.wait_closed()
    return result.result()

async def main():
    print(sys.version.split()[0])
    print("  plain TCP:", await trial(False))
    print("  SSL      :", await trial(True))

asyncio.run(main())
3.16.0a1+dev
  plain TCP: write() and get_write_buffer_size() ok (0)
  SSL      : AttributeError: 'NoneType' object has no attribute '_write_appdata'

The same on 3.14.7 and 3.12.13. The plain-TCP transport is the control: same
script, ssl=None.

Why

  1. SSLProtocol.connection_lost() marks the app transport closed
    (self._app_transport._closed = True, "so that its __dealloc__ doesn't
    complain"), without a close() having been called.
  2. The application calls transport.close().
  3. _SSLProtocolTransport.close() finds _closed already set and takes the
    branch meant for a second close(): self._ssl_protocol = None.
  4. write(), set_protocol(), get_protocol(), resume_reading(),
    get_write_buffer_size(), … all dereference self._ssl_protocol.

The else: self._ssl_protocol = None came with the new SSL implementation
(bpo-44011, 13c10bf, 3.11). 3.10's close() did not have it.

Where it shows: loop.sendfile() over SSL

loop.sendfile() on an SSL transport always takes _sendfile_fallback(),
whose finally awaits _SendfileFallbackProtocol.restore(), and that calls
set_protocol(). If the peer resets mid-transfer and the application closes
the transport in connection_lost(), the caller gets:

  File ".../asyncio/base_events.py", line 1328, in _sendfile_fallback
    await proto.restore()
  File ".../asyncio/base_events.py", line 266, in restore
    self._transport.set_protocol(self._proto)
  File ".../asyncio/sslproto.py", line 98, in set_protocol
    self._ssl_protocol._set_app_protocol(protocol)
AttributeError: 'NoneType' object has no attribute '_set_app_protocol'

instead of the ConnectionError (here a BrokenPipeError). It is a race,
decided by where the fallback is suspended when the reset arrives: if it
wakes from its file read in the executor before the application's
connection_lost() has run, drain() raises ConnectionError and
restore() still finds the protocol. A standalone script (an 8 MiB file
over TLS, a client that resets after 256 KiB) lost the race in 200 of 200
trials here on 3.14.7, so it is no use as a reproducer; the sendfile test below
removes the race and fails in 60 of 60 runs. The fallback's loop can also
meet it at transp.write() instead.

This is how aiohttp's FileResponse meets it over HTTPS (aiohttp closes the
transport in connection_lost() and, with handler_cancellation=True,
cancels the handler). It is logged as Unhandled exception. In Home
Assistant's tracker the _set_app_protocol form is home-assistant/core#176462 (3.14) and the
readinto form is home-assistant/core#132305 (3.13). On 3.8–3.10 the same scenario surfaced as
'NoneType' object has no attribute 'resume_reading' (home-assistant/core#45641, 2021), from
the old implementation's inner transport. None of them reached this tracker.

What the else branch is for

Removing the branch fixes both reproducers, and breaks
test_asyncio.test_ssl.TestSSL.test_create_connection_memory_leak. That test
closes the client transport after the server's TLS shutdown (the same path:
_closed already set), and its comment states the intent: no garbage is
left for the client's SSLContext "even if user stores the SSLTransport in
corresponding protocol instance". Dropping _ssl_protocol is what cuts the
transport's path to the context. So the release is intended, and a fix has
to keep it.

The test does not see the whole of it, though. With the branch removed, it
fails only because the context is freed one loop iteration late: at the
check, the loop's ready queue still holds the application protocol's
connection_lost() (scheduled by SSLProtocol.connection_lost()), which
holds the protocol, and through proto.tr the transport. Run one more
iteration and it passes, with the cyclic GC disabled too. What the test
cannot see is the case its comment names: an application that keeps the
transport. A probe that does (probe_extra_info.py keep: a reset
connection, the client's protocol closing the transport in
connection_lost(), the transport held afterwards) shows the context freed
on main, and kept alive with the branch merely removed.

probe_extra_info.py (run with a PEM holding cert and key, e.g. Lib/test/certdata/keycert.pem, and optionally keep)
"""What does an application see in its own connection_lost() on an SSL transport?

The server resets the connection (SO_LINGER 0); the client's protocol records
get_extra_info() for the TLS keys, then calls close() and write(), as an
application tidying up would. Also checks whether the client SSLContext is
freed afterwards (weakref, refcount only).
"""

import asyncio
import os
import socket
import ssl
import struct
import sys
import weakref

CERT = sys.argv[1]  # a PEM holding cert and key, e.g. Lib/test/certdata/keycert.pem


class Client(asyncio.Protocol):
    def __init__(self, done):
        self.done = done
        self.seen = {}

    def connection_made(self, tr):
        self.tr = tr

    def connection_lost(self, exc):
        for k in ("sslcontext", "ssl_object", "peercert", "cipher", "compression"):
            v = self.tr.get_extra_info(k)
            self.seen[k] = type(v).__name__ if v is not None else None
        try:
            self.tr.close()
            self.tr.write(b"late")
            self.seen["close+write"] = "ok"
        except Exception as e:  # noqa: BLE001
            self.seen["close+write"] = f"{type(e).__name__}: {e}"
        self.done.set_result(None)


async def main():
    sctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
    sctx.load_cert_chain(CERT)

    async def handle(reader, writer):
        await reader.read(1)
        sock = writer.get_extra_info("socket")
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0))
        writer.transport.abort()

    server = await asyncio.start_server(handle, "127.0.0.1", 0, ssl=sctx)
    port = server.sockets[0].getsockname()[1]

    cctx = ssl.create_default_context()
    cctx.check_hostname = False
    cctx.verify_mode = ssl.CERT_NONE
    ref = weakref.ref(cctx)
    loop = asyncio.get_running_loop()
    done = loop.create_future()
    tr, proto = await loop.create_connection(lambda: Client(done), "127.0.0.1", port, ssl=cctx)
    tr.write(b"x")
    await done
    seen = proto.seen
    keep = sys.argv[2:] == ['keep']
    if keep:
        held = tr  # an application that keeps the transport after the loss
    del tr, proto, cctx
    await asyncio.sleep(0.1)
    server.close()
    await server.wait_closed()
    print("in app connection_lost:", seen)
    print("client SSLContext freed afterwards:", ref() is None, "(transport still held)" if keep else "")


asyncio.run(main())

Proposed fix: keep the protocol, release the TLS state after the application is told

Keep _ssl_protocol on the transport, so that a transport closed after a
lost connection behaves as a closed one (write() drops the data,
set_protocol() works, the buffer sizes read 0). Make SSLProtocol drop
what refers to the context instead (_sslobj, _sslcontext, and the
sslcontext and ssl_object entries of _extra), once the connection is
lost, and after the application's connection_lost() has run, so that
it can still read get_extra_info('ssl_object') there. peercert, cipher
and compression are plain data and stay.

The change (sslproto.py, +16 −2)
@@ -114,8 +114,6 @@ def close(self):
         if not self._closed:
             self._closed = True
             self._ssl_protocol._start_shutdown()
-        else:
-            self._ssl_protocol = None
 
     def __del__(self, _warnings=warnings):
         if not self._closed:
@@ -406,6 +404,7 @@ def connection_lost(self, exc):
         if self._app_transport is not None:
             self._app_transport._closed = True
 
+        app_notified = False
         if self._state != SSLProtocolState.DO_HANDSHAKE:
             if (
                 self._app_state == AppProtocolState.STATE_CON_MADE or
@@ -413,6 +412,7 @@ def connection_lost(self, exc):
             ):
                 self._app_state = AppProtocolState.STATE_CON_LOST
                 self._loop.call_soon(self._app_protocol.connection_lost, exc)
+                app_notified = True
         self._set_state(SSLProtocolState.UNWRAPPED)
         self._transport = None
         self._app_transport = None
@@ -425,6 +425,21 @@ def connection_lost(self, exc):
         if self._handshake_timeout_handle:
             self._handshake_timeout_handle.cancel()
             self._handshake_timeout_handle = None
+        if app_notified:
+            # After the app protocol's connection_lost() (scheduled above),
+            # so that it can still read the TLS extra info.
+            self._loop.call_soon(self._release_tls_state)
+        else:
+            self._release_tls_state()
+
+    def _release_tls_state(self):
+        # The connection is gone: drop everything that refers to the
+        # SSLContext, so that an application holding the transport does
+        # not keep the context alive.
+        self._sslobj = None
+        self._sslcontext = None
+        self._extra.pop('sslcontext', None)
+        self._extra.pop('ssl_object', None)
 
     def get_buffer(self, n):
         want = n

The release is immediate when no connection_lost() was scheduled (the
handshake never completed). Releasing one iteration later there too breaks
test_ssl's test_handshake_timeout_handler_leak and test_sslproto's
test_handshake_timeout (tried).

Like removing the branch, this frees the context one iteration after the
application is told rather than before, so test_create_connection_memory_leak
as written fails. The PR would change that test to check what its comment
says: keep the transport past the check, and let the loop run the pending
connection_lost() first (+6 −1). The two versions of the test, 3 runs each
per variant:

sslproto.py the test as on main the test holding the transport
main pass pass
the else removed only fail fail (the context kept alive)
proposed fail (one iteration late) pass
alternative 2 below pass pass

With the proposed change and the three tests below, the whole test_asyncio
suite passes (2,963 run, 73 skipped).

Alternatives considered

  1. A released transport behaves as a closed one. Keep dropping
    _ssl_protocol in close(), and give each method that reaches it a
    closed answer when it is None (write() drops the data,
    set_protocol() and get_protocol() keep a local reference, the buffer
    sizes read 0, is_reading() False, …). It keeps today's memory behaviour
    exactly and needs no test change, but touches about a dozen methods, and
    each new one has to remember the None case. Not implemented.
  2. Release the TLS state at once, at the end of
    SSLProtocol.connection_lost(). It fixes both reproducers, frees the
    context with the transport held, and the whole test_asyncio suite passes
    with no test change. But the application's connection_lost() then finds
    get_extra_info('ssl_object') and ('sslcontext') already None, where
    today it gets the objects (probe), which is a visible change for code that
    logs or inspects the session as it closes.
  3. Guard only the sendfile() fallback (restore() skipping a closing
    transport). Not enough: tried, and the fallback's loop then fails at
    transp.write(), and an application's own write() stays broken.

I'd suggest the proposed fix, as the only one that keeps both the memory
behaviour and what an application sees in connection_lost(), at the cost
of one test that checks a timing rather than what it is named for. I can
send a PR for it, or for 1 or 2 if either is preferred.

Tests (written, failing on main for this reason)

  • test_sslproto.SslProtoHandshakeTests.test_transport_usable_after_connection_lost_and_close:
    connection_lost(), then close(), then write(), get_write_buffer_size(),
    set_protocol() and abort() must not raise;
  • test_sendfile.SendfileMixin.test_sendfile_ssl_close_peer_in_the_middle_closes_transport:
    the client protocol closes its transport in connection_lost(); sendfile()
    must raise ConnectionError. It reads the file synchronously, so that the
    fallback waits only in drain(): a read finishing in the same loop
    iteration as the reset otherwise lets the fallback return before close()
    (6 of 60 runs passed on main that way). As written, 60 of 60 runs fail on
    main with the set_protocol() traceback above, on the kqueue, poll and
    select loops, and 60 of 60 pass with the proposed fix;
  • test_ssl.TestSSL.test_create_connection_memory_leak, changed as above.

AI disclosure

This report, its reproducers and its tests were prepared with the help of
Claude Opus 5.5 (Anthropic), working with me on my own project. It found the
defect while testing that project's HTTPS API, wrote the reproducers, the
tests and the probe, traced the mechanism through sslproto.py and
base_events.py, and implemented and ran the changes discussed here:
removing the else branch, guarding only the sendfile() fallback
(alternative 3), alternative 2, the proposed fix, and a variant of it that
always releases one iteration later (it broke the two handshake-timeout
tests named above). Alternative 1 has not been implemented. The numbers here
come from runs on my machine. I have reviewed the report and the tests, and
I'm responsible for their content.

CPython versions tested on

3.12, 3.14, CPython main branch

Operating systems tested on

macOS (Home Assistant's reports are from Linux)

Linked PRs

Activity

  1. added
    type-bugAn unexpected behavior, bug, or error
    stdlibStandard Library Python modules in the Lib/ directory
    on Oct 20, 2026
  2. junaidkhan80121 commented on Oct 21, 2026

    @junaidkhan80121

    Hi @cornopaez, thanks for the detailed write-up and proposed fix.

    I’ve implemented that approach locally (sslproto change + the regression / memory-leak / sendfile tests you outlined) and the related test_asyncio cases pass on a Windows build of main.

    Happy to open a PR if you’d rather not take it, otherwise I’ll leave it to you. Just say which you prefer

  3. cornopaez commented on Oct 21, 2026

    @cornopaez
    Author

    Hi @junaidkhan80121.

    I'll open the PR myself and see what comes of it. I appreciate your test in a Windows box -- that was something I could not do myself without creating a temp VM to try things out.

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

    stdlibStandard Library Python modules in the Lib/ directorytopic-asynciotype-bugAn unexpected behavior, bug, or error

    Projects

    • Status
      Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions