Skip to content

Commit ca45d62

Browse files
authored
Merge branch 'master' into PYTHON-5862
2 parents 0a216ae + 1215d2f commit ca45d62

9 files changed

Lines changed: 680 additions & 20 deletions

File tree

.evergreen/generated_configs/tasks.yml

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4802,22 +4802,20 @@ tasks:
48024802
- python-3.13
48034803
- standalone-noauth-nossl
48044804
- noauth
4805-
- name: test-non-standard-latest-python3.14t-noauth-ssl-replica-set-cov
4805+
- name: test-non-standard-latest-python3.14t-noauth-ssl-replica-set
48064806
commands:
48074807
- func: run server
48084808
vars:
48094809
AUTH: noauth
48104810
SSL: ssl
48114811
TOPOLOGY: replica_set
48124812
VERSION: latest
4813-
COVERAGE: "1"
48144813
- func: run tests
48154814
vars:
48164815
AUTH: noauth
48174816
SSL: ssl
48184817
TOPOLOGY: replica_set
48194818
VERSION: latest
4820-
COVERAGE: "1"
48214819
TOOLCHAIN_VERSION: 3.14t
48224820
tags:
48234821
- test-non-standard
@@ -4826,7 +4824,6 @@ tasks:
48264824
- replica_set-noauth-ssl
48274825
- noauth
48284826
- free-threaded
4829-
- pr
48304827
- name: test-non-standard-latest-pypy3.11-noauth-ssl-replica-set
48314828
commands:
48324829
- func: run server
@@ -4873,30 +4870,50 @@ tasks:
48734870
- sharded_cluster-auth-ssl
48744871
- auth
48754872
- pr
4876-
- name: test-non-standard-latest-python3.13-noauth-nossl-standalone-cov
4873+
- cov
4874+
- name: test-non-standard-latest-python3.14-auth-ssl-sharded-cluster
4875+
commands:
4876+
- func: run server
4877+
vars:
4878+
AUTH: auth
4879+
SSL: ssl
4880+
TOPOLOGY: sharded_cluster
4881+
VERSION: latest
4882+
- func: run tests
4883+
vars:
4884+
AUTH: auth
4885+
SSL: ssl
4886+
TOPOLOGY: sharded_cluster
4887+
VERSION: latest
4888+
TOOLCHAIN_VERSION: "3.14"
4889+
tags:
4890+
- test-non-standard-no-cov
4891+
- server-latest
4892+
- python-3.14
4893+
- sharded_cluster-auth-ssl
4894+
- auth
4895+
- pr
4896+
- name: test-non-standard-latest-python3.13-noauth-nossl-standalone
48774897
commands:
48784898
- func: run server
48794899
vars:
48804900
AUTH: noauth
48814901
SSL: nossl
48824902
TOPOLOGY: standalone
48834903
VERSION: latest
4884-
COVERAGE: "1"
48854904
- func: run tests
48864905
vars:
48874906
AUTH: noauth
48884907
SSL: nossl
48894908
TOPOLOGY: standalone
48904909
VERSION: latest
4891-
COVERAGE: "1"
48924910
TOOLCHAIN_VERSION: "3.13"
48934911
tags:
48944912
- test-non-standard
48954913
- server-latest
48964914
- python-3.13
48974915
- standalone-noauth-nossl
48984916
- noauth
4899-
- pr
49004917
- name: test-non-standard-rapid-python3.11-noauth-ssl-replica-set
49014918
commands:
49024919
- func: run server

.evergreen/generated_configs/variants.yml

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,8 @@ buildvariants:
193193
tags: [encryption_tag]
194194
- name: encryption-macos
195195
tasks:
196-
- name: .test-non-standard !.pypy
196+
- name: .test-non-standard !.pypy !.cov
197+
- name: .test-non-standard-no-cov !.pypy
197198
display_name: Encryption macOS
198199
run_on:
199200
- macos-14
@@ -203,7 +204,8 @@ buildvariants:
203204
tags: [encryption_tag]
204205
- name: encryption-win64
205206
tasks:
206-
- name: .test-non-standard !.pypy
207+
- name: .test-non-standard !.pypy !.cov
208+
- name: .test-non-standard-no-cov !.pypy
207209
display_name: Encryption Win64
208210
run_on:
209211
- windows-2022-latest-small
@@ -224,7 +226,8 @@ buildvariants:
224226
tags: [encryption_tag]
225227
- name: encryption-crypt_shared-macos
226228
tasks:
227-
- name: .test-non-standard !.pypy
229+
- name: .test-non-standard !.pypy !.cov
230+
- name: .test-non-standard-no-cov !.pypy
228231
display_name: Encryption crypt_shared macOS
229232
run_on:
230233
- macos-14
@@ -235,7 +238,8 @@ buildvariants:
235238
tags: [encryption_tag]
236239
- name: encryption-crypt_shared-win64
237240
tasks:
238-
- name: .test-non-standard !.pypy
241+
- name: .test-non-standard !.pypy !.cov
242+
- name: .test-non-standard-no-cov !.pypy
239243
display_name: Encryption crypt_shared Win64
240244
run_on:
241245
- windows-2022-latest-small

.evergreen/scripts/generate_config.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,11 @@ def get_encryption_expansions(encryption):
132132
display_name = get_variant_name(encryption, host, **expansions)
133133
tasks = [".test-non-standard"]
134134
if host != "rhel8":
135-
tasks = [".test-non-standard !.pypy"]
135+
# Exclude PyPy (not tested with encryption on macOS/win64) and coverage tasks
136+
# (encryption suites exceed the 60-min timeout with coverage overhead on macOS/win64).
137+
# Also include the non-coverage companion tasks (test-non-standard-no-cov) which
138+
# carry the "latest" server tasks without COVERAGE=1.
139+
tasks = [".test-non-standard !.pypy !.cov", ".test-non-standard-no-cov !.pypy"]
136140
variant = create_variant(
137141
tasks,
138142
display_name,
@@ -644,8 +648,9 @@ def create_test_non_standard_tasks():
644648
tasks = []
645649
task_combos = set()
646650
# For each version and topology, rotate through the CPythons.
651+
# Only the sharded_cluster topology runs on PRs to keep patch build size manageable.
647652
for (version, topology), python in zip_cycle(list(product(ALL_VERSIONS, TOPOLOGIES)), CPYTHONS):
648-
pr = version == "latest"
653+
pr = version == "latest" and topology == "sharded_cluster"
649654
task_combos.add((version, topology, python, pr))
650655
# For each PyPy and topology, rotate through the MongoDB versions.
651656
for (python, topology), version in zip_cycle(list(product(PYPYS, TOPOLOGIES)), ALL_VERSIONS):
@@ -670,12 +675,33 @@ def create_test_non_standard_tasks():
670675
expansions["TEST_MIN_DEPS"] = "1"
671676
elif pr:
672677
expansions["COVERAGE"] = "1"
678+
tags.append("cov")
673679
name = get_task_name("test-non-standard", python=python, **expansions)
674680
server_func = FunctionCall(func="run server", vars=expansions)
675681
test_vars = expansions.copy()
676682
test_vars["TOOLCHAIN_VERSION"] = python
677683
test_func = FunctionCall(func="run tests", vars=test_vars)
678684
tasks.append(EvgTask(name=name, tags=tags, commands=[server_func, test_func]))
685+
# For each coverage task, also emit a non-coverage companion so that
686+
# macOS/Win64 encryption variants (which filter out .cov due to timeout
687+
# constraints) still have a "latest" task to activate in patch builds.
688+
if pr and "cov" in tags:
689+
nc_expansions = {k: v for k, v in expansions.items() if k != "COVERAGE"}
690+
# Use a distinct primary tag so companions are not selected by existing
691+
# ".test-non-standard" selectors (e.g. load-balancer, PyOpenSSL variants).
692+
nc_tags = [
693+
"test-non-standard-no-cov" if t == "test-non-standard" else t
694+
for t in tags
695+
if t != "cov"
696+
]
697+
nc_name = get_task_name("test-non-standard", python=python, **nc_expansions)
698+
nc_server_func = FunctionCall(func="run server", vars=nc_expansions)
699+
nc_test_vars = nc_expansions.copy()
700+
nc_test_vars["TOOLCHAIN_VERSION"] = python
701+
nc_test_func = FunctionCall(func="run tests", vars=nc_test_vars)
702+
tasks.append(
703+
EvgTask(name=nc_name, tags=nc_tags, commands=[nc_server_func, nc_test_func])
704+
)
679705
return tasks
680706

681707

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

@@ -297,7 +305,9 @@ async def _async_configured_socket(
297305

298306

299307
async def _configured_protocol_interface(
300-
address: _Address, options: PoolOptions
308+
address: _Address,
309+
options: PoolOptions,
310+
ssl_session_cache: Optional[list[Any]] = None,
301311
) -> AsyncNetworkingInterface:
302312
"""Given (host, port) and PoolOptions, return a configured AsyncNetworkingInterface.
303313
@@ -317,6 +327,22 @@ async def _configured_protocol_interface(
317327
)
318328

319329
host = address[0]
330+
# asyncio does not support TLS session resumption natively (cpython#79152,
331+
# closed without a fix). On Python 3.11+ SSLProtocol.__init__ calls
332+
# wrap_bio() synchronously before the first event-loop yield, so setting
333+
# sslobject_class is race-free. Session injection is skipped on older
334+
# Python versions. (The async path always uses stdlib ssl, never PyOpenSSL.)
335+
if ssl_session_cache is not None and sys.version_info >= (3, 11):
336+
session = ssl_session_cache[0]
337+
if session is not None:
338+
_session = session
339+
340+
class _SessionSSLObject(ssl.SSLObject):
341+
def __init__(self, *args: Any, **kwargs: Any) -> None:
342+
super().__init__(*args, **kwargs)
343+
self.session = _session
344+
345+
ssl_context.sslobject_class = _SessionSSLObject # type: ignore[attr-defined]
320346
try:
321347
# We have to pass hostname / ip address to wrap_socket
322348
# to use SSLContext.check_hostname.
@@ -336,6 +362,7 @@ async def _configured_protocol_interface(
336362
# mismatch, will be turned into ServerSelectionTimeoutErrors later.
337363
details = _get_timeout_details(options)
338364
_raise_connection_failure(address, exc, "SSL handshake failed: ", timeout_details=details)
365+
339366
if (
340367
ssl_context.verify_mode
341368
and not ssl_context.check_hostname
@@ -347,6 +374,13 @@ async def _configured_protocol_interface(
347374
transport.abort()
348375
raise
349376

377+
if ssl_session_cache is not None:
378+
ssl_obj = transport.get_extra_info("ssl_object")
379+
if ssl_obj is not None:
380+
new_session = ssl_obj.session
381+
if new_session is not None:
382+
ssl_session_cache[0] = new_session
383+
350384
return AsyncNetworkingInterface((transport, protocol))
351385

352386

@@ -469,7 +503,11 @@ def _configured_socket(address: _Address, options: PoolOptions) -> Union[socket.
469503
return ssl_sock
470504

471505

472-
def _configured_socket_interface(address: _Address, options: PoolOptions) -> NetworkingInterface:
506+
def _configured_socket_interface(
507+
address: _Address,
508+
options: PoolOptions,
509+
ssl_session_cache: Optional[list[Any]] = None,
510+
) -> NetworkingInterface:
473511
"""Given (host, port) and PoolOptions, return a NetworkingInterface wrapping a configured socket.
474512
475513
Can raise socket.error, ConnectionFailure, or _CertificateError.
@@ -484,13 +522,14 @@ def _configured_socket_interface(address: _Address, options: PoolOptions) -> Net
484522
return NetworkingInterface(sock)
485523

486524
host = address[0]
525+
session = ssl_session_cache[0] if ssl_session_cache is not None else None
487526
try:
488527
# We have to pass hostname / ip address to wrap_socket
489528
# to use SSLContext.check_hostname.
490529
if _has_sni(True):
491-
ssl_sock = ssl_context.wrap_socket(sock, server_hostname=host)
530+
ssl_sock = ssl_context.wrap_socket(sock, server_hostname=host, session=session)
492531
else:
493-
ssl_sock = ssl_context.wrap_socket(sock)
532+
ssl_sock = ssl_context.wrap_socket(sock, session=session)
494533
except _CertificateError:
495534
sock.close()
496535
# Raise _CertificateError directly like we do after match_hostname
@@ -514,5 +553,10 @@ def _configured_socket_interface(address: _Address, options: PoolOptions) -> Net
514553
ssl_sock.close()
515554
raise
516555

556+
if ssl_session_cache is not None:
557+
new_session = _get_ssl_session(ssl_sock)
558+
if new_session is not None:
559+
ssl_session_cache[0] = new_session
560+
517561
ssl_sock.settimeout(options.socket_timeout)
518562
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:

0 commit comments

Comments
 (0)