From f2d6f009a74aeb1f47b9479d0c13ae0af0581802 Mon Sep 17 00:00:00 2001 From: Andrew Svetlov Date: Fri, 30 Oct 2026 12:13:25 +0100 Subject: [PATCH] gh-160964: Keep asyncio SSL transport usable after close on lost connection Closing an SSL transport that was already marked closed, e.g. from the protocol's connection_lost(), detached it from its SSLProtocol, so later calls such as write() or set_protocol() raised AttributeError. This hit the loop.sendfile() fallback when the peer went away mid-transfer. Keep the protocol and instead release the SSL object and context, which is what the detaching was for (GH-98539). If the shutdown is still in progress, release them when the connection is lost. Co-Authored-By: Claude Opus 5.5 --- Lib/asyncio/sslproto.py | 19 ++++++++- Lib/test/test_asyncio/test_sslproto.py | 42 +++++++++++++++++++ ...-10-30-12-00-00.gh-issue-160964.Rk3sLq.rst | 5 +++ 3 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-30-12-00-00.gh-issue-160964.Rk3sLq.rst diff --git a/Lib/asyncio/sslproto.py b/Lib/asyncio/sslproto.py index 59e2d6bcff8fa5..3d1a78d9a0eace 100644 --- a/Lib/asyncio/sslproto.py +++ b/Lib/asyncio/sslproto.py @@ -115,7 +115,7 @@ def close(self): self._closed = True self._ssl_protocol._start_shutdown() else: - self._ssl_protocol = None + self._ssl_protocol._release_ssl() def __del__(self, _warnings=warnings): if not self._closed: @@ -326,6 +326,7 @@ def __init__(self, loop, app_protocol, sslcontext, waiter, self._outgoing = ssl.MemoryBIO() self._state = SSLProtocolState.UNWRAPPED self._conn_lost = 0 # Set when connection_lost called + self._ssl_release_pending = False if call_connection_made: self._app_state = AppProtocolState.STATE_INIT else: @@ -382,6 +383,20 @@ def _get_app_transport(self): self._app_transport_created = True return self._app_transport + def _release_ssl(self): + # Called when the app closes an already closed transport. The app + # transport can outlive the connection, e.g. when the app protocol + # keeps a reference to it, so it should not keep the SSL object and + # context alive. The shutdown still needs them until the connection + # is lost. + if self._transport is not None or not self._conn_lost: + self._ssl_release_pending = True + return + self._sslobj = None + self._sslcontext = None + self._extra.pop('ssl_object', None) + self._extra.pop('sslcontext', None) + def _is_transport_closing(self): return self._transport is not None and self._transport.is_closing() @@ -430,6 +445,8 @@ def connection_lost(self, exc): if self._handshake_timeout_handle: self._handshake_timeout_handle.cancel() self._handshake_timeout_handle = None + if self._ssl_release_pending: + self._release_ssl() def get_buffer(self, n): want = n diff --git a/Lib/test/test_asyncio/test_sslproto.py b/Lib/test/test_asyncio/test_sslproto.py index c8c9eec59e1dbb..4d4340b5414a5e 100644 --- a/Lib/test/test_asyncio/test_sslproto.py +++ b/Lib/test/test_asyncio/test_sslproto.py @@ -289,6 +289,48 @@ def test_write_after_closing(self): # should not raise self.assertIsNone(transp.write(b'data')) + def test_close_after_connection_lost(self): + # gh-160964: closing the transport after the connection is lost, + # e.g. from the app's connection_lost(), must keep it usable but + # release the SSL object and context. + ssl_proto = self.ssl_protocol() + self.connection_made(ssl_proto) + transp = ssl_proto._app_transport + self.assertIsNotNone(transp.get_extra_info('ssl_object')) + self.assertIsNotNone(transp.get_extra_info('sslcontext')) + ssl_proto.connection_lost(None) + transp.close() + + self.assertIsNone(ssl_proto._sslobj) + self.assertIsNone(ssl_proto._sslcontext) + self.assertIsNone(transp.get_extra_info('ssl_object')) + self.assertIsNone(transp.get_extra_info('sslcontext')) + # should not raise + self.assertTrue(transp.is_closing()) + self.assertIsNone(transp.write(b'data')) + self.assertEqual(transp.get_write_buffer_size(), 0) + new_app_proto = asyncio.Protocol() + transp.set_protocol(new_app_proto) + self.assertIs(transp.get_protocol(), new_app_proto) + transp.close() + + def test_close_twice_during_shutdown(self): + # The shutdown still needs the SSL object: it is released only once + # the connection is lost. + ssl_proto = self.ssl_protocol() + self.connection_made(ssl_proto) + transp = ssl_proto._app_transport + transp.close() + transp.close() + self.assertIsNotNone(ssl_proto._sslobj) + self.assertIsNotNone(transp.get_extra_info('ssl_object')) + + ssl_proto.connection_lost(None) + self.assertIsNone(ssl_proto._sslobj) + self.assertIsNone(ssl_proto._sslcontext) + self.assertIsNone(transp.get_extra_info('ssl_object')) + self.assertIsNone(transp.get_extra_info('sslcontext')) + def test_pause_reading_if_ssl_replies_are_not_read(self): # Output which OpenSSL produces on its own while reading (alerts, # KeyUpdate, ...) must not pile up if the peer does not read it. diff --git a/Misc/NEWS.d/next/Library/2026-10-30-12-00-00.gh-issue-160964.Rk3sLq.rst b/Misc/NEWS.d/next/Library/2026-10-30-12-00-00.gh-issue-160964.Rk3sLq.rst new file mode 100644 index 00000000000000..5c6911745c2ca1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-30-12-00-00.gh-issue-160964.Rk3sLq.rst @@ -0,0 +1,5 @@ +Fix :exc:`AttributeError` from the methods of an :mod:`asyncio` SSL transport +that was closed after its connection was lost, for example from the +protocol's :meth:`~asyncio.BaseProtocol.connection_lost`. This affected the +fallback of :meth:`loop.sendfile() ` when the peer +closed the connection during the transfer.