ExportXMLWordPrintableJSON

    • 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

        1. 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.

        1. 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=

      {time.time()-t0:4.0f}

      s ops=

      {n}

      rss=

      {rss_mb():6.1f}

      MB "
      f"traced=

      {tracemalloc.get_traced_memory()[0]/1e6:5.2f}

      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
      ```

        1. 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.

        1. 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]] = (
      • [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.

        1. Workarounds
      • Pin `pymongo<4.18`.
      • Or run on CPython 3.12.7+ / 3.13.1+.

            Assignee:
            Noah Stapp
            Reporter:
            Yonathan Eyal (EXT)
            Votes:
            0 Vote for this issue
            Watchers:
            3 Start watching this issue

              Created:
              Updated: