From c0e04a23f0776bfa6c5573601ed41272fdfe6141 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Jun 2025 17:45:35 +0100 Subject: [PATCH 1/8] Increment version to 3.12.10.dev0 (#11146) --- aiohttp/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aiohttp/__init__.py b/aiohttp/__init__.py index 4df59028912..b86fbfc8167 100644 --- a/aiohttp/__init__.py +++ b/aiohttp/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.12.9" +__version__ = "3.12.10.dev0" from typing import TYPE_CHECKING, Tuple From 834ea610aef0e2fc7b3d1ed5e2ed9740ef0e5542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20Bo=C4=8Dek?= Date: Sat, 7 Jun 2025 13:11:44 +0200 Subject: [PATCH 2/8] [PR #11150/996ad00 backport][3.13] fix: leak of aiodns.DNSResolver when ClientSession is closed (#11151) Co-authored-by: J. Nick Koston --- CHANGES/11150.bugfix.rst | 3 +++ CONTRIBUTORS.txt | 1 + aiohttp/connector.py | 18 +++++++++++++++--- aiohttp/resolver.py | 5 +++-- tests/test_connector.py | 22 +++++++++++++++++++--- 5 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 CHANGES/11150.bugfix.rst diff --git a/CHANGES/11150.bugfix.rst b/CHANGES/11150.bugfix.rst new file mode 100644 index 00000000000..8a51b2e4f0c --- /dev/null +++ b/CHANGES/11150.bugfix.rst @@ -0,0 +1,3 @@ +Fixed leak of ``aiodns.DNSResolver`` when :py:class:`~aiohttp.TCPConnector` is closed and no resolver was passed when creating the connector -- by :user:`Tasssadar`. + +This was a regression introduced in version 3.12.0 (:pr:`10897`). diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 2e2ab140122..6b9f7b124bd 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -368,6 +368,7 @@ Vladimir Shulyak Vladimir Zakharov Vladyslav Bohaichuk Vladyslav Bondar +Vojtěch Boček W. Trevor King Wei Lin Weiwei Wang diff --git a/aiohttp/connector.py b/aiohttp/connector.py index 62b418a4bed..075ef95c814 100644 --- a/aiohttp/connector.py +++ b/aiohttp/connector.py @@ -926,9 +926,14 @@ def __init__( ) self._ssl = _merge_ssl_params(ssl, verify_ssl, ssl_context, fingerprint) + + self._resolver: AbstractResolver if resolver is None: - resolver = DefaultResolver(loop=self._loop) - self._resolver = resolver + self._resolver = DefaultResolver(loop=self._loop) + self._resolver_owner = True + else: + self._resolver = resolver + self._resolver_owner = False self._use_dns_cache = use_dns_cache self._cached_hosts = _DNSCacheTable(ttl=ttl_dns_cache) @@ -956,6 +961,12 @@ def _close(self) -> List[Awaitable[object]]: return waiters + async def close(self) -> None: + """Close all opened transports.""" + if self._resolver_owner: + await self._resolver.close() + await super().close() + @property def family(self) -> int: """Socket family like AF_INET.""" @@ -1709,7 +1720,8 @@ def __init__( loop=loop, ) if not isinstance( - self._loop, asyncio.ProactorEventLoop # type: ignore[attr-defined] + self._loop, + asyncio.ProactorEventLoop, # type: ignore[attr-defined] ): raise RuntimeError( "Named Pipes only available in proactor loop under windows" diff --git a/aiohttp/resolver.py b/aiohttp/resolver.py index 118bf8cbff7..b20e5672ce5 100644 --- a/aiohttp/resolver.py +++ b/aiohttp/resolver.py @@ -258,9 +258,10 @@ def release_resolver( loop: The event loop the resolver was using. """ # Remove client from its loop's tracking - if loop not in self._loop_data: + current_loop_data = self._loop_data.get(loop) + if current_loop_data is None: return - resolver, client_set = self._loop_data[loop] + resolver, client_set = current_loop_data client_set.discard(client) # If no more clients for this loop, cancel and remove its resolver if not client_set: diff --git a/tests/test_connector.py b/tests/test_connector.py index 54da8743ed7..ad5581d625f 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -1270,6 +1270,7 @@ async def test_tcp_connector_dns_cache_not_expired(loop, dns_response) -> None: with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) m_resolver().resolve.return_value = dns_response() + m_resolver().close = mock.AsyncMock() await conn._resolve_host("localhost", 8080) await conn._resolve_host("localhost", 8080) m_resolver().resolve.assert_called_once_with("localhost", 8080, family=0) @@ -1281,6 +1282,7 @@ async def test_tcp_connector_dns_cache_forever(loop, dns_response) -> None: with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) m_resolver().resolve.return_value = dns_response() + m_resolver().close = mock.AsyncMock() await conn._resolve_host("localhost", 8080) await conn._resolve_host("localhost", 8080) m_resolver().resolve.assert_called_once_with("localhost", 8080, family=0) @@ -1292,6 +1294,7 @@ async def test_tcp_connector_use_dns_cache_disabled(loop, dns_response) -> None: with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=False) m_resolver().resolve.side_effect = [dns_response(), dns_response()] + m_resolver().close = mock.AsyncMock() await conn._resolve_host("localhost", 8080) await conn._resolve_host("localhost", 8080) m_resolver().resolve.assert_has_calls( @@ -1308,6 +1311,7 @@ async def test_tcp_connector_dns_throttle_requests(loop, dns_response) -> None: with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) m_resolver().resolve.return_value = dns_response() + m_resolver().close = mock.AsyncMock() loop.create_task(conn._resolve_host("localhost", 8080)) loop.create_task(conn._resolve_host("localhost", 8080)) await asyncio.sleep(0) @@ -1322,6 +1326,7 @@ async def test_tcp_connector_dns_throttle_requests_exception_spread(loop) -> Non conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) e = Exception() m_resolver().resolve.side_effect = e + m_resolver().close = mock.AsyncMock() r1 = loop.create_task(conn._resolve_host("localhost", 8080)) r2 = loop.create_task(conn._resolve_host("localhost", 8080)) await asyncio.sleep(0) @@ -1337,10 +1342,10 @@ async def test_tcp_connector_dns_throttle_requests_exception_spread(loop) -> Non async def test_tcp_connector_dns_throttle_requests_cancelled_when_close( loop, dns_response ): - with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) m_resolver().resolve.return_value = dns_response() + m_resolver().close = mock.AsyncMock() loop.create_task(conn._resolve_host("localhost", 8080)) f = loop.create_task(conn._resolve_host("localhost", 8080)) @@ -1367,7 +1372,6 @@ async def coro(): async def test_tcp_connector_cancel_dns_error_captured( loop, dns_response_error ) -> None: - exception_handler_called = False def exception_handler(loop, context): @@ -1384,6 +1388,7 @@ def exception_handler(loop, context): use_dns_cache=False, ) m_resolver().resolve.return_value = dns_response_error() + m_resolver().close = mock.AsyncMock() f = loop.create_task(conn._create_direct_connection(req, [], ClientTimeout(0))) await asyncio.sleep(0) @@ -1419,6 +1424,7 @@ async def test_tcp_connector_dns_tracing(loop, dns_response) -> None: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) m_resolver().resolve.return_value = dns_response() + m_resolver().close = mock.AsyncMock() await conn._resolve_host("localhost", 8080, traces=traces) on_dns_resolvehost_start.assert_called_once_with( @@ -1460,6 +1466,7 @@ async def test_tcp_connector_dns_tracing_cache_disabled(loop, dns_response) -> N conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=False) m_resolver().resolve.side_effect = [dns_response(), dns_response()] + m_resolver().close = mock.AsyncMock() await conn._resolve_host("localhost", 8080, traces=traces) @@ -1514,6 +1521,7 @@ async def test_tcp_connector_dns_tracing_throttle_requests(loop, dns_response) - with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) m_resolver().resolve.return_value = dns_response() + m_resolver().close = mock.AsyncMock() loop.create_task(conn._resolve_host("localhost", 8080, traces=traces)) loop.create_task(conn._resolve_host("localhost", 8080, traces=traces)) await asyncio.sleep(0) @@ -1528,6 +1536,14 @@ async def test_tcp_connector_dns_tracing_throttle_requests(loop, dns_response) - await conn.close() +async def test_tcp_connector_close_resolver() -> None: + m_resolver = mock.AsyncMock() + with mock.patch("aiohttp.connector.DefaultResolver", return_value=m_resolver): + conn = aiohttp.TCPConnector(use_dns_cache=True, ttl_dns_cache=10) + await conn.close() + m_resolver.close.assert_awaited_once() + + async def test_dns_error(loop) -> None: connector = aiohttp.TCPConnector(loop=loop) connector._resolve_host = mock.AsyncMock( @@ -2896,7 +2912,6 @@ async def f(): async def test_connect_with_limit_cancelled(loop) -> None: - proto = create_mocked_conn() proto.is_connected.return_value = True @@ -3691,6 +3706,7 @@ async def resolve_response() -> List[ResolveResult]: with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: m_resolver().resolve.return_value = resolve_response() + m_resolver().close = mock.AsyncMock() connector = TCPConnector() traces = [DummyTracer()] From c1eea5e786ad38fd4576d55773f9ed7db478c8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20Bo=C4=8Dek?= Date: Sat, 7 Jun 2025 13:12:26 +0200 Subject: [PATCH 3/8] [PR #11150/996ad00 backport][3.12] fix: leak of aiodns.DNSResolver when ClientSession is closed (#11152) Co-authored-by: J. Nick Koston --- CHANGES/11150.bugfix.rst | 3 +++ CONTRIBUTORS.txt | 1 + aiohttp/connector.py | 18 +++++++++++++++--- aiohttp/resolver.py | 5 +++-- tests/test_connector.py | 19 +++++++++++++++++++ 5 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 CHANGES/11150.bugfix.rst diff --git a/CHANGES/11150.bugfix.rst b/CHANGES/11150.bugfix.rst new file mode 100644 index 00000000000..8a51b2e4f0c --- /dev/null +++ b/CHANGES/11150.bugfix.rst @@ -0,0 +1,3 @@ +Fixed leak of ``aiodns.DNSResolver`` when :py:class:`~aiohttp.TCPConnector` is closed and no resolver was passed when creating the connector -- by :user:`Tasssadar`. + +This was a regression introduced in version 3.12.0 (:pr:`10897`). diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 2e2ab140122..6b9f7b124bd 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -368,6 +368,7 @@ Vladimir Shulyak Vladimir Zakharov Vladyslav Bohaichuk Vladyslav Bondar +Vojtěch Boček W. Trevor King Wei Lin Weiwei Wang diff --git a/aiohttp/connector.py b/aiohttp/connector.py index 62b418a4bed..075ef95c814 100644 --- a/aiohttp/connector.py +++ b/aiohttp/connector.py @@ -926,9 +926,14 @@ def __init__( ) self._ssl = _merge_ssl_params(ssl, verify_ssl, ssl_context, fingerprint) + + self._resolver: AbstractResolver if resolver is None: - resolver = DefaultResolver(loop=self._loop) - self._resolver = resolver + self._resolver = DefaultResolver(loop=self._loop) + self._resolver_owner = True + else: + self._resolver = resolver + self._resolver_owner = False self._use_dns_cache = use_dns_cache self._cached_hosts = _DNSCacheTable(ttl=ttl_dns_cache) @@ -956,6 +961,12 @@ def _close(self) -> List[Awaitable[object]]: return waiters + async def close(self) -> None: + """Close all opened transports.""" + if self._resolver_owner: + await self._resolver.close() + await super().close() + @property def family(self) -> int: """Socket family like AF_INET.""" @@ -1709,7 +1720,8 @@ def __init__( loop=loop, ) if not isinstance( - self._loop, asyncio.ProactorEventLoop # type: ignore[attr-defined] + self._loop, + asyncio.ProactorEventLoop, # type: ignore[attr-defined] ): raise RuntimeError( "Named Pipes only available in proactor loop under windows" diff --git a/aiohttp/resolver.py b/aiohttp/resolver.py index 118bf8cbff7..b20e5672ce5 100644 --- a/aiohttp/resolver.py +++ b/aiohttp/resolver.py @@ -258,9 +258,10 @@ def release_resolver( loop: The event loop the resolver was using. """ # Remove client from its loop's tracking - if loop not in self._loop_data: + current_loop_data = self._loop_data.get(loop) + if current_loop_data is None: return - resolver, client_set = self._loop_data[loop] + resolver, client_set = current_loop_data client_set.discard(client) # If no more clients for this loop, cancel and remove its resolver if not client_set: diff --git a/tests/test_connector.py b/tests/test_connector.py index 54da8743ed7..79776380297 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -1270,6 +1270,7 @@ async def test_tcp_connector_dns_cache_not_expired(loop, dns_response) -> None: with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) m_resolver().resolve.return_value = dns_response() + m_resolver().close = mock.AsyncMock() await conn._resolve_host("localhost", 8080) await conn._resolve_host("localhost", 8080) m_resolver().resolve.assert_called_once_with("localhost", 8080, family=0) @@ -1281,6 +1282,7 @@ async def test_tcp_connector_dns_cache_forever(loop, dns_response) -> None: with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) m_resolver().resolve.return_value = dns_response() + m_resolver().close = mock.AsyncMock() await conn._resolve_host("localhost", 8080) await conn._resolve_host("localhost", 8080) m_resolver().resolve.assert_called_once_with("localhost", 8080, family=0) @@ -1292,6 +1294,7 @@ async def test_tcp_connector_use_dns_cache_disabled(loop, dns_response) -> None: with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=False) m_resolver().resolve.side_effect = [dns_response(), dns_response()] + m_resolver().close = mock.AsyncMock() await conn._resolve_host("localhost", 8080) await conn._resolve_host("localhost", 8080) m_resolver().resolve.assert_has_calls( @@ -1308,6 +1311,7 @@ async def test_tcp_connector_dns_throttle_requests(loop, dns_response) -> None: with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) m_resolver().resolve.return_value = dns_response() + m_resolver().close = mock.AsyncMock() loop.create_task(conn._resolve_host("localhost", 8080)) loop.create_task(conn._resolve_host("localhost", 8080)) await asyncio.sleep(0) @@ -1322,6 +1326,7 @@ async def test_tcp_connector_dns_throttle_requests_exception_spread(loop) -> Non conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) e = Exception() m_resolver().resolve.side_effect = e + m_resolver().close = mock.AsyncMock() r1 = loop.create_task(conn._resolve_host("localhost", 8080)) r2 = loop.create_task(conn._resolve_host("localhost", 8080)) await asyncio.sleep(0) @@ -1341,6 +1346,7 @@ async def test_tcp_connector_dns_throttle_requests_cancelled_when_close( with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) m_resolver().resolve.return_value = dns_response() + m_resolver().close = mock.AsyncMock() loop.create_task(conn._resolve_host("localhost", 8080)) f = loop.create_task(conn._resolve_host("localhost", 8080)) @@ -1384,6 +1390,7 @@ def exception_handler(loop, context): use_dns_cache=False, ) m_resolver().resolve.return_value = dns_response_error() + m_resolver().close = mock.AsyncMock() f = loop.create_task(conn._create_direct_connection(req, [], ClientTimeout(0))) await asyncio.sleep(0) @@ -1419,6 +1426,7 @@ async def test_tcp_connector_dns_tracing(loop, dns_response) -> None: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) m_resolver().resolve.return_value = dns_response() + m_resolver().close = mock.AsyncMock() await conn._resolve_host("localhost", 8080, traces=traces) on_dns_resolvehost_start.assert_called_once_with( @@ -1460,6 +1468,7 @@ async def test_tcp_connector_dns_tracing_cache_disabled(loop, dns_response) -> N conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=False) m_resolver().resolve.side_effect = [dns_response(), dns_response()] + m_resolver().close = mock.AsyncMock() await conn._resolve_host("localhost", 8080, traces=traces) @@ -1514,6 +1523,7 @@ async def test_tcp_connector_dns_tracing_throttle_requests(loop, dns_response) - with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: conn = aiohttp.TCPConnector(loop=loop, use_dns_cache=True, ttl_dns_cache=10) m_resolver().resolve.return_value = dns_response() + m_resolver().close = mock.AsyncMock() loop.create_task(conn._resolve_host("localhost", 8080, traces=traces)) loop.create_task(conn._resolve_host("localhost", 8080, traces=traces)) await asyncio.sleep(0) @@ -1528,6 +1538,14 @@ async def test_tcp_connector_dns_tracing_throttle_requests(loop, dns_response) - await conn.close() +async def test_tcp_connector_close_resolver() -> None: + m_resolver = mock.AsyncMock() + with mock.patch("aiohttp.connector.DefaultResolver", return_value=m_resolver): + conn = aiohttp.TCPConnector(use_dns_cache=True, ttl_dns_cache=10) + await conn.close() + m_resolver.close.assert_awaited_once() + + async def test_dns_error(loop) -> None: connector = aiohttp.TCPConnector(loop=loop) connector._resolve_host = mock.AsyncMock( @@ -3691,6 +3709,7 @@ async def resolve_response() -> List[ResolveResult]: with mock.patch("aiohttp.connector.DefaultResolver") as m_resolver: m_resolver().resolve.return_value = resolve_response() + m_resolver().close = mock.AsyncMock() connector = TCPConnector() traces = [DummyTracer()] From dcc0ba2122e7dda7e43692396fde8485426e8385 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Jun 2025 06:38:13 -0500 Subject: [PATCH 4/8] Release 3.12.10 (#11153) --- CHANGES.rst | 20 ++++++++++++++++++++ CHANGES/11150.bugfix.rst | 3 --- aiohttp/__init__.py | 2 +- 3 files changed, 21 insertions(+), 4 deletions(-) delete mode 100644 CHANGES/11150.bugfix.rst diff --git a/CHANGES.rst b/CHANGES.rst index fd27e959e23..6fb4135456c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -10,6 +10,26 @@ .. towncrier release notes start +3.12.10 (2025-06-07) +==================== + +Bug fixes +--------- + +- Fixed leak of ``aiodns.DNSResolver`` when :py:class:`~aiohttp.TCPConnector` is closed and no resolver was passed when creating the connector -- by :user:`Tasssadar`. + + This was a regression introduced in version 3.12.0 (:pr:`10897`). + + + *Related issues and pull requests on GitHub:* + :issue:`11150`. + + + + +---- + + 3.12.9 (2025-06-04) =================== diff --git a/CHANGES/11150.bugfix.rst b/CHANGES/11150.bugfix.rst deleted file mode 100644 index 8a51b2e4f0c..00000000000 --- a/CHANGES/11150.bugfix.rst +++ /dev/null @@ -1,3 +0,0 @@ -Fixed leak of ``aiodns.DNSResolver`` when :py:class:`~aiohttp.TCPConnector` is closed and no resolver was passed when creating the connector -- by :user:`Tasssadar`. - -This was a regression introduced in version 3.12.0 (:pr:`10897`). diff --git a/aiohttp/__init__.py b/aiohttp/__init__.py index b86fbfc8167..dccff3e0040 100644 --- a/aiohttp/__init__.py +++ b/aiohttp/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.12.10.dev0" +__version__ = "3.12.10" from typing import TYPE_CHECKING, Tuple From 86a9a3827c876acfd32886bd51b49df7f5f1925e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Jun 2025 08:17:29 -0500 Subject: [PATCH 5/8] Abort ssl connections on close when ssl_shutdown_timeout is 0 (#11148) --- CHANGES/11148.deprecation.rst | 1 + CHANGES/11148.feature.rst | 10 + aiohttp/client.py | 9 +- aiohttp/client_proto.py | 9 + aiohttp/connector.py | 111 ++++++--- docs/client_reference.rst | 60 +++-- tests/test_client_functional.py | 5 +- tests/test_client_proto.py | 40 ++++ tests/test_client_session.py | 99 ++++++-- tests/test_connector.py | 396 +++++++++++++++++++++++++++++++- tests/test_proxy.py | 26 +-- 11 files changed, 672 insertions(+), 94 deletions(-) create mode 120000 CHANGES/11148.deprecation.rst create mode 100644 CHANGES/11148.feature.rst diff --git a/CHANGES/11148.deprecation.rst b/CHANGES/11148.deprecation.rst new file mode 120000 index 00000000000..f4ddfb298af --- /dev/null +++ b/CHANGES/11148.deprecation.rst @@ -0,0 +1 @@ +11148.feature.rst \ No newline at end of file diff --git a/CHANGES/11148.feature.rst b/CHANGES/11148.feature.rst new file mode 100644 index 00000000000..6c47c93c7ba --- /dev/null +++ b/CHANGES/11148.feature.rst @@ -0,0 +1,10 @@ +Improved SSL connection handling by changing the default ``ssl_shutdown_timeout`` +from ``0.1`` to ``0`` seconds. SSL connections now use Python's default graceful +shutdown during normal operation but are aborted immediately when the connector +is closed, providing optimal behavior for both cases. Also added support for +``ssl_shutdown_timeout=0`` on all Python versions. Previously, this value was +rejected on Python 3.11+ and ignored on earlier versions. Non-zero values on +Python < 3.11 now trigger a ``RuntimeWarning`` -- by :user:`bdraco`. + +The ``ssl_shutdown_timeout`` parameter is now deprecated and will be removed in +aiohttp 4.0 as there is no clear use case for changing the default. diff --git a/aiohttp/client.py b/aiohttp/client.py index d4800a49ac6..284814a5792 100644 --- a/aiohttp/client.py +++ b/aiohttp/client.py @@ -297,7 +297,7 @@ def __init__( max_field_size: int = 8190, fallback_charset_resolver: _CharsetResolver = lambda r, b: "utf-8", middlewares: Sequence[ClientMiddlewareType] = (), - ssl_shutdown_timeout: Optional[float] = 0.1, + ssl_shutdown_timeout: Union[_SENTINEL, None, float] = sentinel, ) -> None: # We initialise _connector to None immediately, as it's referenced in __del__() # and could cause issues if an exception occurs during initialisation. @@ -323,6 +323,13 @@ def __init__( ) self._timeout = timeout + if ssl_shutdown_timeout is not sentinel: + warnings.warn( + "The ssl_shutdown_timeout parameter is deprecated and will be removed in aiohttp 4.0", + DeprecationWarning, + stacklevel=2, + ) + if connector is None: connector = TCPConnector(ssl_shutdown_timeout=ssl_shutdown_timeout) # Initialize these three attrs before raising any exception, diff --git a/aiohttp/client_proto.py b/aiohttp/client_proto.py index c1c0869f059..93221faaa30 100644 --- a/aiohttp/client_proto.py +++ b/aiohttp/client_proto.py @@ -95,6 +95,15 @@ def close(self) -> None: self._payload = None self._drop_timeout() + def abort(self) -> None: + self._exception = None # Break cyclic references + transport = self.transport + if transport is not None: + transport.abort() + self.transport = None + self._payload = None + self._drop_timeout() + def is_connected(self) -> bool: return self.transport is not None and not self.transport.is_closing() diff --git a/aiohttp/connector.py b/aiohttp/connector.py index 4ee0d570127..1c96ff42457 100644 --- a/aiohttp/connector.py +++ b/aiohttp/connector.py @@ -205,15 +205,19 @@ def closed(self) -> bool: class _TransportPlaceholder: """placeholder for BaseConnector.connect function""" - __slots__ = ("closed",) + __slots__ = ("closed", "transport") def __init__(self, closed_future: asyncio.Future[Optional[Exception]]) -> None: """Initialize a placeholder for a transport.""" self.closed = closed_future + self.transport = None def close(self) -> None: """Close the placeholder.""" + def abort(self) -> None: + """Abort the placeholder (does nothing).""" + class BaseConnector: """Base connector class. @@ -431,9 +435,14 @@ def _cleanup_closed(self) -> None: timeout_ceil_threshold=self._timeout_ceil_threshold, ) - async def close(self) -> None: - """Close all opened transports.""" - waiters = self._close_immediately() + async def close(self, *, abort_ssl: bool = False) -> None: + """Close all opened transports. + + :param abort_ssl: If True, SSL connections will be aborted immediately + without performing the shutdown handshake. This provides + faster cleanup at the cost of less graceful disconnection. + """ + waiters = self._close_immediately(abort_ssl=abort_ssl) if waiters: results = await asyncio.gather(*waiters, return_exceptions=True) for res in results: @@ -441,7 +450,7 @@ async def close(self) -> None: err_msg = "Error while closing connector: " + repr(res) client_logger.debug(err_msg) - def _close_immediately(self) -> List[Awaitable[object]]: + def _close_immediately(self, *, abort_ssl: bool = False) -> List[Awaitable[object]]: waiters: List[Awaitable[object]] = [] if self._closed: @@ -463,12 +472,26 @@ def _close_immediately(self) -> List[Awaitable[object]]: for data in self._conns.values(): for proto, _ in data: - proto.close() + if ( + abort_ssl + and proto.transport + and proto.transport.get_extra_info("sslcontext") is not None + ): + proto.abort() + else: + proto.close() if closed := proto.closed: waiters.append(closed) for proto in self._acquired: - proto.close() + if ( + abort_ssl + and proto.transport + and proto.transport.get_extra_info("sslcontext") is not None + ): + proto.abort() + else: + proto.close() if closed := proto.closed: waiters.append(closed) @@ -838,11 +861,12 @@ class TCPConnector(BaseConnector): socket_factory - A SocketFactoryType function that, if supplied, will be used to create sockets given an AddrInfoType. - ssl_shutdown_timeout - Grace period for SSL shutdown handshake on TLS - connections. Default is 0.1 seconds. This usually - allows for a clean SSL shutdown by notifying the - remote peer of connection closure, while avoiding - excessive delays during connector cleanup. + ssl_shutdown_timeout - DEPRECATED. Will be removed in aiohttp 4.0. + Grace period for SSL shutdown handshake on TLS + connections. Default is 0 seconds (immediate abort). + This parameter allowed for a clean SSL shutdown by + notifying the remote peer of connection closure, + while avoiding excessive delays during connector cleanup. Note: Only takes effect on Python 3.11+. """ @@ -866,7 +890,7 @@ def __init__( happy_eyeballs_delay: Optional[float] = 0.25, interleave: Optional[int] = None, socket_factory: Optional[SocketFactoryType] = None, - ssl_shutdown_timeout: Optional[float] = 0.1, + ssl_shutdown_timeout: Union[_SENTINEL, None, float] = sentinel, ): super().__init__( keepalive_timeout=keepalive_timeout, @@ -903,13 +927,50 @@ def __init__( self._interleave = interleave self._resolve_host_tasks: Set["asyncio.Task[List[ResolveResult]]"] = set() self._socket_factory = socket_factory - self._ssl_shutdown_timeout = ssl_shutdown_timeout + self._ssl_shutdown_timeout: Optional[float] - def _close_immediately(self) -> List[Awaitable[object]]: + # Handle ssl_shutdown_timeout with warning for Python < 3.11 + if ssl_shutdown_timeout is sentinel: + self._ssl_shutdown_timeout = 0 + else: + # Deprecation warning for ssl_shutdown_timeout parameter + warnings.warn( + "The ssl_shutdown_timeout parameter is deprecated and will be removed in aiohttp 4.0", + DeprecationWarning, + stacklevel=2, + ) + if ( + sys.version_info < (3, 11) + and ssl_shutdown_timeout is not None + and ssl_shutdown_timeout != 0 + ): + warnings.warn( + f"ssl_shutdown_timeout={ssl_shutdown_timeout} is ignored on Python < 3.11; " + "only ssl_shutdown_timeout=0 is supported. The timeout will be ignored.", + RuntimeWarning, + stacklevel=2, + ) + self._ssl_shutdown_timeout = ssl_shutdown_timeout + + async def close(self, *, abort_ssl: bool = False) -> None: + """Close all opened transports. + + :param abort_ssl: If True, SSL connections will be aborted immediately + without performing the shutdown handshake. If False (default), + the behavior is determined by ssl_shutdown_timeout: + - If ssl_shutdown_timeout=0: connections are aborted + - If ssl_shutdown_timeout>0: graceful shutdown is performed + """ + if self._resolver_owner: + await self._resolver.close() + # Use abort_ssl param if explicitly set, otherwise use ssl_shutdown_timeout default + await super().close(abort_ssl=abort_ssl or self._ssl_shutdown_timeout == 0) + + def _close_immediately(self, *, abort_ssl: bool = False) -> List[Awaitable[object]]: for fut in chain.from_iterable(self._throttle_dns_futures.values()): fut.cancel() - waiters = super()._close_immediately() + waiters = super()._close_immediately(abort_ssl=abort_ssl) for t in self._resolve_host_tasks: t.cancel() @@ -917,12 +978,6 @@ def _close_immediately(self) -> List[Awaitable[object]]: return waiters - async def close(self) -> None: - """Close all opened transports.""" - if self._resolver_owner: - await self._resolver.close() - await super().close() - @property def family(self) -> int: """Socket family like AF_INET.""" @@ -1155,7 +1210,7 @@ async def _wrap_create_connection( # Add ssl_shutdown_timeout for Python 3.11+ when SSL is used if ( kwargs.get("ssl") - and self._ssl_shutdown_timeout is not None + and self._ssl_shutdown_timeout and sys.version_info >= (3, 11) ): kwargs["ssl_shutdown_timeout"] = self._ssl_shutdown_timeout @@ -1233,10 +1288,7 @@ async def _start_tls_connection( ): try: # ssl_shutdown_timeout is only available in Python 3.11+ - if ( - sys.version_info >= (3, 11) - and self._ssl_shutdown_timeout is not None - ): + if sys.version_info >= (3, 11) and self._ssl_shutdown_timeout: tls_transport = await self._loop.start_tls( underlying_transport, tls_proto, @@ -1257,7 +1309,10 @@ async def _start_tls_connection( # We need to close the underlying transport since # `start_tls()` probably failed before it had a # chance to do this: - underlying_transport.close() + if self._ssl_shutdown_timeout == 0: + underlying_transport.abort() + else: + underlying_transport.close() raise if isinstance(tls_transport, asyncio.Transport): fingerprint = self._get_fingerprint(req) diff --git a/docs/client_reference.rst b/docs/client_reference.rst index 223ac855215..a262bd47a1a 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -58,7 +58,7 @@ The client session supports the context manager protocol for self closing. max_line_size=8190, \ max_field_size=8190, \ fallback_charset_resolver=lambda r, b: "utf-8", \ - ssl_shutdown_timeout=0.1) + ssl_shutdown_timeout=0) The class for creating client sessions and making requests. @@ -241,16 +241,31 @@ The client session supports the context manager protocol for self closing. .. versionadded:: 3.8.6 - :param float ssl_shutdown_timeout: Grace period for SSL shutdown handshake on TLS - connections (``0.1`` seconds by default). This usually provides sufficient time - to notify the remote peer of connection closure, helping prevent broken - connections on the server side, while minimizing delays during connector - cleanup. This timeout is passed to the underlying :class:`TCPConnector` - when one is created automatically. Note: This parameter only takes effect - on Python 3.11+. + :param float ssl_shutdown_timeout: **(DEPRECATED)** This parameter is deprecated + and will be removed in aiohttp 4.0. Grace period for SSL shutdown handshake on + TLS connections when the connector is closed (``0`` seconds by default). + By default (``0``), SSL connections are aborted immediately when the + connector is closed, without performing the shutdown handshake. During + normal operation, SSL connections use Python's default SSL shutdown + behavior. Setting this to a positive value (e.g., ``0.1``) will perform + a graceful shutdown when closing the connector, notifying the remote + peer which can help prevent "connection reset" errors at the cost of + additional cleanup time. This timeout is passed to the underlying + :class:`TCPConnector` when one is created automatically. + Note: On Python versions prior to 3.11, only a value of ``0`` is supported; + other values will trigger a warning. .. versionadded:: 3.12.5 + .. versionchanged:: 3.12.11 + Changed default from ``0.1`` to ``0`` to abort SSL connections + immediately when the connector is closed. Added support for + ``ssl_shutdown_timeout=0`` on all Python versions. A :exc:`RuntimeWarning` + is issued when non-zero values are passed on Python < 3.11. + + .. deprecated:: 3.12.11 + This parameter is deprecated and will be removed in aiohttp 4.0. + .. attribute:: closed ``True`` if the session has been closed, ``False`` otherwise. @@ -1180,7 +1195,7 @@ is controlled by *force_close* constructor's parameter). force_close=False, limit=100, limit_per_host=0, \ enable_cleanup_closed=False, timeout_ceil_threshold=5, \ happy_eyeballs_delay=0.25, interleave=None, loop=None, \ - socket_factory=None, ssl_shutdown_timeout=0.1) + socket_factory=None, ssl_shutdown_timeout=0) Connector for working with *HTTP* and *HTTPS* via *TCP* sockets. @@ -1307,16 +1322,29 @@ is controlled by *force_close* constructor's parameter). .. versionadded:: 3.12 - :param float ssl_shutdown_timeout: Grace period for SSL shutdown on TLS - connections (``0.1`` seconds by default). This parameter balances two - important considerations: usually providing sufficient time to notify - the remote server (which helps prevent "connection reset" errors), - while avoiding unnecessary delays during connector cleanup. - The default value provides a reasonable compromise for most use cases. - Note: This parameter only takes effect on Python 3.11+. + :param float ssl_shutdown_timeout: **(DEPRECATED)** This parameter is deprecated + and will be removed in aiohttp 4.0. Grace period for SSL shutdown on TLS + connections when the connector is closed (``0`` seconds by default). + By default (``0``), SSL connections are aborted immediately when the + connector is closed, without performing the shutdown handshake. During + normal operation, SSL connections use Python's default SSL shutdown + behavior. Setting this to a positive value (e.g., ``0.1``) will perform + a graceful shutdown when closing the connector, notifying the remote + server which can help prevent "connection reset" errors at the cost of + additional cleanup time. Note: On Python versions prior to 3.11, only + a value of ``0`` is supported; other values will trigger a warning. .. versionadded:: 3.12.5 + .. versionchanged:: 3.12.11 + Changed default from ``0.1`` to ``0`` to abort SSL connections + immediately when the connector is closed. Added support for + ``ssl_shutdown_timeout=0`` on all Python versions. A :exc:`RuntimeWarning` + is issued when non-zero values are passed on Python < 3.11. + + .. deprecated:: 3.12.11 + This parameter is deprecated and will be removed in aiohttp 4.0. + .. attribute:: family *TCP* socket family e.g. :data:`socket.AF_INET` or diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py index caed7286c91..19eeb2d41d3 100644 --- a/tests/test_client_functional.py +++ b/tests/test_client_functional.py @@ -717,7 +717,10 @@ async def test_ssl_client_shutdown_timeout( ) -> None: # Test that ssl_shutdown_timeout is properly used during connection closure - connector = aiohttp.TCPConnector(ssl=client_ssl_ctx, ssl_shutdown_timeout=0.1) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + connector = aiohttp.TCPConnector(ssl=client_ssl_ctx, ssl_shutdown_timeout=0.1) async def streaming_handler(request: web.Request) -> NoReturn: # Create a streaming response that continuously sends data diff --git a/tests/test_client_proto.py b/tests/test_client_proto.py index e8749aaeb9f..0764d26221d 100644 --- a/tests/test_client_proto.py +++ b/tests/test_client_proto.py @@ -308,3 +308,43 @@ async def test_closed_property_after_connection_lost( # After connection_lost, closed should return None if it was never accessed assert proto.closed is None + + +async def test_abort(loop: asyncio.AbstractEventLoop) -> None: + """Test the abort() method.""" + proto = ResponseHandler(loop=loop) + + # Create a mock transport + transport = mock.Mock() + proto.connection_made(transport) + + # Set up some state + proto._payload = mock.Mock() + + # Mock _drop_timeout method using patch.object + with mock.patch.object(proto, "_drop_timeout") as mock_drop_timeout: + # Call abort + proto.abort() + + # Verify transport.abort() was called + transport.abort.assert_called_once() + + # Verify cleanup + assert proto.transport is None + assert proto._payload is None + assert proto._exception is None # type: ignore[unreachable] + mock_drop_timeout.assert_called_once() + + +async def test_abort_without_transport(loop: asyncio.AbstractEventLoop) -> None: + """Test abort() when transport is None.""" + proto = ResponseHandler(loop=loop) + + # Mock _drop_timeout method using patch.object + with mock.patch.object(proto, "_drop_timeout") as mock_drop_timeout: + # Call abort without transport + proto.abort() + + # Should not raise and should still clean up + assert proto._exception is None + mock_drop_timeout.assert_not_called() diff --git a/tests/test_client_session.py b/tests/test_client_session.py index 63d6e98165e..80779886dca 100644 --- a/tests/test_client_session.py +++ b/tests/test_client_session.py @@ -3,6 +3,8 @@ import gc import io import json +import sys +import warnings from collections import deque from http.cookies import BaseCookie, SimpleCookie from typing import ( @@ -349,32 +351,91 @@ async def test_create_connector( assert m.called +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Use test_ssl_shutdown_timeout_passed_to_connector_pre_311 for Python < 3.11", +) async def test_ssl_shutdown_timeout_passed_to_connector() -> None: - # Test default value + # Test default value (no warning expected) async with ClientSession() as session: assert isinstance(session.connector, TCPConnector) - assert session.connector._ssl_shutdown_timeout == 0.1 + assert session.connector._ssl_shutdown_timeout == 0 - # Test custom value - async with ClientSession(ssl_shutdown_timeout=1.0) as session: - assert isinstance(session.connector, TCPConnector) - assert session.connector._ssl_shutdown_timeout == 1.0 + # Test custom value - expect deprecation warning + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + async with ClientSession(ssl_shutdown_timeout=1.0) as session: + assert isinstance(session.connector, TCPConnector) + assert session.connector._ssl_shutdown_timeout == 1.0 - # Test None value - async with ClientSession(ssl_shutdown_timeout=None) as session: - assert isinstance(session.connector, TCPConnector) - assert session.connector._ssl_shutdown_timeout is None + # Test None value - expect deprecation warning + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + async with ClientSession(ssl_shutdown_timeout=None) as session: + assert isinstance(session.connector, TCPConnector) + assert session.connector._ssl_shutdown_timeout is None # Test that it doesn't affect when custom connector is provided - custom_conn = TCPConnector(ssl_shutdown_timeout=2.0) - async with ClientSession( - connector=custom_conn, ssl_shutdown_timeout=1.0 - ) as session: - assert session.connector is not None - assert isinstance(session.connector, TCPConnector) - assert ( - session.connector._ssl_shutdown_timeout == 2.0 - ) # Should use connector's value + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + custom_conn = TCPConnector(ssl_shutdown_timeout=2.0) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + async with ClientSession( + connector=custom_conn, ssl_shutdown_timeout=1.0 + ) as session: + assert session.connector is not None + assert isinstance(session.connector, TCPConnector) + assert ( + session.connector._ssl_shutdown_timeout == 2.0 + ) # Should use connector's value + + +@pytest.mark.skipif( + sys.version_info >= (3, 11), + reason="This test is for Python < 3.11 runtime warning behavior", +) +async def test_ssl_shutdown_timeout_passed_to_connector_pre_311() -> None: + """Test that both deprecation and runtime warnings are issued on Python < 3.11.""" + # Test custom value - expect both deprecation and runtime warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + async with ClientSession(ssl_shutdown_timeout=1.0) as session: + assert isinstance(session.connector, TCPConnector) + assert session.connector._ssl_shutdown_timeout == 1.0 + # Should have deprecation warnings (from ClientSession and TCPConnector) and runtime warning + # ClientSession emits 1 DeprecationWarning, TCPConnector emits 1 DeprecationWarning + 1 RuntimeWarning = 3 total + assert len(w) == 3 + deprecation_count = sum( + 1 for warn in w if issubclass(warn.category, DeprecationWarning) + ) + runtime_count = sum( + 1 for warn in w if issubclass(warn.category, RuntimeWarning) + ) + assert deprecation_count == 2 # One from ClientSession, one from TCPConnector + assert runtime_count == 1 # One from TCPConnector + + # Test with custom connector + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + custom_conn = TCPConnector(ssl_shutdown_timeout=2.0) + # Should have both deprecation and runtime warnings + assert len(w) == 2 + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + async with ClientSession( + connector=custom_conn, ssl_shutdown_timeout=1.0 + ) as session: + assert session.connector is not None + assert isinstance(session.connector, TCPConnector) + assert ( + session.connector._ssl_shutdown_timeout == 2.0 + ) # Should use connector's value def test_connector_loop(loop: asyncio.AbstractEventLoop) -> None: diff --git a/tests/test_connector.py b/tests/test_connector.py index 10ba4227a33..3e9f60a59cf 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -7,6 +7,7 @@ import ssl import sys import uuid +import warnings from collections import defaultdict, deque from concurrent import futures from contextlib import closing, suppress @@ -2103,25 +2104,55 @@ async def test_tcp_connector_ctor(loop: asyncio.AbstractEventLoop) -> None: await conn.close() +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Use test_tcp_connector_ssl_shutdown_timeout_pre_311 for Python < 3.11", +) async def test_tcp_connector_ssl_shutdown_timeout( loop: asyncio.AbstractEventLoop, ) -> None: - # Test default value + # Test default value (no warning expected) conn = aiohttp.TCPConnector() - assert conn._ssl_shutdown_timeout == 0.1 + assert conn._ssl_shutdown_timeout == 0 await conn.close() - # Test custom value - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) + # Test custom value - expect deprecation warning + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) assert conn._ssl_shutdown_timeout == 1.0 await conn.close() - # Test None value - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None) + # Test None value - expect deprecation warning + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None) assert conn._ssl_shutdown_timeout is None await conn.close() +@pytest.mark.skipif( + sys.version_info >= (3, 11), + reason="This test is for Python < 3.11 runtime warning behavior", +) +async def test_tcp_connector_ssl_shutdown_timeout_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that both deprecation and runtime warnings are issued on Python < 3.11.""" + # Test custom value - expect both deprecation and runtime warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) + # Should have both deprecation and runtime warnings + assert len(w) == 2 + assert any(issubclass(warn.category, DeprecationWarning) for warn in w) + assert any(issubclass(warn.category, RuntimeWarning) for warn in w) + assert conn._ssl_shutdown_timeout == 1.0 + await conn.close() + + @pytest.mark.skipif( sys.version_info < (3, 11), reason="ssl_shutdown_timeout requires Python 3.11+" ) @@ -2129,7 +2160,10 @@ async def test_tcp_connector_ssl_shutdown_timeout_passed_to_create_connection( loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock ) -> None: # Test that ssl_shutdown_timeout is passed to create_connection for SSL connections - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) with mock.patch.object( conn._loop, "create_connection", autospec=True, spec_set=True @@ -2144,7 +2178,10 @@ async def test_tcp_connector_ssl_shutdown_timeout_passed_to_create_connection( await conn.close() # Test with None value - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None) with mock.patch.object( conn._loop, "create_connection", autospec=True, spec_set=True @@ -2160,7 +2197,10 @@ async def test_tcp_connector_ssl_shutdown_timeout_passed_to_create_connection( await conn.close() # Test that ssl_shutdown_timeout is NOT passed for non-SSL connections - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) with mock.patch.object( conn._loop, "create_connection", autospec=True, spec_set=True @@ -2181,7 +2221,178 @@ async def test_tcp_connector_ssl_shutdown_timeout_not_passed_pre_311( loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock ) -> None: # Test that ssl_shutdown_timeout is NOT passed to create_connection on Python < 3.11 - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) + # Should have both deprecation and runtime warnings + assert len(w) == 2 + assert any(issubclass(warn.category, DeprecationWarning) for warn in w) + assert any(issubclass(warn.category, RuntimeWarning) for warn in w) + + with mock.patch.object( + conn._loop, "create_connection", autospec=True, spec_set=True + ) as create_connection: + create_connection.return_value = mock.Mock(), mock.Mock() + + # Test with HTTPS + req = ClientRequest("GET", URL("https://example.com"), loop=loop) + with closing(await conn.connect(req, [], ClientTimeout())): + assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs + + # Test with HTTP + req = ClientRequest("GET", URL("http://example.com"), loop=loop) + with closing(await conn.connect(req, [], ClientTimeout())): + assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs + + await conn.close() + + +async def test_tcp_connector_close_abort_ssl_when_shutdown_timeout_zero( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that close() uses abort() for SSL connections when ssl_shutdown_timeout=0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + # Create a mock SSL protocol + proto = mock.create_autospec(ResponseHandler, instance=True) + proto.closed = None + + # Create mock SSL transport + transport = mock.Mock() + transport.get_extra_info.return_value = mock.Mock() # Returns SSL context + transport.is_closing.return_value = False + proto.transport = transport + + # Add the protocol to acquired connections + conn._acquired.add(proto) + + # Close the connector + await conn.close() + + # Verify abort was called instead of close for SSL connection + proto.abort.assert_called_once() + proto.close.assert_not_called() + + +async def test_tcp_connector_close_doesnt_abort_non_ssl_when_shutdown_timeout_zero( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that close() still uses close() for non-SSL connections even when ssl_shutdown_timeout=0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + # Create a mock non-SSL protocol + proto = mock.create_autospec(ResponseHandler, instance=True) + proto.closed = None + + # Create mock non-SSL transport + transport = mock.Mock() + transport.get_extra_info.return_value = None # No SSL context + transport.is_closing.return_value = False + proto.transport = transport + + # Add the protocol to acquired connections + conn._acquired.add(proto) + + # Close the connector + await conn.close() + + # Verify close was called for non-SSL connection + proto.close.assert_called_once() + proto.abort.assert_not_called() + + +async def test_tcp_connector_ssl_shutdown_timeout_warning_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that a warning is issued for non-zero ssl_shutdown_timeout on Python < 3.11.""" + with ( + mock.patch.object(sys, "version_info", (3, 10, 0)), + warnings.catch_warnings(record=True) as w, + ): + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=5.0) + + # We should get two warnings: deprecation and runtime warning + assert len(w) == 2 + + # Find each warning type + deprecation_warning = next( + (warn for warn in w if issubclass(warn.category, DeprecationWarning)), None + ) + runtime_warning = next( + (warn for warn in w if issubclass(warn.category, RuntimeWarning)), None + ) + + assert deprecation_warning is not None + assert "ssl_shutdown_timeout parameter is deprecated" in str( + deprecation_warning.message + ) + + assert runtime_warning is not None + assert "ssl_shutdown_timeout=5.0 is ignored on Python < 3.11" in str( + runtime_warning.message + ) + assert "only ssl_shutdown_timeout=0 is supported" in str( + runtime_warning.message + ) + + # Verify the value is still stored + assert conn._ssl_shutdown_timeout == 5.0 + + await conn.close() + + +async def test_tcp_connector_ssl_shutdown_timeout_zero_no_warning_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that no warning is issued for ssl_shutdown_timeout=0 on Python < 3.11.""" + with ( + mock.patch.object(sys, "version_info", (3, 10, 0)), + warnings.catch_warnings(record=True) as w, + ): + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + # We should get one warning: deprecation + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "ssl_shutdown_timeout parameter is deprecated" in str(w[0].message) + assert conn._ssl_shutdown_timeout == 0 + + await conn.close() + + +async def test_tcp_connector_ssl_shutdown_timeout_sentinel_no_warning_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that no warning is issued when sentinel is used on Python < 3.11.""" + with ( + mock.patch.object(sys, "version_info", (3, 10, 0)), + warnings.catch_warnings(record=True) as w, + ): + warnings.simplefilter("always") + conn = aiohttp.TCPConnector() # Uses sentinel by default + + assert len(w) == 0 + assert conn._ssl_shutdown_timeout == 0 # Default value + + await conn.close() + + +async def test_tcp_connector_ssl_shutdown_timeout_zero_not_passed( + loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock +) -> None: + """Test that ssl_shutdown_timeout=0 is NOT passed to create_connection.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) with mock.patch.object( conn._loop, "create_connection", autospec=True, spec_set=True @@ -2191,9 +2402,41 @@ async def test_tcp_connector_ssl_shutdown_timeout_not_passed_pre_311( # Test with HTTPS req = ClientRequest("GET", URL("https://example.com"), loop=loop) with closing(await conn.connect(req, [], ClientTimeout())): + # Verify ssl_shutdown_timeout was NOT passed assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs - # Test with HTTP + # Test with HTTP (should not have ssl_shutdown_timeout anyway) + req = ClientRequest("GET", URL("http://example.com"), loop=loop) + with closing(await conn.connect(req, [], ClientTimeout())): + assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs + + await conn.close() + + +@pytest.mark.skipif( + sys.version_info < (3, 11), reason="ssl_shutdown_timeout requires Python 3.11+" +) +async def test_tcp_connector_ssl_shutdown_timeout_nonzero_passed( + loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock +) -> None: + """Test that non-zero ssl_shutdown_timeout IS passed to create_connection on Python 3.11+.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=5.0) + + with mock.patch.object( + conn._loop, "create_connection", autospec=True, spec_set=True + ) as create_connection: + create_connection.return_value = mock.Mock(), mock.Mock() + + # Test with HTTPS + req = ClientRequest("GET", URL("https://example.com"), loop=loop) + with closing(await conn.connect(req, [], ClientTimeout())): + # Verify ssl_shutdown_timeout WAS passed + assert create_connection.call_args.kwargs["ssl_shutdown_timeout"] == 5.0 + + # Test with HTTP (should not have ssl_shutdown_timeout) req = ClientRequest("GET", URL("http://example.com"), loop=loop) with closing(await conn.connect(req, [], ClientTimeout())): assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs @@ -2201,11 +2444,142 @@ async def test_tcp_connector_ssl_shutdown_timeout_not_passed_pre_311( await conn.close() +async def test_tcp_connector_close_abort_ssl_connections_in_conns( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that SSL connections in _conns are aborted when ssl_shutdown_timeout=0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + # Create mock SSL protocol + proto = mock.create_autospec(ResponseHandler, instance=True) + proto.closed = None + + # Create mock SSL transport + transport = mock.Mock() + transport.get_extra_info.return_value = mock.Mock() # Returns SSL context + proto.transport = transport + + # Add the protocol to _conns + key = ConnectionKey("host", 443, True, True, None, None, None) + conn._conns[key] = deque([(proto, loop.time())]) + + # Close the connector + await conn.close() + + # Verify abort was called for SSL connection + proto.abort.assert_called_once() + proto.close.assert_not_called() + + async def test_tcp_connector_allowed_protocols(loop: asyncio.AbstractEventLoop) -> None: conn = aiohttp.TCPConnector() assert conn.allowed_protocol_schema_set == {"", "tcp", "http", "https", "ws", "wss"} +async def test_start_tls_exception_with_ssl_shutdown_timeout_zero( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test _start_tls_connection exception handling with ssl_shutdown_timeout=0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + underlying_transport = mock.Mock() + req = mock.Mock() + req.server_hostname = None + req.host = "example.com" + req.is_ssl = mock.Mock(return_value=True) + + # Patch _get_ssl_context to return a valid context and make start_tls fail + with ( + mock.patch.object( + conn, "_get_ssl_context", return_value=ssl.create_default_context() + ), + mock.patch.object(conn._loop, "start_tls", side_effect=OSError("TLS failed")), + ): + with pytest.raises(OSError): + await conn._start_tls_connection(underlying_transport, req, ClientTimeout()) + + # Should abort, not close + underlying_transport.abort.assert_called_once() + underlying_transport.close.assert_not_called() + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Use test_start_tls_exception_with_ssl_shutdown_timeout_nonzero_pre_311 for Python < 3.11", +) +async def test_start_tls_exception_with_ssl_shutdown_timeout_nonzero( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test _start_tls_connection exception handling with ssl_shutdown_timeout>0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) + + underlying_transport = mock.Mock() + req = mock.Mock() + req.server_hostname = None + req.host = "example.com" + req.is_ssl = mock.Mock(return_value=True) + + # Patch _get_ssl_context to return a valid context and make start_tls fail + with ( + mock.patch.object( + conn, "_get_ssl_context", return_value=ssl.create_default_context() + ), + mock.patch.object(conn._loop, "start_tls", side_effect=OSError("TLS failed")), + ): + with pytest.raises(OSError): + await conn._start_tls_connection(underlying_transport, req, ClientTimeout()) + + # Should close, not abort + underlying_transport.close.assert_called_once() + underlying_transport.abort.assert_not_called() + + +@pytest.mark.skipif( + sys.version_info >= (3, 11), + reason="This test is for Python < 3.11 runtime warning behavior", +) +async def test_start_tls_exception_with_ssl_shutdown_timeout_nonzero_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test _start_tls_connection exception handling with ssl_shutdown_timeout>0 on Python < 3.11.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) + # Should have both deprecation and runtime warnings + assert len(w) == 2 + assert any(issubclass(warn.category, DeprecationWarning) for warn in w) + assert any(issubclass(warn.category, RuntimeWarning) for warn in w) + + underlying_transport = mock.Mock() + req = mock.Mock() + req.server_hostname = None + req.host = "example.com" + req.is_ssl = mock.Mock(return_value=True) + + # Patch _get_ssl_context to return a valid context and make start_tls fail + with ( + mock.patch.object( + conn, "_get_ssl_context", return_value=ssl.create_default_context() + ), + mock.patch.object(conn._loop, "start_tls", side_effect=OSError("TLS failed")), + ): + with pytest.raises(OSError): + await conn._start_tls_connection(underlying_transport, req, ClientTimeout()) + + # Should close, not abort + underlying_transport.close.assert_called_once() + underlying_transport.abort.assert_not_called() + + async def test_invalid_ssl_param() -> None: with pytest.raises(TypeError): aiohttp.TCPConnector(ssl=object()) # type: ignore[arg-type] diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 6094cdcb894..783af5a5089 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -2,7 +2,6 @@ import gc import socket import ssl -import sys import unittest from unittest import mock @@ -1045,23 +1044,14 @@ async def make_conn() -> aiohttp.TCPConnector: ) ) - if sys.version_info >= (3, 11): - tls_m.assert_called_with( - mock.ANY, - mock.ANY, - _SSL_CONTEXT_VERIFIED, - server_hostname="www.python.org", - ssl_handshake_timeout=mock.ANY, - ssl_shutdown_timeout=0.1, - ) - else: - tls_m.assert_called_with( - mock.ANY, - mock.ANY, - _SSL_CONTEXT_VERIFIED, - server_hostname="www.python.org", - ssl_handshake_timeout=mock.ANY, - ) + # ssl_shutdown_timeout=0 is not passed to start_tls + tls_m.assert_called_with( + mock.ANY, + mock.ANY, + _SSL_CONTEXT_VERIFIED, + server_hostname="www.python.org", + ssl_handshake_timeout=mock.ANY, + ) self.assertEqual(req.url.path, "/") self.assertEqual(proxy_req.method, "CONNECT") From 27c2ceed7c842cf941001918dc89be0c18f2e17a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Jun 2025 08:45:52 -0500 Subject: [PATCH 6/8] [PR #11148/86a9a38 backport][3.13] Abort ssl connections on close when ssl_shutdown_timeout is 0 (#11156) --- CHANGES/11148.deprecation.rst | 1 + CHANGES/11148.feature.rst | 10 + aiohttp/client.py | 9 +- aiohttp/client_proto.py | 9 + aiohttp/connector.py | 106 +++++++-- docs/client_reference.rst | 60 +++-- tests/test_client_functional.py | 5 +- tests/test_client_proto.py | 40 ++++ tests/test_client_session.py | 99 ++++++-- tests/test_connector.py | 396 +++++++++++++++++++++++++++++++- tests/test_proxy.py | 26 +-- 11 files changed, 670 insertions(+), 91 deletions(-) create mode 120000 CHANGES/11148.deprecation.rst create mode 100644 CHANGES/11148.feature.rst diff --git a/CHANGES/11148.deprecation.rst b/CHANGES/11148.deprecation.rst new file mode 120000 index 00000000000..f4ddfb298af --- /dev/null +++ b/CHANGES/11148.deprecation.rst @@ -0,0 +1 @@ +11148.feature.rst \ No newline at end of file diff --git a/CHANGES/11148.feature.rst b/CHANGES/11148.feature.rst new file mode 100644 index 00000000000..6c47c93c7ba --- /dev/null +++ b/CHANGES/11148.feature.rst @@ -0,0 +1,10 @@ +Improved SSL connection handling by changing the default ``ssl_shutdown_timeout`` +from ``0.1`` to ``0`` seconds. SSL connections now use Python's default graceful +shutdown during normal operation but are aborted immediately when the connector +is closed, providing optimal behavior for both cases. Also added support for +``ssl_shutdown_timeout=0`` on all Python versions. Previously, this value was +rejected on Python 3.11+ and ignored on earlier versions. Non-zero values on +Python < 3.11 now trigger a ``RuntimeWarning`` -- by :user:`bdraco`. + +The ``ssl_shutdown_timeout`` parameter is now deprecated and will be removed in +aiohttp 4.0 as there is no clear use case for changing the default. diff --git a/aiohttp/client.py b/aiohttp/client.py index 576a965ba5d..ce95e5cb39e 100644 --- a/aiohttp/client.py +++ b/aiohttp/client.py @@ -303,7 +303,7 @@ def __init__( max_field_size: int = 8190, fallback_charset_resolver: _CharsetResolver = lambda r, b: "utf-8", middlewares: Sequence[ClientMiddlewareType] = (), - ssl_shutdown_timeout: Optional[float] = 0.1, + ssl_shutdown_timeout: Union[_SENTINEL, None, float] = sentinel, ) -> None: # We initialise _connector to None immediately, as it's referenced in __del__() # and could cause issues if an exception occurs during initialisation. @@ -361,6 +361,13 @@ def __init__( "timeout.connect" ) + if ssl_shutdown_timeout is not sentinel: + warnings.warn( + "The ssl_shutdown_timeout parameter is deprecated and will be removed in aiohttp 4.0", + DeprecationWarning, + stacklevel=2, + ) + if connector is None: connector = TCPConnector(ssl_shutdown_timeout=ssl_shutdown_timeout) diff --git a/aiohttp/client_proto.py b/aiohttp/client_proto.py index 7d00b366a79..e2fb1ce64cb 100644 --- a/aiohttp/client_proto.py +++ b/aiohttp/client_proto.py @@ -95,6 +95,15 @@ def close(self) -> None: self._payload = None self._drop_timeout() + def abort(self) -> None: + self._exception = None # Break cyclic references + transport = self.transport + if transport is not None: + transport.abort() + self.transport = None + self._payload = None + self._drop_timeout() + def is_connected(self) -> bool: return self.transport is not None and not self.transport.is_closing() diff --git a/aiohttp/connector.py b/aiohttp/connector.py index 075ef95c814..4479ae321bc 100644 --- a/aiohttp/connector.py +++ b/aiohttp/connector.py @@ -52,6 +52,7 @@ from .client_proto import ResponseHandler from .client_reqrep import ClientRequest, Fingerprint, _merge_ssl_params from .helpers import ( + _SENTINEL, ceil_timeout, is_ip_address, noop, @@ -231,15 +232,19 @@ def closed(self) -> bool: class _TransportPlaceholder: """placeholder for BaseConnector.connect function""" - __slots__ = ("closed",) + __slots__ = ("closed", "transport") def __init__(self, closed_future: asyncio.Future[Optional[Exception]]) -> None: """Initialize a placeholder for a transport.""" self.closed = closed_future + self.transport = None def close(self) -> None: """Close the placeholder.""" + def abort(self) -> None: + """Abort the placeholder (does nothing).""" + class BaseConnector: """Base connector class. @@ -469,9 +474,14 @@ def _cleanup_closed(self) -> None: timeout_ceil_threshold=self._timeout_ceil_threshold, ) - def close(self) -> Awaitable[None]: - """Close all opened transports.""" - if not (waiters := self._close()): + def close(self, *, abort_ssl: bool = False) -> Awaitable[None]: + """Close all opened transports. + + :param abort_ssl: If True, SSL connections will be aborted immediately + without performing the shutdown handshake. This provides + faster cleanup at the cost of less graceful disconnection. + """ + if not (waiters := self._close(abort_ssl=abort_ssl)): # If there are no connections to close, we can return a noop # awaitable to avoid scheduling a task on the event loop. return _DeprecationWaiter(noop()) @@ -484,7 +494,7 @@ def close(self) -> Awaitable[None]: task = self._loop.create_task(coro) return _DeprecationWaiter(task) - def _close(self) -> List[Awaitable[object]]: + def _close(self, *, abort_ssl: bool = False) -> List[Awaitable[object]]: waiters: List[Awaitable[object]] = [] if self._closed: @@ -506,12 +516,26 @@ def _close(self) -> List[Awaitable[object]]: for data in self._conns.values(): for proto, _ in data: - proto.close() + if ( + abort_ssl + and proto.transport + and proto.transport.get_extra_info("sslcontext") is not None + ): + proto.abort() + else: + proto.close() if closed := proto.closed: waiters.append(closed) for proto in self._acquired: - proto.close() + if ( + abort_ssl + and proto.transport + and proto.transport.get_extra_info("sslcontext") is not None + ): + proto.abort() + else: + proto.close() if closed := proto.closed: waiters.append(closed) @@ -881,11 +905,12 @@ class TCPConnector(BaseConnector): socket_factory - A SocketFactoryType function that, if supplied, will be used to create sockets given an AddrInfoType. - ssl_shutdown_timeout - Grace period for SSL shutdown handshake on TLS - connections. Default is 0.1 seconds. This usually - allows for a clean SSL shutdown by notifying the - remote peer of connection closure, while avoiding - excessive delays during connector cleanup. + ssl_shutdown_timeout - DEPRECATED. Will be removed in aiohttp 4.0. + Grace period for SSL shutdown handshake on TLS + connections. Default is 0 seconds (immediate abort). + This parameter allowed for a clean SSL shutdown by + notifying the remote peer of connection closure, + while avoiding excessive delays during connector cleanup. Note: Only takes effect on Python 3.11+. """ @@ -913,7 +938,7 @@ def __init__( happy_eyeballs_delay: Optional[float] = 0.25, interleave: Optional[int] = None, socket_factory: Optional[SocketFactoryType] = None, - ssl_shutdown_timeout: Optional[float] = 0.1, + ssl_shutdown_timeout: Union[_SENTINEL, None, float] = sentinel, ): super().__init__( keepalive_timeout=keepalive_timeout, @@ -946,14 +971,36 @@ def __init__( self._interleave = interleave self._resolve_host_tasks: Set["asyncio.Task[List[ResolveResult]]"] = set() self._socket_factory = socket_factory - self._ssl_shutdown_timeout = ssl_shutdown_timeout + self._ssl_shutdown_timeout: Optional[float] + # Handle ssl_shutdown_timeout with warning for Python < 3.11 + if ssl_shutdown_timeout is sentinel: + self._ssl_shutdown_timeout = 0 + else: + # Deprecation warning for ssl_shutdown_timeout parameter + warnings.warn( + "The ssl_shutdown_timeout parameter is deprecated and will be removed in aiohttp 4.0", + DeprecationWarning, + stacklevel=2, + ) + if ( + sys.version_info < (3, 11) + and ssl_shutdown_timeout is not None + and ssl_shutdown_timeout != 0 + ): + warnings.warn( + f"ssl_shutdown_timeout={ssl_shutdown_timeout} is ignored on Python < 3.11; " + "only ssl_shutdown_timeout=0 is supported. The timeout will be ignored.", + RuntimeWarning, + stacklevel=2, + ) + self._ssl_shutdown_timeout = ssl_shutdown_timeout - def _close(self) -> List[Awaitable[object]]: + def _close(self, *, abort_ssl: bool = False) -> List[Awaitable[object]]: """Close all ongoing DNS calls.""" for fut in chain.from_iterable(self._throttle_dns_futures.values()): fut.cancel() - waiters = super()._close() + waiters = super()._close(abort_ssl=abort_ssl) for t in self._resolve_host_tasks: t.cancel() @@ -961,11 +1008,20 @@ def _close(self) -> List[Awaitable[object]]: return waiters - async def close(self) -> None: - """Close all opened transports.""" + async def close(self, *, abort_ssl: bool = False) -> None: + """ + Close all opened transports. + + :param abort_ssl: If True, SSL connections will be aborted immediately + without performing the shutdown handshake. If False (default), + the behavior is determined by ssl_shutdown_timeout: + - If ssl_shutdown_timeout=0: connections are aborted + - If ssl_shutdown_timeout>0: graceful shutdown is performed + """ if self._resolver_owner: await self._resolver.close() - await super().close() + # Use abort_ssl param if explicitly set, otherwise use ssl_shutdown_timeout default + await super().close(abort_ssl=abort_ssl or self._ssl_shutdown_timeout == 0) @property def family(self) -> int: @@ -1200,7 +1256,7 @@ async def _wrap_create_connection( # Add ssl_shutdown_timeout for Python 3.11+ when SSL is used if ( kwargs.get("ssl") - and self._ssl_shutdown_timeout is not None + and self._ssl_shutdown_timeout and sys.version_info >= (3, 11) ): kwargs["ssl_shutdown_timeout"] = self._ssl_shutdown_timeout @@ -1343,10 +1399,7 @@ async def _start_tls_connection( ): try: # ssl_shutdown_timeout is only available in Python 3.11+ - if ( - sys.version_info >= (3, 11) - and self._ssl_shutdown_timeout is not None - ): + if sys.version_info >= (3, 11) and self._ssl_shutdown_timeout: tls_transport = await self._loop.start_tls( underlying_transport, tls_proto, @@ -1367,7 +1420,10 @@ async def _start_tls_connection( # We need to close the underlying transport since # `start_tls()` probably failed before it had a # chance to do this: - underlying_transport.close() + if self._ssl_shutdown_timeout == 0: + underlying_transport.abort() + else: + underlying_transport.close() raise if isinstance(tls_transport, asyncio.Transport): fingerprint = self._get_fingerprint(req) diff --git a/docs/client_reference.rst b/docs/client_reference.rst index 1644c57054b..ab16e35aed5 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -58,7 +58,7 @@ The client session supports the context manager protocol for self closing. max_line_size=8190, \ max_field_size=8190, \ fallback_charset_resolver=lambda r, b: "utf-8", \ - ssl_shutdown_timeout=0.1) + ssl_shutdown_timeout=0) The class for creating client sessions and making requests. @@ -257,16 +257,31 @@ The client session supports the context manager protocol for self closing. .. versionadded:: 3.8.6 - :param float ssl_shutdown_timeout: Grace period for SSL shutdown handshake on TLS - connections (``0.1`` seconds by default). This usually provides sufficient time - to notify the remote peer of connection closure, helping prevent broken - connections on the server side, while minimizing delays during connector - cleanup. This timeout is passed to the underlying :class:`TCPConnector` - when one is created automatically. Note: This parameter only takes effect - on Python 3.11+. + :param float ssl_shutdown_timeout: **(DEPRECATED)** This parameter is deprecated + and will be removed in aiohttp 4.0. Grace period for SSL shutdown handshake on + TLS connections when the connector is closed (``0`` seconds by default). + By default (``0``), SSL connections are aborted immediately when the + connector is closed, without performing the shutdown handshake. During + normal operation, SSL connections use Python's default SSL shutdown + behavior. Setting this to a positive value (e.g., ``0.1``) will perform + a graceful shutdown when closing the connector, notifying the remote + peer which can help prevent "connection reset" errors at the cost of + additional cleanup time. This timeout is passed to the underlying + :class:`TCPConnector` when one is created automatically. + Note: On Python versions prior to 3.11, only a value of ``0`` is supported; + other values will trigger a warning. .. versionadded:: 3.12.5 + .. versionchanged:: 3.12.11 + Changed default from ``0.1`` to ``0`` to abort SSL connections + immediately when the connector is closed. Added support for + ``ssl_shutdown_timeout=0`` on all Python versions. A :exc:`RuntimeWarning` + is issued when non-zero values are passed on Python < 3.11. + + .. deprecated:: 3.12.11 + This parameter is deprecated and will be removed in aiohttp 4.0. + .. attribute:: closed ``True`` if the session has been closed, ``False`` otherwise. @@ -1196,7 +1211,7 @@ is controlled by *force_close* constructor's parameter). force_close=False, limit=100, limit_per_host=0, \ enable_cleanup_closed=False, timeout_ceil_threshold=5, \ happy_eyeballs_delay=0.25, interleave=None, loop=None, \ - socket_factory=None, ssl_shutdown_timeout=0.1) + socket_factory=None, ssl_shutdown_timeout=0) Connector for working with *HTTP* and *HTTPS* via *TCP* sockets. @@ -1323,16 +1338,29 @@ is controlled by *force_close* constructor's parameter). .. versionadded:: 3.12 - :param float ssl_shutdown_timeout: Grace period for SSL shutdown on TLS - connections (``0.1`` seconds by default). This parameter balances two - important considerations: usually providing sufficient time to notify - the remote server (which helps prevent "connection reset" errors), - while avoiding unnecessary delays during connector cleanup. - The default value provides a reasonable compromise for most use cases. - Note: This parameter only takes effect on Python 3.11+. + :param float ssl_shutdown_timeout: **(DEPRECATED)** This parameter is deprecated + and will be removed in aiohttp 4.0. Grace period for SSL shutdown on TLS + connections when the connector is closed (``0`` seconds by default). + By default (``0``), SSL connections are aborted immediately when the + connector is closed, without performing the shutdown handshake. During + normal operation, SSL connections use Python's default SSL shutdown + behavior. Setting this to a positive value (e.g., ``0.1``) will perform + a graceful shutdown when closing the connector, notifying the remote + server which can help prevent "connection reset" errors at the cost of + additional cleanup time. Note: On Python versions prior to 3.11, only + a value of ``0`` is supported; other values will trigger a warning. .. versionadded:: 3.12.5 + .. versionchanged:: 3.12.11 + Changed default from ``0.1`` to ``0`` to abort SSL connections + immediately when the connector is closed. Added support for + ``ssl_shutdown_timeout=0`` on all Python versions. A :exc:`RuntimeWarning` + is issued when non-zero values are passed on Python < 3.11. + + .. deprecated:: 3.12.11 + This parameter is deprecated and will be removed in aiohttp 4.0. + .. attribute:: family *TCP* socket family e.g. :data:`socket.AF_INET` or diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py index 5c18178b714..08cc5c97538 100644 --- a/tests/test_client_functional.py +++ b/tests/test_client_functional.py @@ -698,7 +698,10 @@ async def test_ssl_client_shutdown_timeout( ) -> None: # Test that ssl_shutdown_timeout is properly used during connection closure - connector = aiohttp.TCPConnector(ssl=client_ssl_ctx, ssl_shutdown_timeout=0.1) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + connector = aiohttp.TCPConnector(ssl=client_ssl_ctx, ssl_shutdown_timeout=0.1) async def streaming_handler(request: web.Request) -> NoReturn: # Create a streaming response that continuously sends data diff --git a/tests/test_client_proto.py b/tests/test_client_proto.py index 2a42996950f..b75ebae1137 100644 --- a/tests/test_client_proto.py +++ b/tests/test_client_proto.py @@ -303,3 +303,43 @@ async def test_closed_property_after_connection_lost( # After connection_lost, closed should return None if it was never accessed assert proto.closed is None + + +async def test_abort(loop: asyncio.AbstractEventLoop) -> None: + """Test the abort() method.""" + proto = ResponseHandler(loop=loop) + + # Create a mock transport + transport = mock.Mock() + proto.connection_made(transport) + + # Set up some state + proto._payload = mock.Mock() + + # Mock _drop_timeout method using patch.object + with mock.patch.object(proto, "_drop_timeout") as mock_drop_timeout: + # Call abort + proto.abort() + + # Verify transport.abort() was called + transport.abort.assert_called_once() + + # Verify cleanup + assert proto.transport is None + assert proto._payload is None + assert proto._exception is None # type: ignore[unreachable] + mock_drop_timeout.assert_called_once() + + +async def test_abort_without_transport(loop: asyncio.AbstractEventLoop) -> None: + """Test abort() when transport is None.""" + proto = ResponseHandler(loop=loop) + + # Mock _drop_timeout method using patch.object + with mock.patch.object(proto, "_drop_timeout") as mock_drop_timeout: + # Call abort without transport + proto.abort() + + # Should not raise and should still clean up + assert proto._exception is None + mock_drop_timeout.assert_not_called() diff --git a/tests/test_client_session.py b/tests/test_client_session.py index 2702350f132..754cac1b47e 100644 --- a/tests/test_client_session.py +++ b/tests/test_client_session.py @@ -3,6 +3,8 @@ import gc import io import json +import sys +import warnings from collections import deque from http.cookies import BaseCookie, SimpleCookie from typing import Any, Awaitable, Callable, Iterator, List, Optional, cast @@ -310,32 +312,91 @@ async def test_create_connector(create_session, loop, mocker) -> None: assert connector.close.called +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Use test_ssl_shutdown_timeout_passed_to_connector_pre_311 for Python < 3.11", +) async def test_ssl_shutdown_timeout_passed_to_connector() -> None: - # Test default value + # Test default value (no warning expected) async with ClientSession() as session: assert isinstance(session.connector, TCPConnector) - assert session.connector._ssl_shutdown_timeout == 0.1 + assert session.connector._ssl_shutdown_timeout == 0 - # Test custom value - async with ClientSession(ssl_shutdown_timeout=1.0) as session: - assert isinstance(session.connector, TCPConnector) - assert session.connector._ssl_shutdown_timeout == 1.0 + # Test custom value - expect deprecation warning + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + async with ClientSession(ssl_shutdown_timeout=1.0) as session: + assert isinstance(session.connector, TCPConnector) + assert session.connector._ssl_shutdown_timeout == 1.0 - # Test None value - async with ClientSession(ssl_shutdown_timeout=None) as session: - assert isinstance(session.connector, TCPConnector) - assert session.connector._ssl_shutdown_timeout is None + # Test None value - expect deprecation warning + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + async with ClientSession(ssl_shutdown_timeout=None) as session: + assert isinstance(session.connector, TCPConnector) + assert session.connector._ssl_shutdown_timeout is None # Test that it doesn't affect when custom connector is provided - custom_conn = TCPConnector(ssl_shutdown_timeout=2.0) - async with ClientSession( - connector=custom_conn, ssl_shutdown_timeout=1.0 - ) as session: - assert session.connector is not None - assert isinstance(session.connector, TCPConnector) - assert ( - session.connector._ssl_shutdown_timeout == 2.0 - ) # Should use connector's value + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + custom_conn = TCPConnector(ssl_shutdown_timeout=2.0) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + async with ClientSession( + connector=custom_conn, ssl_shutdown_timeout=1.0 + ) as session: + assert session.connector is not None + assert isinstance(session.connector, TCPConnector) + assert ( + session.connector._ssl_shutdown_timeout == 2.0 + ) # Should use connector's value + + +@pytest.mark.skipif( + sys.version_info >= (3, 11), + reason="This test is for Python < 3.11 runtime warning behavior", +) +async def test_ssl_shutdown_timeout_passed_to_connector_pre_311() -> None: + """Test that both deprecation and runtime warnings are issued on Python < 3.11.""" + # Test custom value - expect both deprecation and runtime warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + async with ClientSession(ssl_shutdown_timeout=1.0) as session: + assert isinstance(session.connector, TCPConnector) + assert session.connector._ssl_shutdown_timeout == 1.0 + # Should have deprecation warnings (from ClientSession and TCPConnector) and runtime warning + # ClientSession emits 1 DeprecationWarning, TCPConnector emits 1 DeprecationWarning + 1 RuntimeWarning = 3 total + assert len(w) == 3 + deprecation_count = sum( + 1 for warn in w if issubclass(warn.category, DeprecationWarning) + ) + runtime_count = sum( + 1 for warn in w if issubclass(warn.category, RuntimeWarning) + ) + assert deprecation_count == 2 # One from ClientSession, one from TCPConnector + assert runtime_count == 1 # One from TCPConnector + + # Test with custom connector + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + custom_conn = TCPConnector(ssl_shutdown_timeout=2.0) + # Should have both deprecation and runtime warnings + assert len(w) == 2 + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + async with ClientSession( + connector=custom_conn, ssl_shutdown_timeout=1.0 + ) as session: + assert session.connector is not None + assert isinstance(session.connector, TCPConnector) + assert ( + session.connector._ssl_shutdown_timeout == 2.0 + ) # Should use connector's value def test_connector_loop(loop: asyncio.AbstractEventLoop) -> None: diff --git a/tests/test_connector.py b/tests/test_connector.py index ad5581d625f..f4f33f74cd0 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -8,6 +8,7 @@ import ssl import sys import uuid +import warnings from collections import defaultdict, deque from concurrent import futures from contextlib import closing, suppress @@ -2042,25 +2043,55 @@ async def test_tcp_connector_ctor() -> None: await conn.close() +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Use test_tcp_connector_ssl_shutdown_timeout_pre_311 for Python < 3.11", +) async def test_tcp_connector_ssl_shutdown_timeout( loop: asyncio.AbstractEventLoop, ) -> None: - # Test default value + # Test default value (no warning expected) conn = aiohttp.TCPConnector() - assert conn._ssl_shutdown_timeout == 0.1 + assert conn._ssl_shutdown_timeout == 0 await conn.close() - # Test custom value - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) + # Test custom value - expect deprecation warning + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) assert conn._ssl_shutdown_timeout == 1.0 await conn.close() - # Test None value - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None) + # Test None value - expect deprecation warning + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None) assert conn._ssl_shutdown_timeout is None await conn.close() +@pytest.mark.skipif( + sys.version_info >= (3, 11), + reason="This test is for Python < 3.11 runtime warning behavior", +) +async def test_tcp_connector_ssl_shutdown_timeout_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that both deprecation and runtime warnings are issued on Python < 3.11.""" + # Test custom value - expect both deprecation and runtime warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) + # Should have both deprecation and runtime warnings + assert len(w) == 2 + assert any(issubclass(warn.category, DeprecationWarning) for warn in w) + assert any(issubclass(warn.category, RuntimeWarning) for warn in w) + assert conn._ssl_shutdown_timeout == 1.0 + await conn.close() + + @pytest.mark.skipif( sys.version_info < (3, 11), reason="ssl_shutdown_timeout requires Python 3.11+" ) @@ -2068,7 +2099,10 @@ async def test_tcp_connector_ssl_shutdown_timeout_passed_to_create_connection( loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock ) -> None: # Test that ssl_shutdown_timeout is passed to create_connection for SSL connections - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) with mock.patch.object( conn._loop, "create_connection", autospec=True, spec_set=True @@ -2083,7 +2117,10 @@ async def test_tcp_connector_ssl_shutdown_timeout_passed_to_create_connection( await conn.close() # Test with None value - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None) with mock.patch.object( conn._loop, "create_connection", autospec=True, spec_set=True @@ -2099,7 +2136,10 @@ async def test_tcp_connector_ssl_shutdown_timeout_passed_to_create_connection( await conn.close() # Test that ssl_shutdown_timeout is NOT passed for non-SSL connections - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) with mock.patch.object( conn._loop, "create_connection", autospec=True, spec_set=True @@ -2120,7 +2160,178 @@ async def test_tcp_connector_ssl_shutdown_timeout_not_passed_pre_311( loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock ) -> None: # Test that ssl_shutdown_timeout is NOT passed to create_connection on Python < 3.11 - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) + # Should have both deprecation and runtime warnings + assert len(w) == 2 + assert any(issubclass(warn.category, DeprecationWarning) for warn in w) + assert any(issubclass(warn.category, RuntimeWarning) for warn in w) + + with mock.patch.object( + conn._loop, "create_connection", autospec=True, spec_set=True + ) as create_connection: + create_connection.return_value = mock.Mock(), mock.Mock() + + # Test with HTTPS + req = ClientRequest("GET", URL("https://example.com"), loop=loop) + with closing(await conn.connect(req, [], ClientTimeout())): + assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs + + # Test with HTTP + req = ClientRequest("GET", URL("http://example.com"), loop=loop) + with closing(await conn.connect(req, [], ClientTimeout())): + assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs + + await conn.close() + + +async def test_tcp_connector_close_abort_ssl_when_shutdown_timeout_zero( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that close() uses abort() for SSL connections when ssl_shutdown_timeout=0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + # Create a mock SSL protocol + proto = mock.create_autospec(ResponseHandler, instance=True) + proto.closed = None + + # Create mock SSL transport + transport = mock.Mock() + transport.get_extra_info.return_value = mock.Mock() # Returns SSL context + transport.is_closing.return_value = False + proto.transport = transport + + # Add the protocol to acquired connections + conn._acquired.add(proto) + + # Close the connector + await conn.close() + + # Verify abort was called instead of close for SSL connection + proto.abort.assert_called_once() + proto.close.assert_not_called() + + +async def test_tcp_connector_close_doesnt_abort_non_ssl_when_shutdown_timeout_zero( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that close() still uses close() for non-SSL connections even when ssl_shutdown_timeout=0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + # Create a mock non-SSL protocol + proto = mock.create_autospec(ResponseHandler, instance=True) + proto.closed = None + + # Create mock non-SSL transport + transport = mock.Mock() + transport.get_extra_info.return_value = None # No SSL context + transport.is_closing.return_value = False + proto.transport = transport + + # Add the protocol to acquired connections + conn._acquired.add(proto) + + # Close the connector + await conn.close() + + # Verify close was called for non-SSL connection + proto.close.assert_called_once() + proto.abort.assert_not_called() + + +async def test_tcp_connector_ssl_shutdown_timeout_warning_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that a warning is issued for non-zero ssl_shutdown_timeout on Python < 3.11.""" + with ( + mock.patch.object(sys, "version_info", (3, 10, 0)), + warnings.catch_warnings(record=True) as w, + ): + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=5.0) + + # We should get two warnings: deprecation and runtime warning + assert len(w) == 2 + + # Find each warning type + deprecation_warning = next( + (warn for warn in w if issubclass(warn.category, DeprecationWarning)), None + ) + runtime_warning = next( + (warn for warn in w if issubclass(warn.category, RuntimeWarning)), None + ) + + assert deprecation_warning is not None + assert "ssl_shutdown_timeout parameter is deprecated" in str( + deprecation_warning.message + ) + + assert runtime_warning is not None + assert "ssl_shutdown_timeout=5.0 is ignored on Python < 3.11" in str( + runtime_warning.message + ) + assert "only ssl_shutdown_timeout=0 is supported" in str( + runtime_warning.message + ) + + # Verify the value is still stored + assert conn._ssl_shutdown_timeout == 5.0 + + await conn.close() + + +async def test_tcp_connector_ssl_shutdown_timeout_zero_no_warning_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that no warning is issued for ssl_shutdown_timeout=0 on Python < 3.11.""" + with ( + mock.patch.object(sys, "version_info", (3, 10, 0)), + warnings.catch_warnings(record=True) as w, + ): + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + # We should get one warning: deprecation + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "ssl_shutdown_timeout parameter is deprecated" in str(w[0].message) + assert conn._ssl_shutdown_timeout == 0 + + await conn.close() + + +async def test_tcp_connector_ssl_shutdown_timeout_sentinel_no_warning_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that no warning is issued when sentinel is used on Python < 3.11.""" + with ( + mock.patch.object(sys, "version_info", (3, 10, 0)), + warnings.catch_warnings(record=True) as w, + ): + warnings.simplefilter("always") + conn = aiohttp.TCPConnector() # Uses sentinel by default + + assert len(w) == 0 + assert conn._ssl_shutdown_timeout == 0 # Default value + + await conn.close() + + +async def test_tcp_connector_ssl_shutdown_timeout_zero_not_passed( + loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock +) -> None: + """Test that ssl_shutdown_timeout=0 is NOT passed to create_connection.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) with mock.patch.object( conn._loop, "create_connection", autospec=True, spec_set=True @@ -2130,9 +2341,10 @@ async def test_tcp_connector_ssl_shutdown_timeout_not_passed_pre_311( # Test with HTTPS req = ClientRequest("GET", URL("https://example.com"), loop=loop) with closing(await conn.connect(req, [], ClientTimeout())): + # Verify ssl_shutdown_timeout was NOT passed assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs - # Test with HTTP + # Test with HTTP (should not have ssl_shutdown_timeout anyway) req = ClientRequest("GET", URL("http://example.com"), loop=loop) with closing(await conn.connect(req, [], ClientTimeout())): assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs @@ -2140,11 +2352,173 @@ async def test_tcp_connector_ssl_shutdown_timeout_not_passed_pre_311( await conn.close() +@pytest.mark.skipif( + sys.version_info < (3, 11), reason="ssl_shutdown_timeout requires Python 3.11+" +) +async def test_tcp_connector_ssl_shutdown_timeout_nonzero_passed( + loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock +) -> None: + """Test that non-zero ssl_shutdown_timeout IS passed to create_connection on Python 3.11+.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=5.0) + + with mock.patch.object( + conn._loop, "create_connection", autospec=True, spec_set=True + ) as create_connection: + create_connection.return_value = mock.Mock(), mock.Mock() + + # Test with HTTPS + req = ClientRequest("GET", URL("https://example.com"), loop=loop) + with closing(await conn.connect(req, [], ClientTimeout())): + # Verify ssl_shutdown_timeout WAS passed + assert create_connection.call_args.kwargs["ssl_shutdown_timeout"] == 5.0 + + # Test with HTTP (should not have ssl_shutdown_timeout) + req = ClientRequest("GET", URL("http://example.com"), loop=loop) + with closing(await conn.connect(req, [], ClientTimeout())): + assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs + + await conn.close() + + +async def test_tcp_connector_close_abort_ssl_connections_in_conns( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that SSL connections in _conns are aborted when ssl_shutdown_timeout=0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + # Create mock SSL protocol + proto = mock.create_autospec(ResponseHandler, instance=True) + proto.closed = None + + # Create mock SSL transport + transport = mock.Mock() + transport.get_extra_info.return_value = mock.Mock() # Returns SSL context + proto.transport = transport + + # Add the protocol to _conns + key = ConnectionKey("host", 443, True, True, None, None, None) + conn._conns[key] = deque([(proto, loop.time())]) + + # Close the connector + await conn.close() + + # Verify abort was called for SSL connection + proto.abort.assert_called_once() + proto.close.assert_not_called() + + async def test_tcp_connector_allowed_protocols(loop: asyncio.AbstractEventLoop) -> None: conn = aiohttp.TCPConnector() assert conn.allowed_protocol_schema_set == {"", "tcp", "http", "https", "ws", "wss"} +async def test_start_tls_exception_with_ssl_shutdown_timeout_zero( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test _start_tls_connection exception handling with ssl_shutdown_timeout=0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + underlying_transport = mock.Mock() + req = mock.Mock() + req.server_hostname = None + req.host = "example.com" + req.is_ssl = mock.Mock(return_value=True) + + # Patch _get_ssl_context to return a valid context and make start_tls fail + with ( + mock.patch.object( + conn, "_get_ssl_context", return_value=ssl.create_default_context() + ), + mock.patch.object(conn._loop, "start_tls", side_effect=OSError("TLS failed")), + ): + with pytest.raises(OSError): + await conn._start_tls_connection(underlying_transport, req, ClientTimeout()) + + # Should abort, not close + underlying_transport.abort.assert_called_once() + underlying_transport.close.assert_not_called() + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Use test_start_tls_exception_with_ssl_shutdown_timeout_nonzero_pre_311 for Python < 3.11", +) +async def test_start_tls_exception_with_ssl_shutdown_timeout_nonzero( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test _start_tls_connection exception handling with ssl_shutdown_timeout>0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) + + underlying_transport = mock.Mock() + req = mock.Mock() + req.server_hostname = None + req.host = "example.com" + req.is_ssl = mock.Mock(return_value=True) + + # Patch _get_ssl_context to return a valid context and make start_tls fail + with ( + mock.patch.object( + conn, "_get_ssl_context", return_value=ssl.create_default_context() + ), + mock.patch.object(conn._loop, "start_tls", side_effect=OSError("TLS failed")), + ): + with pytest.raises(OSError): + await conn._start_tls_connection(underlying_transport, req, ClientTimeout()) + + # Should close, not abort + underlying_transport.close.assert_called_once() + underlying_transport.abort.assert_not_called() + + +@pytest.mark.skipif( + sys.version_info >= (3, 11), + reason="This test is for Python < 3.11 runtime warning behavior", +) +async def test_start_tls_exception_with_ssl_shutdown_timeout_nonzero_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test _start_tls_connection exception handling with ssl_shutdown_timeout>0 on Python < 3.11.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) + # Should have both deprecation and runtime warnings + assert len(w) == 2 + assert any(issubclass(warn.category, DeprecationWarning) for warn in w) + assert any(issubclass(warn.category, RuntimeWarning) for warn in w) + + underlying_transport = mock.Mock() + req = mock.Mock() + req.server_hostname = None + req.host = "example.com" + req.is_ssl = mock.Mock(return_value=True) + + # Patch _get_ssl_context to return a valid context and make start_tls fail + with ( + mock.patch.object( + conn, "_get_ssl_context", return_value=ssl.create_default_context() + ), + mock.patch.object(conn._loop, "start_tls", side_effect=OSError("TLS failed")), + ): + with pytest.raises(OSError): + await conn._start_tls_connection(underlying_transport, req, ClientTimeout()) + + # Should close, not abort + underlying_transport.close.assert_called_once() + underlying_transport.abort.assert_not_called() + + async def test_invalid_ssl_param() -> None: with pytest.raises(TypeError): aiohttp.TCPConnector(ssl=object()) # type: ignore[arg-type] diff --git a/tests/test_proxy.py b/tests/test_proxy.py index f5ebf6adc4f..4c506cc5730 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -935,24 +935,14 @@ async def make_conn(): self.loop.run_until_complete( connector._create_connection(req, None, aiohttp.ClientTimeout()) ) - - if sys.version_info >= (3, 11): - self.loop.start_tls.assert_called_with( - mock.ANY, - mock.ANY, - _SSL_CONTEXT_VERIFIED, - server_hostname="www.python.org", - ssl_handshake_timeout=mock.ANY, - ssl_shutdown_timeout=0.1, - ) - else: - self.loop.start_tls.assert_called_with( - mock.ANY, - mock.ANY, - _SSL_CONTEXT_VERIFIED, - server_hostname="www.python.org", - ssl_handshake_timeout=mock.ANY, - ) + # ssl_shutdown_timeout=0 is not passed to start_tls + self.loop.start_tls.assert_called_with( + mock.ANY, + mock.ANY, + _SSL_CONTEXT_VERIFIED, + server_hostname="www.python.org", + ssl_handshake_timeout=mock.ANY, + ) self.assertEqual(req.url.path, "/") self.assertEqual(proxy_req.method, "CONNECT") From ca39f6a05d6805bc6de956d11dce7baa67e63e41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Jun 2025 08:53:02 -0500 Subject: [PATCH 7/8] [PR #11148/86a9a38 backport][3.12] Abort ssl connections on close when ssl_shutdown_timeout is 0 (#11155) --- CHANGES/11148.deprecation.rst | 1 + CHANGES/11148.feature.rst | 10 + aiohttp/client.py | 9 +- aiohttp/client_proto.py | 9 + aiohttp/connector.py | 106 +++++++-- docs/client_reference.rst | 60 +++-- tests/test_client_functional.py | 5 +- tests/test_client_proto.py | 40 ++++ tests/test_client_session.py | 99 ++++++-- tests/test_connector.py | 396 +++++++++++++++++++++++++++++++- tests/test_proxy.py | 26 +-- 11 files changed, 670 insertions(+), 91 deletions(-) create mode 120000 CHANGES/11148.deprecation.rst create mode 100644 CHANGES/11148.feature.rst diff --git a/CHANGES/11148.deprecation.rst b/CHANGES/11148.deprecation.rst new file mode 120000 index 00000000000..f4ddfb298af --- /dev/null +++ b/CHANGES/11148.deprecation.rst @@ -0,0 +1 @@ +11148.feature.rst \ No newline at end of file diff --git a/CHANGES/11148.feature.rst b/CHANGES/11148.feature.rst new file mode 100644 index 00000000000..6c47c93c7ba --- /dev/null +++ b/CHANGES/11148.feature.rst @@ -0,0 +1,10 @@ +Improved SSL connection handling by changing the default ``ssl_shutdown_timeout`` +from ``0.1`` to ``0`` seconds. SSL connections now use Python's default graceful +shutdown during normal operation but are aborted immediately when the connector +is closed, providing optimal behavior for both cases. Also added support for +``ssl_shutdown_timeout=0`` on all Python versions. Previously, this value was +rejected on Python 3.11+ and ignored on earlier versions. Non-zero values on +Python < 3.11 now trigger a ``RuntimeWarning`` -- by :user:`bdraco`. + +The ``ssl_shutdown_timeout`` parameter is now deprecated and will be removed in +aiohttp 4.0 as there is no clear use case for changing the default. diff --git a/aiohttp/client.py b/aiohttp/client.py index 576a965ba5d..ce95e5cb39e 100644 --- a/aiohttp/client.py +++ b/aiohttp/client.py @@ -303,7 +303,7 @@ def __init__( max_field_size: int = 8190, fallback_charset_resolver: _CharsetResolver = lambda r, b: "utf-8", middlewares: Sequence[ClientMiddlewareType] = (), - ssl_shutdown_timeout: Optional[float] = 0.1, + ssl_shutdown_timeout: Union[_SENTINEL, None, float] = sentinel, ) -> None: # We initialise _connector to None immediately, as it's referenced in __del__() # and could cause issues if an exception occurs during initialisation. @@ -361,6 +361,13 @@ def __init__( "timeout.connect" ) + if ssl_shutdown_timeout is not sentinel: + warnings.warn( + "The ssl_shutdown_timeout parameter is deprecated and will be removed in aiohttp 4.0", + DeprecationWarning, + stacklevel=2, + ) + if connector is None: connector = TCPConnector(ssl_shutdown_timeout=ssl_shutdown_timeout) diff --git a/aiohttp/client_proto.py b/aiohttp/client_proto.py index 7d00b366a79..e2fb1ce64cb 100644 --- a/aiohttp/client_proto.py +++ b/aiohttp/client_proto.py @@ -95,6 +95,15 @@ def close(self) -> None: self._payload = None self._drop_timeout() + def abort(self) -> None: + self._exception = None # Break cyclic references + transport = self.transport + if transport is not None: + transport.abort() + self.transport = None + self._payload = None + self._drop_timeout() + def is_connected(self) -> bool: return self.transport is not None and not self.transport.is_closing() diff --git a/aiohttp/connector.py b/aiohttp/connector.py index 075ef95c814..4479ae321bc 100644 --- a/aiohttp/connector.py +++ b/aiohttp/connector.py @@ -52,6 +52,7 @@ from .client_proto import ResponseHandler from .client_reqrep import ClientRequest, Fingerprint, _merge_ssl_params from .helpers import ( + _SENTINEL, ceil_timeout, is_ip_address, noop, @@ -231,15 +232,19 @@ def closed(self) -> bool: class _TransportPlaceholder: """placeholder for BaseConnector.connect function""" - __slots__ = ("closed",) + __slots__ = ("closed", "transport") def __init__(self, closed_future: asyncio.Future[Optional[Exception]]) -> None: """Initialize a placeholder for a transport.""" self.closed = closed_future + self.transport = None def close(self) -> None: """Close the placeholder.""" + def abort(self) -> None: + """Abort the placeholder (does nothing).""" + class BaseConnector: """Base connector class. @@ -469,9 +474,14 @@ def _cleanup_closed(self) -> None: timeout_ceil_threshold=self._timeout_ceil_threshold, ) - def close(self) -> Awaitable[None]: - """Close all opened transports.""" - if not (waiters := self._close()): + def close(self, *, abort_ssl: bool = False) -> Awaitable[None]: + """Close all opened transports. + + :param abort_ssl: If True, SSL connections will be aborted immediately + without performing the shutdown handshake. This provides + faster cleanup at the cost of less graceful disconnection. + """ + if not (waiters := self._close(abort_ssl=abort_ssl)): # If there are no connections to close, we can return a noop # awaitable to avoid scheduling a task on the event loop. return _DeprecationWaiter(noop()) @@ -484,7 +494,7 @@ def close(self) -> Awaitable[None]: task = self._loop.create_task(coro) return _DeprecationWaiter(task) - def _close(self) -> List[Awaitable[object]]: + def _close(self, *, abort_ssl: bool = False) -> List[Awaitable[object]]: waiters: List[Awaitable[object]] = [] if self._closed: @@ -506,12 +516,26 @@ def _close(self) -> List[Awaitable[object]]: for data in self._conns.values(): for proto, _ in data: - proto.close() + if ( + abort_ssl + and proto.transport + and proto.transport.get_extra_info("sslcontext") is not None + ): + proto.abort() + else: + proto.close() if closed := proto.closed: waiters.append(closed) for proto in self._acquired: - proto.close() + if ( + abort_ssl + and proto.transport + and proto.transport.get_extra_info("sslcontext") is not None + ): + proto.abort() + else: + proto.close() if closed := proto.closed: waiters.append(closed) @@ -881,11 +905,12 @@ class TCPConnector(BaseConnector): socket_factory - A SocketFactoryType function that, if supplied, will be used to create sockets given an AddrInfoType. - ssl_shutdown_timeout - Grace period for SSL shutdown handshake on TLS - connections. Default is 0.1 seconds. This usually - allows for a clean SSL shutdown by notifying the - remote peer of connection closure, while avoiding - excessive delays during connector cleanup. + ssl_shutdown_timeout - DEPRECATED. Will be removed in aiohttp 4.0. + Grace period for SSL shutdown handshake on TLS + connections. Default is 0 seconds (immediate abort). + This parameter allowed for a clean SSL shutdown by + notifying the remote peer of connection closure, + while avoiding excessive delays during connector cleanup. Note: Only takes effect on Python 3.11+. """ @@ -913,7 +938,7 @@ def __init__( happy_eyeballs_delay: Optional[float] = 0.25, interleave: Optional[int] = None, socket_factory: Optional[SocketFactoryType] = None, - ssl_shutdown_timeout: Optional[float] = 0.1, + ssl_shutdown_timeout: Union[_SENTINEL, None, float] = sentinel, ): super().__init__( keepalive_timeout=keepalive_timeout, @@ -946,14 +971,36 @@ def __init__( self._interleave = interleave self._resolve_host_tasks: Set["asyncio.Task[List[ResolveResult]]"] = set() self._socket_factory = socket_factory - self._ssl_shutdown_timeout = ssl_shutdown_timeout + self._ssl_shutdown_timeout: Optional[float] + # Handle ssl_shutdown_timeout with warning for Python < 3.11 + if ssl_shutdown_timeout is sentinel: + self._ssl_shutdown_timeout = 0 + else: + # Deprecation warning for ssl_shutdown_timeout parameter + warnings.warn( + "The ssl_shutdown_timeout parameter is deprecated and will be removed in aiohttp 4.0", + DeprecationWarning, + stacklevel=2, + ) + if ( + sys.version_info < (3, 11) + and ssl_shutdown_timeout is not None + and ssl_shutdown_timeout != 0 + ): + warnings.warn( + f"ssl_shutdown_timeout={ssl_shutdown_timeout} is ignored on Python < 3.11; " + "only ssl_shutdown_timeout=0 is supported. The timeout will be ignored.", + RuntimeWarning, + stacklevel=2, + ) + self._ssl_shutdown_timeout = ssl_shutdown_timeout - def _close(self) -> List[Awaitable[object]]: + def _close(self, *, abort_ssl: bool = False) -> List[Awaitable[object]]: """Close all ongoing DNS calls.""" for fut in chain.from_iterable(self._throttle_dns_futures.values()): fut.cancel() - waiters = super()._close() + waiters = super()._close(abort_ssl=abort_ssl) for t in self._resolve_host_tasks: t.cancel() @@ -961,11 +1008,20 @@ def _close(self) -> List[Awaitable[object]]: return waiters - async def close(self) -> None: - """Close all opened transports.""" + async def close(self, *, abort_ssl: bool = False) -> None: + """ + Close all opened transports. + + :param abort_ssl: If True, SSL connections will be aborted immediately + without performing the shutdown handshake. If False (default), + the behavior is determined by ssl_shutdown_timeout: + - If ssl_shutdown_timeout=0: connections are aborted + - If ssl_shutdown_timeout>0: graceful shutdown is performed + """ if self._resolver_owner: await self._resolver.close() - await super().close() + # Use abort_ssl param if explicitly set, otherwise use ssl_shutdown_timeout default + await super().close(abort_ssl=abort_ssl or self._ssl_shutdown_timeout == 0) @property def family(self) -> int: @@ -1200,7 +1256,7 @@ async def _wrap_create_connection( # Add ssl_shutdown_timeout for Python 3.11+ when SSL is used if ( kwargs.get("ssl") - and self._ssl_shutdown_timeout is not None + and self._ssl_shutdown_timeout and sys.version_info >= (3, 11) ): kwargs["ssl_shutdown_timeout"] = self._ssl_shutdown_timeout @@ -1343,10 +1399,7 @@ async def _start_tls_connection( ): try: # ssl_shutdown_timeout is only available in Python 3.11+ - if ( - sys.version_info >= (3, 11) - and self._ssl_shutdown_timeout is not None - ): + if sys.version_info >= (3, 11) and self._ssl_shutdown_timeout: tls_transport = await self._loop.start_tls( underlying_transport, tls_proto, @@ -1367,7 +1420,10 @@ async def _start_tls_connection( # We need to close the underlying transport since # `start_tls()` probably failed before it had a # chance to do this: - underlying_transport.close() + if self._ssl_shutdown_timeout == 0: + underlying_transport.abort() + else: + underlying_transport.close() raise if isinstance(tls_transport, asyncio.Transport): fingerprint = self._get_fingerprint(req) diff --git a/docs/client_reference.rst b/docs/client_reference.rst index 1644c57054b..ab16e35aed5 100644 --- a/docs/client_reference.rst +++ b/docs/client_reference.rst @@ -58,7 +58,7 @@ The client session supports the context manager protocol for self closing. max_line_size=8190, \ max_field_size=8190, \ fallback_charset_resolver=lambda r, b: "utf-8", \ - ssl_shutdown_timeout=0.1) + ssl_shutdown_timeout=0) The class for creating client sessions and making requests. @@ -257,16 +257,31 @@ The client session supports the context manager protocol for self closing. .. versionadded:: 3.8.6 - :param float ssl_shutdown_timeout: Grace period for SSL shutdown handshake on TLS - connections (``0.1`` seconds by default). This usually provides sufficient time - to notify the remote peer of connection closure, helping prevent broken - connections on the server side, while minimizing delays during connector - cleanup. This timeout is passed to the underlying :class:`TCPConnector` - when one is created automatically. Note: This parameter only takes effect - on Python 3.11+. + :param float ssl_shutdown_timeout: **(DEPRECATED)** This parameter is deprecated + and will be removed in aiohttp 4.0. Grace period for SSL shutdown handshake on + TLS connections when the connector is closed (``0`` seconds by default). + By default (``0``), SSL connections are aborted immediately when the + connector is closed, without performing the shutdown handshake. During + normal operation, SSL connections use Python's default SSL shutdown + behavior. Setting this to a positive value (e.g., ``0.1``) will perform + a graceful shutdown when closing the connector, notifying the remote + peer which can help prevent "connection reset" errors at the cost of + additional cleanup time. This timeout is passed to the underlying + :class:`TCPConnector` when one is created automatically. + Note: On Python versions prior to 3.11, only a value of ``0`` is supported; + other values will trigger a warning. .. versionadded:: 3.12.5 + .. versionchanged:: 3.12.11 + Changed default from ``0.1`` to ``0`` to abort SSL connections + immediately when the connector is closed. Added support for + ``ssl_shutdown_timeout=0`` on all Python versions. A :exc:`RuntimeWarning` + is issued when non-zero values are passed on Python < 3.11. + + .. deprecated:: 3.12.11 + This parameter is deprecated and will be removed in aiohttp 4.0. + .. attribute:: closed ``True`` if the session has been closed, ``False`` otherwise. @@ -1196,7 +1211,7 @@ is controlled by *force_close* constructor's parameter). force_close=False, limit=100, limit_per_host=0, \ enable_cleanup_closed=False, timeout_ceil_threshold=5, \ happy_eyeballs_delay=0.25, interleave=None, loop=None, \ - socket_factory=None, ssl_shutdown_timeout=0.1) + socket_factory=None, ssl_shutdown_timeout=0) Connector for working with *HTTP* and *HTTPS* via *TCP* sockets. @@ -1323,16 +1338,29 @@ is controlled by *force_close* constructor's parameter). .. versionadded:: 3.12 - :param float ssl_shutdown_timeout: Grace period for SSL shutdown on TLS - connections (``0.1`` seconds by default). This parameter balances two - important considerations: usually providing sufficient time to notify - the remote server (which helps prevent "connection reset" errors), - while avoiding unnecessary delays during connector cleanup. - The default value provides a reasonable compromise for most use cases. - Note: This parameter only takes effect on Python 3.11+. + :param float ssl_shutdown_timeout: **(DEPRECATED)** This parameter is deprecated + and will be removed in aiohttp 4.0. Grace period for SSL shutdown on TLS + connections when the connector is closed (``0`` seconds by default). + By default (``0``), SSL connections are aborted immediately when the + connector is closed, without performing the shutdown handshake. During + normal operation, SSL connections use Python's default SSL shutdown + behavior. Setting this to a positive value (e.g., ``0.1``) will perform + a graceful shutdown when closing the connector, notifying the remote + server which can help prevent "connection reset" errors at the cost of + additional cleanup time. Note: On Python versions prior to 3.11, only + a value of ``0`` is supported; other values will trigger a warning. .. versionadded:: 3.12.5 + .. versionchanged:: 3.12.11 + Changed default from ``0.1`` to ``0`` to abort SSL connections + immediately when the connector is closed. Added support for + ``ssl_shutdown_timeout=0`` on all Python versions. A :exc:`RuntimeWarning` + is issued when non-zero values are passed on Python < 3.11. + + .. deprecated:: 3.12.11 + This parameter is deprecated and will be removed in aiohttp 4.0. + .. attribute:: family *TCP* socket family e.g. :data:`socket.AF_INET` or diff --git a/tests/test_client_functional.py b/tests/test_client_functional.py index 5c18178b714..08cc5c97538 100644 --- a/tests/test_client_functional.py +++ b/tests/test_client_functional.py @@ -698,7 +698,10 @@ async def test_ssl_client_shutdown_timeout( ) -> None: # Test that ssl_shutdown_timeout is properly used during connection closure - connector = aiohttp.TCPConnector(ssl=client_ssl_ctx, ssl_shutdown_timeout=0.1) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + connector = aiohttp.TCPConnector(ssl=client_ssl_ctx, ssl_shutdown_timeout=0.1) async def streaming_handler(request: web.Request) -> NoReturn: # Create a streaming response that continuously sends data diff --git a/tests/test_client_proto.py b/tests/test_client_proto.py index 2a42996950f..b75ebae1137 100644 --- a/tests/test_client_proto.py +++ b/tests/test_client_proto.py @@ -303,3 +303,43 @@ async def test_closed_property_after_connection_lost( # After connection_lost, closed should return None if it was never accessed assert proto.closed is None + + +async def test_abort(loop: asyncio.AbstractEventLoop) -> None: + """Test the abort() method.""" + proto = ResponseHandler(loop=loop) + + # Create a mock transport + transport = mock.Mock() + proto.connection_made(transport) + + # Set up some state + proto._payload = mock.Mock() + + # Mock _drop_timeout method using patch.object + with mock.patch.object(proto, "_drop_timeout") as mock_drop_timeout: + # Call abort + proto.abort() + + # Verify transport.abort() was called + transport.abort.assert_called_once() + + # Verify cleanup + assert proto.transport is None + assert proto._payload is None + assert proto._exception is None # type: ignore[unreachable] + mock_drop_timeout.assert_called_once() + + +async def test_abort_without_transport(loop: asyncio.AbstractEventLoop) -> None: + """Test abort() when transport is None.""" + proto = ResponseHandler(loop=loop) + + # Mock _drop_timeout method using patch.object + with mock.patch.object(proto, "_drop_timeout") as mock_drop_timeout: + # Call abort without transport + proto.abort() + + # Should not raise and should still clean up + assert proto._exception is None + mock_drop_timeout.assert_not_called() diff --git a/tests/test_client_session.py b/tests/test_client_session.py index 2702350f132..754cac1b47e 100644 --- a/tests/test_client_session.py +++ b/tests/test_client_session.py @@ -3,6 +3,8 @@ import gc import io import json +import sys +import warnings from collections import deque from http.cookies import BaseCookie, SimpleCookie from typing import Any, Awaitable, Callable, Iterator, List, Optional, cast @@ -310,32 +312,91 @@ async def test_create_connector(create_session, loop, mocker) -> None: assert connector.close.called +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Use test_ssl_shutdown_timeout_passed_to_connector_pre_311 for Python < 3.11", +) async def test_ssl_shutdown_timeout_passed_to_connector() -> None: - # Test default value + # Test default value (no warning expected) async with ClientSession() as session: assert isinstance(session.connector, TCPConnector) - assert session.connector._ssl_shutdown_timeout == 0.1 + assert session.connector._ssl_shutdown_timeout == 0 - # Test custom value - async with ClientSession(ssl_shutdown_timeout=1.0) as session: - assert isinstance(session.connector, TCPConnector) - assert session.connector._ssl_shutdown_timeout == 1.0 + # Test custom value - expect deprecation warning + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + async with ClientSession(ssl_shutdown_timeout=1.0) as session: + assert isinstance(session.connector, TCPConnector) + assert session.connector._ssl_shutdown_timeout == 1.0 - # Test None value - async with ClientSession(ssl_shutdown_timeout=None) as session: - assert isinstance(session.connector, TCPConnector) - assert session.connector._ssl_shutdown_timeout is None + # Test None value - expect deprecation warning + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + async with ClientSession(ssl_shutdown_timeout=None) as session: + assert isinstance(session.connector, TCPConnector) + assert session.connector._ssl_shutdown_timeout is None # Test that it doesn't affect when custom connector is provided - custom_conn = TCPConnector(ssl_shutdown_timeout=2.0) - async with ClientSession( - connector=custom_conn, ssl_shutdown_timeout=1.0 - ) as session: - assert session.connector is not None - assert isinstance(session.connector, TCPConnector) - assert ( - session.connector._ssl_shutdown_timeout == 2.0 - ) # Should use connector's value + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + custom_conn = TCPConnector(ssl_shutdown_timeout=2.0) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + async with ClientSession( + connector=custom_conn, ssl_shutdown_timeout=1.0 + ) as session: + assert session.connector is not None + assert isinstance(session.connector, TCPConnector) + assert ( + session.connector._ssl_shutdown_timeout == 2.0 + ) # Should use connector's value + + +@pytest.mark.skipif( + sys.version_info >= (3, 11), + reason="This test is for Python < 3.11 runtime warning behavior", +) +async def test_ssl_shutdown_timeout_passed_to_connector_pre_311() -> None: + """Test that both deprecation and runtime warnings are issued on Python < 3.11.""" + # Test custom value - expect both deprecation and runtime warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + async with ClientSession(ssl_shutdown_timeout=1.0) as session: + assert isinstance(session.connector, TCPConnector) + assert session.connector._ssl_shutdown_timeout == 1.0 + # Should have deprecation warnings (from ClientSession and TCPConnector) and runtime warning + # ClientSession emits 1 DeprecationWarning, TCPConnector emits 1 DeprecationWarning + 1 RuntimeWarning = 3 total + assert len(w) == 3 + deprecation_count = sum( + 1 for warn in w if issubclass(warn.category, DeprecationWarning) + ) + runtime_count = sum( + 1 for warn in w if issubclass(warn.category, RuntimeWarning) + ) + assert deprecation_count == 2 # One from ClientSession, one from TCPConnector + assert runtime_count == 1 # One from TCPConnector + + # Test with custom connector + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + custom_conn = TCPConnector(ssl_shutdown_timeout=2.0) + # Should have both deprecation and runtime warnings + assert len(w) == 2 + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + async with ClientSession( + connector=custom_conn, ssl_shutdown_timeout=1.0 + ) as session: + assert session.connector is not None + assert isinstance(session.connector, TCPConnector) + assert ( + session.connector._ssl_shutdown_timeout == 2.0 + ) # Should use connector's value def test_connector_loop(loop: asyncio.AbstractEventLoop) -> None: diff --git a/tests/test_connector.py b/tests/test_connector.py index 79776380297..c7938ed08e4 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -8,6 +8,7 @@ import ssl import sys import uuid +import warnings from collections import defaultdict, deque from concurrent import futures from contextlib import closing, suppress @@ -2044,25 +2045,55 @@ async def test_tcp_connector_ctor() -> None: await conn.close() +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Use test_tcp_connector_ssl_shutdown_timeout_pre_311 for Python < 3.11", +) async def test_tcp_connector_ssl_shutdown_timeout( loop: asyncio.AbstractEventLoop, ) -> None: - # Test default value + # Test default value (no warning expected) conn = aiohttp.TCPConnector() - assert conn._ssl_shutdown_timeout == 0.1 + assert conn._ssl_shutdown_timeout == 0 await conn.close() - # Test custom value - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) + # Test custom value - expect deprecation warning + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) assert conn._ssl_shutdown_timeout == 1.0 await conn.close() - # Test None value - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None) + # Test None value - expect deprecation warning + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None) assert conn._ssl_shutdown_timeout is None await conn.close() +@pytest.mark.skipif( + sys.version_info >= (3, 11), + reason="This test is for Python < 3.11 runtime warning behavior", +) +async def test_tcp_connector_ssl_shutdown_timeout_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that both deprecation and runtime warnings are issued on Python < 3.11.""" + # Test custom value - expect both deprecation and runtime warnings + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) + # Should have both deprecation and runtime warnings + assert len(w) == 2 + assert any(issubclass(warn.category, DeprecationWarning) for warn in w) + assert any(issubclass(warn.category, RuntimeWarning) for warn in w) + assert conn._ssl_shutdown_timeout == 1.0 + await conn.close() + + @pytest.mark.skipif( sys.version_info < (3, 11), reason="ssl_shutdown_timeout requires Python 3.11+" ) @@ -2070,7 +2101,10 @@ async def test_tcp_connector_ssl_shutdown_timeout_passed_to_create_connection( loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock ) -> None: # Test that ssl_shutdown_timeout is passed to create_connection for SSL connections - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) with mock.patch.object( conn._loop, "create_connection", autospec=True, spec_set=True @@ -2085,7 +2119,10 @@ async def test_tcp_connector_ssl_shutdown_timeout_passed_to_create_connection( await conn.close() # Test with None value - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=None) with mock.patch.object( conn._loop, "create_connection", autospec=True, spec_set=True @@ -2101,7 +2138,10 @@ async def test_tcp_connector_ssl_shutdown_timeout_passed_to_create_connection( await conn.close() # Test that ssl_shutdown_timeout is NOT passed for non-SSL connections - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) with mock.patch.object( conn._loop, "create_connection", autospec=True, spec_set=True @@ -2122,7 +2162,178 @@ async def test_tcp_connector_ssl_shutdown_timeout_not_passed_pre_311( loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock ) -> None: # Test that ssl_shutdown_timeout is NOT passed to create_connection on Python < 3.11 - conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=2.5) + # Should have both deprecation and runtime warnings + assert len(w) == 2 + assert any(issubclass(warn.category, DeprecationWarning) for warn in w) + assert any(issubclass(warn.category, RuntimeWarning) for warn in w) + + with mock.patch.object( + conn._loop, "create_connection", autospec=True, spec_set=True + ) as create_connection: + create_connection.return_value = mock.Mock(), mock.Mock() + + # Test with HTTPS + req = ClientRequest("GET", URL("https://example.com"), loop=loop) + with closing(await conn.connect(req, [], ClientTimeout())): + assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs + + # Test with HTTP + req = ClientRequest("GET", URL("http://example.com"), loop=loop) + with closing(await conn.connect(req, [], ClientTimeout())): + assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs + + await conn.close() + + +async def test_tcp_connector_close_abort_ssl_when_shutdown_timeout_zero( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that close() uses abort() for SSL connections when ssl_shutdown_timeout=0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + # Create a mock SSL protocol + proto = mock.create_autospec(ResponseHandler, instance=True) + proto.closed = None + + # Create mock SSL transport + transport = mock.Mock() + transport.get_extra_info.return_value = mock.Mock() # Returns SSL context + transport.is_closing.return_value = False + proto.transport = transport + + # Add the protocol to acquired connections + conn._acquired.add(proto) + + # Close the connector + await conn.close() + + # Verify abort was called instead of close for SSL connection + proto.abort.assert_called_once() + proto.close.assert_not_called() + + +async def test_tcp_connector_close_doesnt_abort_non_ssl_when_shutdown_timeout_zero( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that close() still uses close() for non-SSL connections even when ssl_shutdown_timeout=0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + # Create a mock non-SSL protocol + proto = mock.create_autospec(ResponseHandler, instance=True) + proto.closed = None + + # Create mock non-SSL transport + transport = mock.Mock() + transport.get_extra_info.return_value = None # No SSL context + transport.is_closing.return_value = False + proto.transport = transport + + # Add the protocol to acquired connections + conn._acquired.add(proto) + + # Close the connector + await conn.close() + + # Verify close was called for non-SSL connection + proto.close.assert_called_once() + proto.abort.assert_not_called() + + +async def test_tcp_connector_ssl_shutdown_timeout_warning_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that a warning is issued for non-zero ssl_shutdown_timeout on Python < 3.11.""" + with ( + mock.patch.object(sys, "version_info", (3, 10, 0)), + warnings.catch_warnings(record=True) as w, + ): + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=5.0) + + # We should get two warnings: deprecation and runtime warning + assert len(w) == 2 + + # Find each warning type + deprecation_warning = next( + (warn for warn in w if issubclass(warn.category, DeprecationWarning)), None + ) + runtime_warning = next( + (warn for warn in w if issubclass(warn.category, RuntimeWarning)), None + ) + + assert deprecation_warning is not None + assert "ssl_shutdown_timeout parameter is deprecated" in str( + deprecation_warning.message + ) + + assert runtime_warning is not None + assert "ssl_shutdown_timeout=5.0 is ignored on Python < 3.11" in str( + runtime_warning.message + ) + assert "only ssl_shutdown_timeout=0 is supported" in str( + runtime_warning.message + ) + + # Verify the value is still stored + assert conn._ssl_shutdown_timeout == 5.0 + + await conn.close() + + +async def test_tcp_connector_ssl_shutdown_timeout_zero_no_warning_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that no warning is issued for ssl_shutdown_timeout=0 on Python < 3.11.""" + with ( + mock.patch.object(sys, "version_info", (3, 10, 0)), + warnings.catch_warnings(record=True) as w, + ): + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + # We should get one warning: deprecation + assert len(w) == 1 + assert issubclass(w[0].category, DeprecationWarning) + assert "ssl_shutdown_timeout parameter is deprecated" in str(w[0].message) + assert conn._ssl_shutdown_timeout == 0 + + await conn.close() + + +async def test_tcp_connector_ssl_shutdown_timeout_sentinel_no_warning_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that no warning is issued when sentinel is used on Python < 3.11.""" + with ( + mock.patch.object(sys, "version_info", (3, 10, 0)), + warnings.catch_warnings(record=True) as w, + ): + warnings.simplefilter("always") + conn = aiohttp.TCPConnector() # Uses sentinel by default + + assert len(w) == 0 + assert conn._ssl_shutdown_timeout == 0 # Default value + + await conn.close() + + +async def test_tcp_connector_ssl_shutdown_timeout_zero_not_passed( + loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock +) -> None: + """Test that ssl_shutdown_timeout=0 is NOT passed to create_connection.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) with mock.patch.object( conn._loop, "create_connection", autospec=True, spec_set=True @@ -2132,9 +2343,10 @@ async def test_tcp_connector_ssl_shutdown_timeout_not_passed_pre_311( # Test with HTTPS req = ClientRequest("GET", URL("https://example.com"), loop=loop) with closing(await conn.connect(req, [], ClientTimeout())): + # Verify ssl_shutdown_timeout was NOT passed assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs - # Test with HTTP + # Test with HTTP (should not have ssl_shutdown_timeout anyway) req = ClientRequest("GET", URL("http://example.com"), loop=loop) with closing(await conn.connect(req, [], ClientTimeout())): assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs @@ -2142,11 +2354,173 @@ async def test_tcp_connector_ssl_shutdown_timeout_not_passed_pre_311( await conn.close() +@pytest.mark.skipif( + sys.version_info < (3, 11), reason="ssl_shutdown_timeout requires Python 3.11+" +) +async def test_tcp_connector_ssl_shutdown_timeout_nonzero_passed( + loop: asyncio.AbstractEventLoop, start_connection: mock.AsyncMock +) -> None: + """Test that non-zero ssl_shutdown_timeout IS passed to create_connection on Python 3.11+.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=5.0) + + with mock.patch.object( + conn._loop, "create_connection", autospec=True, spec_set=True + ) as create_connection: + create_connection.return_value = mock.Mock(), mock.Mock() + + # Test with HTTPS + req = ClientRequest("GET", URL("https://example.com"), loop=loop) + with closing(await conn.connect(req, [], ClientTimeout())): + # Verify ssl_shutdown_timeout WAS passed + assert create_connection.call_args.kwargs["ssl_shutdown_timeout"] == 5.0 + + # Test with HTTP (should not have ssl_shutdown_timeout) + req = ClientRequest("GET", URL("http://example.com"), loop=loop) + with closing(await conn.connect(req, [], ClientTimeout())): + assert "ssl_shutdown_timeout" not in create_connection.call_args.kwargs + + await conn.close() + + +async def test_tcp_connector_close_abort_ssl_connections_in_conns( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test that SSL connections in _conns are aborted when ssl_shutdown_timeout=0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + # Create mock SSL protocol + proto = mock.create_autospec(ResponseHandler, instance=True) + proto.closed = None + + # Create mock SSL transport + transport = mock.Mock() + transport.get_extra_info.return_value = mock.Mock() # Returns SSL context + proto.transport = transport + + # Add the protocol to _conns + key = ConnectionKey("host", 443, True, True, None, None, None) + conn._conns[key] = deque([(proto, loop.time())]) + + # Close the connector + await conn.close() + + # Verify abort was called for SSL connection + proto.abort.assert_called_once() + proto.close.assert_not_called() + + async def test_tcp_connector_allowed_protocols(loop: asyncio.AbstractEventLoop) -> None: conn = aiohttp.TCPConnector() assert conn.allowed_protocol_schema_set == {"", "tcp", "http", "https", "ws", "wss"} +async def test_start_tls_exception_with_ssl_shutdown_timeout_zero( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test _start_tls_connection exception handling with ssl_shutdown_timeout=0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=0) + + underlying_transport = mock.Mock() + req = mock.Mock() + req.server_hostname = None + req.host = "example.com" + req.is_ssl = mock.Mock(return_value=True) + + # Patch _get_ssl_context to return a valid context and make start_tls fail + with ( + mock.patch.object( + conn, "_get_ssl_context", return_value=ssl.create_default_context() + ), + mock.patch.object(conn._loop, "start_tls", side_effect=OSError("TLS failed")), + ): + with pytest.raises(OSError): + await conn._start_tls_connection(underlying_transport, req, ClientTimeout()) + + # Should abort, not close + underlying_transport.abort.assert_called_once() + underlying_transport.close.assert_not_called() + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="Use test_start_tls_exception_with_ssl_shutdown_timeout_nonzero_pre_311 for Python < 3.11", +) +async def test_start_tls_exception_with_ssl_shutdown_timeout_nonzero( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test _start_tls_connection exception handling with ssl_shutdown_timeout>0.""" + with pytest.warns( + DeprecationWarning, match="ssl_shutdown_timeout parameter is deprecated" + ): + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) + + underlying_transport = mock.Mock() + req = mock.Mock() + req.server_hostname = None + req.host = "example.com" + req.is_ssl = mock.Mock(return_value=True) + + # Patch _get_ssl_context to return a valid context and make start_tls fail + with ( + mock.patch.object( + conn, "_get_ssl_context", return_value=ssl.create_default_context() + ), + mock.patch.object(conn._loop, "start_tls", side_effect=OSError("TLS failed")), + ): + with pytest.raises(OSError): + await conn._start_tls_connection(underlying_transport, req, ClientTimeout()) + + # Should close, not abort + underlying_transport.close.assert_called_once() + underlying_transport.abort.assert_not_called() + + +@pytest.mark.skipif( + sys.version_info >= (3, 11), + reason="This test is for Python < 3.11 runtime warning behavior", +) +async def test_start_tls_exception_with_ssl_shutdown_timeout_nonzero_pre_311( + loop: asyncio.AbstractEventLoop, +) -> None: + """Test _start_tls_connection exception handling with ssl_shutdown_timeout>0 on Python < 3.11.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + conn = aiohttp.TCPConnector(ssl_shutdown_timeout=1.0) + # Should have both deprecation and runtime warnings + assert len(w) == 2 + assert any(issubclass(warn.category, DeprecationWarning) for warn in w) + assert any(issubclass(warn.category, RuntimeWarning) for warn in w) + + underlying_transport = mock.Mock() + req = mock.Mock() + req.server_hostname = None + req.host = "example.com" + req.is_ssl = mock.Mock(return_value=True) + + # Patch _get_ssl_context to return a valid context and make start_tls fail + with ( + mock.patch.object( + conn, "_get_ssl_context", return_value=ssl.create_default_context() + ), + mock.patch.object(conn._loop, "start_tls", side_effect=OSError("TLS failed")), + ): + with pytest.raises(OSError): + await conn._start_tls_connection(underlying_transport, req, ClientTimeout()) + + # Should close, not abort + underlying_transport.close.assert_called_once() + underlying_transport.abort.assert_not_called() + + async def test_invalid_ssl_param() -> None: with pytest.raises(TypeError): aiohttp.TCPConnector(ssl=object()) # type: ignore[arg-type] diff --git a/tests/test_proxy.py b/tests/test_proxy.py index f5ebf6adc4f..4c506cc5730 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -935,24 +935,14 @@ async def make_conn(): self.loop.run_until_complete( connector._create_connection(req, None, aiohttp.ClientTimeout()) ) - - if sys.version_info >= (3, 11): - self.loop.start_tls.assert_called_with( - mock.ANY, - mock.ANY, - _SSL_CONTEXT_VERIFIED, - server_hostname="www.python.org", - ssl_handshake_timeout=mock.ANY, - ssl_shutdown_timeout=0.1, - ) - else: - self.loop.start_tls.assert_called_with( - mock.ANY, - mock.ANY, - _SSL_CONTEXT_VERIFIED, - server_hostname="www.python.org", - ssl_handshake_timeout=mock.ANY, - ) + # ssl_shutdown_timeout=0 is not passed to start_tls + self.loop.start_tls.assert_called_with( + mock.ANY, + mock.ANY, + _SSL_CONTEXT_VERIFIED, + server_hostname="www.python.org", + ssl_handshake_timeout=mock.ANY, + ) self.assertEqual(req.url.path, "/") self.assertEqual(proxy_req.method, "CONNECT") From f9621dc9c9f902f83f20725d9f4ae09a63590516 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Jun 2025 10:08:32 -0500 Subject: [PATCH 8/8] Release 3.12.11 (#11157) --- CHANGES.rst | 48 +++++++++++++++++++++++++++++++++++ CHANGES/11148.deprecation.rst | 1 - CHANGES/11148.feature.rst | 10 -------- aiohttp/__init__.py | 2 +- 4 files changed, 49 insertions(+), 12 deletions(-) delete mode 120000 CHANGES/11148.deprecation.rst delete mode 100644 CHANGES/11148.feature.rst diff --git a/CHANGES.rst b/CHANGES.rst index 6fb4135456c..859cfd1f6b0 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -10,6 +10,54 @@ .. towncrier release notes start +3.12.11 (2025-06-07) +==================== + +Features +-------- + +- Improved SSL connection handling by changing the default ``ssl_shutdown_timeout`` + from ``0.1`` to ``0`` seconds. SSL connections now use Python's default graceful + shutdown during normal operation but are aborted immediately when the connector + is closed, providing optimal behavior for both cases. Also added support for + ``ssl_shutdown_timeout=0`` on all Python versions. Previously, this value was + rejected on Python 3.11+ and ignored on earlier versions. Non-zero values on + Python < 3.11 now trigger a ``RuntimeWarning`` -- by :user:`bdraco`. + + The ``ssl_shutdown_timeout`` parameter is now deprecated and will be removed in + aiohttp 4.0 as there is no clear use case for changing the default. + + + *Related issues and pull requests on GitHub:* + :issue:`11148`. + + + + +Deprecations (removal in next major release) +-------------------------------------------- + +- Improved SSL connection handling by changing the default ``ssl_shutdown_timeout`` + from ``0.1`` to ``0`` seconds. SSL connections now use Python's default graceful + shutdown during normal operation but are aborted immediately when the connector + is closed, providing optimal behavior for both cases. Also added support for + ``ssl_shutdown_timeout=0`` on all Python versions. Previously, this value was + rejected on Python 3.11+ and ignored on earlier versions. Non-zero values on + Python < 3.11 now trigger a ``RuntimeWarning`` -- by :user:`bdraco`. + + The ``ssl_shutdown_timeout`` parameter is now deprecated and will be removed in + aiohttp 4.0 as there is no clear use case for changing the default. + + + *Related issues and pull requests on GitHub:* + :issue:`11148`. + + + + +---- + + 3.12.10 (2025-06-07) ==================== diff --git a/CHANGES/11148.deprecation.rst b/CHANGES/11148.deprecation.rst deleted file mode 120000 index f4ddfb298af..00000000000 --- a/CHANGES/11148.deprecation.rst +++ /dev/null @@ -1 +0,0 @@ -11148.feature.rst \ No newline at end of file diff --git a/CHANGES/11148.feature.rst b/CHANGES/11148.feature.rst deleted file mode 100644 index 6c47c93c7ba..00000000000 --- a/CHANGES/11148.feature.rst +++ /dev/null @@ -1,10 +0,0 @@ -Improved SSL connection handling by changing the default ``ssl_shutdown_timeout`` -from ``0.1`` to ``0`` seconds. SSL connections now use Python's default graceful -shutdown during normal operation but are aborted immediately when the connector -is closed, providing optimal behavior for both cases. Also added support for -``ssl_shutdown_timeout=0`` on all Python versions. Previously, this value was -rejected on Python 3.11+ and ignored on earlier versions. Non-zero values on -Python < 3.11 now trigger a ``RuntimeWarning`` -- by :user:`bdraco`. - -The ``ssl_shutdown_timeout`` parameter is now deprecated and will be removed in -aiohttp 4.0 as there is no clear use case for changing the default. diff --git a/aiohttp/__init__.py b/aiohttp/__init__.py index dccff3e0040..9872fa1bb00 100644 --- a/aiohttp/__init__.py +++ b/aiohttp/__init__.py @@ -1,4 +1,4 @@ -__version__ = "3.12.10" +__version__ = "3.12.11" from typing import TYPE_CHECKING, Tuple