Skip to content

Commit 53c0436

Browse files
committed
Merge with master
2 parents d8182e2 + 5319a1b commit 53c0436

9 files changed

Lines changed: 667 additions & 83 deletions

File tree

doc/changelog.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
Changelog
22
=========
33

4+
Changes in Version 4.18.0
5+
-------------------------
6+
7+
- Improved TLS connection performance by reusing TLS sessions across connections
8+
to the same server, avoiding a full handshake on each new connection.
9+
Session resumption is supported on all Python versions for synchronous clients
10+
and on Python 3.11+ for async clients.
11+
412
Changes in Version 4.17.0 (2026/04/20)
513
--------------------------------------
614

pymongo/asynchronous/pool.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,9 @@ def __init__(
754754
self._pending = 0
755755
self._max_connecting = self.opts.max_connecting
756756
self._client_id = client_id
757+
self._ssl_session_cache: Optional[list[Any]] = (
758+
[None] if self.opts._ssl_context is not None else None
759+
)
757760
# Log before publishing event to prevent potential listener preemption in tests
758761
if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG):
759762
_debug_log(
@@ -1041,7 +1044,9 @@ async def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> A
10411044
listeners.publish_connection_created(self.address, conn_id)
10421045

10431046
try:
1044-
networking_interface = await _configured_protocol_interface(self.address, self.opts)
1047+
networking_interface = await _configured_protocol_interface(
1048+
self.address, self.opts, self._ssl_session_cache
1049+
)
10451050
# Catch KeyboardInterrupt, CancelledError, etc. and cleanup.
10461051
except BaseException as error:
10471052
async with self.lock:

pymongo/pool_shared.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@
4747
from pymongo.pyopenssl_context import _sslConn
4848
from pymongo.typings import _Address
4949

50+
51+
def _get_ssl_session(ssl_sock: Any) -> Optional[Any]:
52+
"""Return the TLS session from an SSL socket, handling both PyOpenSSL and stdlib ssl."""
53+
if hasattr(ssl_sock, "get_session"):
54+
return ssl_sock.get_session()
55+
return getattr(ssl_sock, "session", None)
56+
57+
5058
try:
5159
from fcntl import F_GETFD, F_SETFD, FD_CLOEXEC, fcntl
5260

@@ -308,7 +316,9 @@ async def _async_configured_socket(
308316

309317

310318
async def _configured_protocol_interface(
311-
address: _Address, options: PoolOptions
319+
address: _Address,
320+
options: PoolOptions,
321+
ssl_session_cache: Optional[list[Any]] = None,
312322
) -> AsyncNetworkingInterface:
313323
"""Given (host, port) and PoolOptions, return a configured AsyncNetworkingInterface.
314324
@@ -328,6 +338,22 @@ async def _configured_protocol_interface(
328338
)
329339

330340
host = address[0]
341+
# asyncio does not support TLS session resumption natively (cpython#79152,
342+
# closed without a fix). On Python 3.11+ SSLProtocol.__init__ calls
343+
# wrap_bio() synchronously before the first event-loop yield, so setting
344+
# sslobject_class is race-free. Session injection is skipped on older
345+
# Python versions. (The async path always uses stdlib ssl, never PyOpenSSL.)
346+
if ssl_session_cache is not None and sys.version_info >= (3, 11):
347+
session = ssl_session_cache[0]
348+
if session is not None:
349+
_session = session
350+
351+
class _SessionSSLObject(ssl.SSLObject):
352+
def __init__(self, *args: Any, **kwargs: Any) -> None:
353+
super().__init__(*args, **kwargs)
354+
self.session = _session
355+
356+
ssl_context.sslobject_class = _SessionSSLObject # type: ignore[attr-defined]
331357
try:
332358
# We have to pass hostname / ip address to wrap_socket
333359
# to use SSLContext.check_hostname.
@@ -354,6 +380,14 @@ async def _configured_protocol_interface(
354380
and not options.tls_allow_invalid_hostnames
355381
):
356382
ssl.match_hostname(transport.get_extra_info("peercert"), hostname=host) # type:ignore[attr-defined,unused-ignore]
383+
384+
if ssl_session_cache is not None:
385+
ssl_obj = transport.get_extra_info("ssl_object")
386+
if ssl_obj is not None:
387+
new_session = ssl_obj.session
388+
if new_session is not None:
389+
ssl_session_cache[0] = new_session
390+
357391
return AsyncNetworkingInterface((transport, protocol))
358392
except BaseException:
359393
# Protect against cancellation, _CertificateError, or interruption
@@ -481,7 +515,11 @@ def _configured_socket(address: _Address, options: PoolOptions) -> Union[socket.
481515
return ssl_sock
482516

483517

484-
def _configured_socket_interface(address: _Address, options: PoolOptions) -> NetworkingInterface:
518+
def _configured_socket_interface(
519+
address: _Address,
520+
options: PoolOptions,
521+
ssl_session_cache: Optional[list[Any]] = None,
522+
) -> NetworkingInterface:
485523
"""Given (host, port) and PoolOptions, return a NetworkingInterface wrapping a configured socket.
486524
487525
Can raise socket.error, ConnectionFailure, or _CertificateError.
@@ -496,13 +534,14 @@ def _configured_socket_interface(address: _Address, options: PoolOptions) -> Net
496534
return NetworkingInterface(sock)
497535

498536
host = address[0]
537+
session = ssl_session_cache[0] if ssl_session_cache is not None else None
499538
try:
500539
# We have to pass hostname / ip address to wrap_socket
501540
# to use SSLContext.check_hostname.
502541
if _has_sni(True):
503-
ssl_sock = ssl_context.wrap_socket(sock, server_hostname=host)
542+
ssl_sock = ssl_context.wrap_socket(sock, server_hostname=host, session=session)
504543
else:
505-
ssl_sock = ssl_context.wrap_socket(sock)
544+
ssl_sock = ssl_context.wrap_socket(sock, session=session)
506545
except _CertificateError:
507546
sock.close()
508547
# Raise _CertificateError directly like we do after match_hostname
@@ -526,5 +565,10 @@ def _configured_socket_interface(address: _Address, options: PoolOptions) -> Net
526565
ssl_sock.close()
527566
raise
528567

568+
if ssl_session_cache is not None:
569+
new_session = _get_ssl_session(ssl_sock)
570+
if new_session is not None:
571+
ssl_session_cache[0] = new_session
572+
529573
ssl_sock.settimeout(options.socket_timeout)
530574
return NetworkingInterface(ssl_sock)

pymongo/synchronous/pool.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -752,6 +752,9 @@ def __init__(
752752
self._pending = 0
753753
self._max_connecting = self.opts.max_connecting
754754
self._client_id = client_id
755+
self._ssl_session_cache: Optional[list[Any]] = (
756+
[None] if self.opts._ssl_context is not None else None
757+
)
755758
# Log before publishing event to prevent potential listener preemption in tests
756759
if self.enabled_for_logging and _CONNECTION_LOGGER.isEnabledFor(logging.DEBUG):
757760
_debug_log(
@@ -1037,7 +1040,9 @@ def connect(self, handler: Optional[_MongoClientErrorHandler] = None) -> Connect
10371040
listeners.publish_connection_created(self.address, conn_id)
10381041

10391042
try:
1040-
networking_interface = _configured_socket_interface(self.address, self.opts)
1043+
networking_interface = _configured_socket_interface(
1044+
self.address, self.opts, self._ssl_session_cache
1045+
)
10411046
# Catch KeyboardInterrupt, CancelledError, etc. and cleanup.
10421047
except BaseException as error:
10431048
with self.lock:

test/asynchronous/test_async_cancellation.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,15 @@
2121
import socket
2222
import ssl
2323
import sys
24+
from unittest.mock import patch
2425

2526
from test.asynchronous.utils import async_get_pool
2627
from test.utils_shared import delay, one
27-
from unittest.mock import patch
2828

2929
sys.path[0:0] = [""]
3030

31-
from test.asynchronous import AsyncIntegrationTest, async_client_context, connected
32-
3331
from pymongo import pool_shared
32+
from test.asynchronous import AsyncIntegrationTest, async_client_context, connected
3433

3534

3635
class TestAsyncCancellation(AsyncIntegrationTest):

0 commit comments

Comments
 (0)