-
Type:
Bug
-
Resolution: Unresolved
-
Priority:
Major - P3
-
None
-
Affects Version/s: 4.19.0, 4.18.2
-
Component/s: Connection Mgmt, TLS/SSL
-
None
-
None
-
Python Drivers
-
None
-
None
-
None
-
None
-
None
-
None
-
- Description
4.18.0 added TLS session reuse ("Improved TLS connection performance by reusing TLS sessions across connections to the same server"). After each handshake the pool reads the session back (`_get_ssl_session()` on the sync path, `ssl_obj.session` in `_configured_protocol_interface` on the async path) and stores it in `_ssl_session_cache`.
On CPython 3.11 and earlier, 3.12.0-3.12.6 and 3.13.0, the `SSLSocket.session` / `SSLObject.session` getter leaks a copy of the session on every call. That's python/cpython#116810(https://github.com/python/cpython/issues/116810), fixed by python/cpython#123249(https://github.com/python/cpython/pull/123249) in 3.12.7 and 3.13.1 and not backported to 3.11. In 3.11's `Modules/_ssl.c`, `PySSL_get_session()` still calls `_ssl_session_dup()` and then overwrites the result with `SSL_get1_session()`, so the duplicate is never freed.
As a result, on those Python versions every new pooled TLS connection leaks ~5 KB of native memory, with both `MongoClient` and `AsyncMongoClient`. RSS grows while tracemalloc and gc object counts stay flat. Long-running services that open TLS connections steadily grow without bound.
*Impact seen in production:* a service on Python 3.11 / Alpine upgraded to pymongo 4.18.2 `AsyncMongoClient` (from Motor 3.2), connecting to an Atlas replica set via `mongodb+srv://`. After the upgrade, every process grew ~6-7 MB/hour with no plateau. That included a mostly idle worker process. Before the upgrade, memory was flat for days.
-
- Steps to reproduce
1. Start a 3-member replica set with `--tlsMode requireTLS` (self-signed CA), for example `mongo:7.0` on ports 27017-27019.
2. In `python:3.11-alpine`, with `pip install pymongo==4.18.2`, run the following. Every operation opens a new pooled TLS connection (`maxIdleTimeMS=50` plus a 100 ms sleep):
```python
import asyncio, gc, sys, time, tracemalloc
import pymongo
from pymongo import AsyncMongoClient
uri, secs = sys.argv[1], int(sys.argv[2])
def rss_mb():
for line in open("/proc/self/status"):
if line.startswith("VmRSS"):
return int(line.split()[1]) / 1e3
async def main():
print("pymongo", pymongo._version_, "python", sys.version.split()[0])
client = AsyncMongoClient(uri, maxIdleTimeMS=50, minPoolSize=0)
coll = client.test.test
await coll.find_one({})
tracemalloc.start()
t0, n, nxt = time.time(), 0, secs / 6
while time.time() - t0 < secs:
await coll.find_one({})
n += 1
await asyncio.sleep(0.1) # > maxIdleTimeMS -> next op opens a new TLS connection
if time.time() - t0 >= nxt:
nxt += secs / 6
gc.collect()
print(f"t=
s ops=
{n}rss=
{rss_mb():6.1f}MB "
f"traced=
MB objs=
{len(gc.get_objects())}")
await client.close()
asyncio.run(main())
```
Run it with:
```
python repro.py "mongodb://localhost:27017,localhost:27018,localhost:27019/?replicaSet=rs0&tls=true&tlsCAFile=/tls/ca.pem" 150
```
The CPython bug on its own, without pymongo:
```python
s = ssl_ctx.wrap_socket(socket.create_connection((host, port)), server_hostname=host)
for _ in range(20000):
s.session # 3.11.17: RSS 40 -> 113 MB; 3.12.15 / 3.13.16 / 3.14.8: flat
```
-
- Results
MongoDB 7.0 TLS replica set, OpenSSL 3.5.9, `python:<ver>-alpine` images, roughly 900-1,300 new connections per run:
| pymongo | Python | Client | RSS |
| — | — | — | — |
| 4.18.2 | 3.11.17 | async | 37.3 → 42.4 MB (linear) |
| 4.19.0 | 3.11.17 | async | 37.5 → 43.0 MB (linear) |
| 4.18.2 | 3.11.17 | sync | 33.5 → 36.9 MB (linear) |
| 4.18.2 | 3.12.15 | async | flat (36.9 → 37.1 MB) |
| 4.17.0 | 3.11.17 | async | flat |
| 4.18.2 + patch below | 3.11.17 | async | flat (35.9 → 36.1 MB) |
| 4.18.2 + patch below | 3.11.17 | sync | flat (32.0 MB) |
Bare `ssl` getter test, 2,000 connections reading `.session` once each: 3.10.22 and 3.11.17 leak ~4.9 KB per read. 3.12.15, 3.13.16 and 3.14.8 are flat.
Against mongod 7.0, `session_reused` was never true in these runs, so on affected Pythons the feature costs memory and gives no handshake savings here.
-
- Suggested fix
Turn TLS session reuse off on CPython versions with the leaking getter. Applied to 4.18.2 and verified with both clients:
```diff
— a/pymongo/pool_shared.py
+++ b/pymongo/pool_shared.py
@@ -61,6 +61,14 @@
service_id: Optional[ObjectId]
+# Reading SSLSocket.session / SSLObject.session leaks a copy of the session on
+# every call in CPython < 3.12.7 and 3.13.0 (python/cpython#116810), so TLS
+# session reuse is disabled on those versions.
+_TLS_SESSION_GETTER_LEAKS = sys.implementation.name == "cpython" and (
+ sys.version_info < (3, 12, 7) or sys.version_info[:3] == (3, 13, 0)
+)
+
+
def _get_ssl_session(ssl_sock: Any) -> Optional[Any]:
— a/pymongo/asynchronous/pool.py
+++ b/pymongo/asynchronous/pool.py
@@ -72,6 +72,7 @@
_CancellationContext,
+ _TLS_SESSION_GETTER_LEAKS,
_configured_protocol_interface,
@@ -642,7 +643,7 @@
self._ssl_session_cache: Optional[list[Any]] = (
- [None] if self.opts._ssl_context is not None else None
+ [None] if self.opts._ssl_context is not None and not _TLS_SESSION_GETTER_LEAKS else None
)-
- a/pymongo/synchronous/pool.py
+++ b/pymongo/synchronous/pool.py
@@ -70,6 +70,7 @@
_CancellationContext,
+ _TLS_SESSION_GETTER_LEAKS,
_configured_socket_interface,
@@ -640,7 +641,7 @@
self._ssl_session_cache: Optional[list[Any]] = (
- a/pymongo/synchronous/pool.py
-
- [None] if self.opts._ssl_context is not None else None
+ [None] if self.opts._ssl_context is not None and not _TLS_SESSION_GETTER_LEAKS else None
)
```
In the real repo, `synchronous/pool.py` is generated from the async file by the unasync tooling, so only the async file and `pool_shared.py` need editing there.
PyOpenSSL (sync path only) uses `get_session()` and isn't affected by the CPython bug. The gate could be narrowed to the stdlib-ssl case if that matters.
-
- Workarounds
- Pin `pymongo<4.18`.
- Or run on CPython 3.12.7+ / 3.13.1+.