Repository navigation
asyncio: after an SSL connection is lost, transport.close() leaves a transport whose write(), set_protocol(), … raise AttributeError #159875
Description
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
SSLProtocol.connection_lost()marks the app transport closed
(self._app_transport._closed = True, "so that its__dealloc__doesn't
complain"), without aclose()having been called.- The application calls
transport.close(). _SSLProtocolTransport.close()finds_closedalready set and takes the
branch meant for a secondclose():self._ssl_protocol = None.write(),set_protocol(),get_protocol(),resume_reading(),
get_write_buffer_size(), … all dereferenceself._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 = nThe 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
- A released transport behaves as a closed one. Keep dropping
_ssl_protocolinclose(), and give each method that reaches it a
closed answer when it isNone(write()drops the data,
set_protocol()andget_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 theNonecase. Not implemented. - 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 wholetest_asynciosuite passes
with no test change. But the application'sconnection_lost()then finds
get_extra_info('ssl_object')and('sslcontext')alreadyNone, where
today it gets the objects (probe), which is a visible change for code that
logs or inspects the session as it closes. - 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 ownwrite()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(), thenclose(), thenwrite(),get_write_buffer_size(),
set_protocol()andabort()must not raise;test_sendfile.SendfileMixin.test_sendfile_ssl_close_peer_in_the_middle_closes_transport:
the client protocol closes its transport inconnection_lost();sendfile()
must raiseConnectionError. It reads the file synchronously, so that the
fallback waits only indrain(): a read finishing in the same loop
iteration as the reset otherwise lets the fallback return beforeclose()
(6 of 60 runs passed onmainthat way). As written, 60 of 60 runs fail on
mainwith theset_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
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsTodo
Activity
junaidkhan80121 commentedon Oct 21, 2026
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
cornopaez commentedon Oct 21, 2026
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.