diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7a61ee9c6..36ed7fa2e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -46,7 +46,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@fca7ace96b7d713c7035871441bd52efbe39e27e # v3 + uses: github/codeql-action/init@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -63,6 +63,6 @@ jobs: pip install -e . - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@fca7ace96b7d713c7035871441bd52efbe39e27e # v3 + uses: github/codeql-action/analyze@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 90d1eba11..48097316f 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -26,7 +26,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@fca7ace96b7d713c7035871441bd52efbe39e27e # v3 + uses: github/codeql-action/upload-sarif@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3 with: sarif_file: results.sarif category: zizmor diff --git a/doc/changelog.rst b/doc/changelog.rst index d729f9afe..ca4784f91 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -8,6 +8,39 @@ PyMongo 4.14 brings a number of changes including: - Added :attr:`bson.codec_options.TypeRegistry.codecs` and :attr:`bson.codec_options.TypeRegistry.fallback_encoder` properties to allow users to directly access the type codecs and fallback encoder for a given :class:`bson.codec_options.TypeRegistry`. +Changes in Version 4.13.2 (2025/06/17) +-------------------------------------- + +Version 4.13.2 is a bug fix release. + +- Fixed a bug where ``AsyncMongoClient`` would block the event loop while creating new connections, + potentially significantly increasing latency for ongoing operations. + +Issues Resolved +............... + +See the `PyMongo 4.13.2 release notes in JIRA`_ for the list of resolved issues +in this release. + +.. _PyMongo 4.13.2 release notes in JIRA: https://jira.mongodb.org/secure/ReleaseNote.jspa?projectId=10004&version=43937 + + +Changes in Version 4.13.1 (2025/06/10) +-------------------------------------- + +Version 4.13.1 is a bug fix release. + +- Fixed a bug that could raise ``ServerSelectionTimeoutError`` when using timeouts with ``AsyncMongoClient``. +- Fixed a bug that could raise ``NetworkTimeout`` errors on Windows. + +Issues Resolved +............... + +See the `PyMongo 4.13.1 release notes in JIRA`_ for the list of resolved issues +in this release. + +.. _PyMongo 4.13.1 release notes in JIRA: https://jira.mongodb.org/secure/ReleaseNote.jspa?projectId=10004&version=43924 + Changes in Version 4.13.0 (2025/05/14) -------------------------------------- diff --git a/pymongo/pool_shared.py b/pymongo/pool_shared.py index 308ecef34..905f1a4d1 100644 --- a/pymongo/pool_shared.py +++ b/pymongo/pool_shared.py @@ -206,7 +206,8 @@ async def _async_create_connection(address: _Address, options: PoolOptions) -> s # SOCK_CLOEXEC not supported for Unix sockets. _set_non_inheritable_non_atomic(sock.fileno()) try: - sock.connect(host) + sock.setblocking(False) + await asyncio.get_running_loop().sock_connect(sock, host) return sock except OSError: sock.close() @@ -241,14 +242,22 @@ async def _async_create_connection(address: _Address, options: PoolOptions) -> s timeout = options.connect_timeout elif timeout <= 0: raise socket.timeout("timed out") - sock.settimeout(timeout) sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, True) _set_keepalive_times(sock) - sock.connect(sa) + # Socket needs to be non-blocking during connection to not block the event loop + sock.setblocking(False) + await asyncio.wait_for( + asyncio.get_running_loop().sock_connect(sock, sa), timeout=timeout + ) + sock.settimeout(timeout) return sock - except OSError as e: - err = e + except asyncio.TimeoutError as e: sock.close() + err = socket.timeout("timed out") + err.__cause__ = e + except OSError as e: + sock.close() + err = e # type: ignore[assignment] if err is not None: raise err diff --git a/pymongo/pyopenssl_context.py b/pymongo/pyopenssl_context.py index 0d4f27cf5..08fe99c88 100644 --- a/pymongo/pyopenssl_context.py +++ b/pymongo/pyopenssl_context.py @@ -420,9 +420,9 @@ class SSLContext: pyopenssl.verify_ip_address(ssl_conn, server_hostname) else: pyopenssl.verify_hostname(ssl_conn, server_hostname) - except ( # type:ignore[misc] - service_identity.SICertificateError, - service_identity.SIVerificationError, + except ( + service_identity.CertificateError, + service_identity.VerificationError, ) as exc: raise _CertificateError(str(exc)) from None return ssl_conn diff --git a/test/asynchronous/test_async_loop_unblocked.py b/test/asynchronous/test_async_loop_unblocked.py new file mode 100644 index 000000000..86f934b79 --- /dev/null +++ b/test/asynchronous/test_async_loop_unblocked.py @@ -0,0 +1,56 @@ +# Copyright 2025-present MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Test that the asynchronous API does not block the event loop.""" +from __future__ import annotations + +import asyncio +import time +from test.asynchronous import AsyncIntegrationTest + +from pymongo.errors import ServerSelectionTimeoutError + + +class TestClientLoopUnblocked(AsyncIntegrationTest): + async def test_client_does_not_block_loop(self): + # Use an unreachable TEST-NET host to ensure that the client times out attempting to create a connection. + client = self.simple_client("192.0.2.1", serverSelectionTimeoutMS=500) + latencies = [] + + # If the loop is being blocked, at least one iteration will have a latency much more than 0.1 seconds + async def background_task(): + start = time.monotonic() + try: + while True: + start = time.monotonic() + await asyncio.sleep(0.1) + latencies.append(time.monotonic() - start) + except asyncio.CancelledError: + latencies.append(time.monotonic() - start) + raise + + t = asyncio.create_task(background_task()) + + with self.assertRaisesRegex(ServerSelectionTimeoutError, "No servers found yet"): + await client.admin.command("ping") + + t.cancel() + with self.assertRaises(asyncio.CancelledError): + await t + + self.assertLessEqual( + sorted(latencies, reverse=True)[0], + 1.0, + "Background task was blocked from running", + ) diff --git a/test/asynchronous/test_session.py b/test/asynchronous/test_session.py index 0ceaea98f..bf2bce27e 100644 --- a/test/asynchronous/test_session.py +++ b/test/asynchronous/test_session.py @@ -196,7 +196,7 @@ class TestSession(AsyncIntegrationTest): lsid_set = set() listener = OvertCommandListener() client = await self.async_rs_or_single_client(event_listeners=[listener], maxPoolSize=1) - # Retry up to 10 times because there is a known race that can cause multiple + # Retry up to 10 times because there is a known race condition that can cause multiple # sessions to be used: connection check in happens before session check in for _ in range(10): cursor = client.db.test.find({}) @@ -235,7 +235,6 @@ class TestSession(AsyncIntegrationTest): for t in tasks: await t.join() self.assertIsNone(t.exc) - await client.close() lsid_set.clear() for i in listener.started_events: if i.command.get("lsid"): diff --git a/test/asynchronous/test_ssl.py b/test/asynchronous/test_ssl.py index 023ee9168..a05bc9379 100644 --- a/test/asynchronous/test_ssl.py +++ b/test/asynchronous/test_ssl.py @@ -323,7 +323,7 @@ class TestSSL(AsyncIntegrationTest): response = await self.client.admin.command(HelloCompat.LEGACY_CMD) - with self.assertRaises(ConnectionFailure): + with self.assertRaises(ConnectionFailure) as cm: await connected( self.simple_client( "server", @@ -335,6 +335,8 @@ class TestSSL(AsyncIntegrationTest): **self.credentials, # type: ignore[arg-type] ) ) + # PYTHON-5414 Check for "module service_identity has no attribute SICertificateError" + self.assertNotIn("has no attribute", str(cm.exception)) await connected( self.simple_client( diff --git a/test/test_session.py b/test/test_session.py index d70032d15..89670df9e 100644 --- a/test/test_session.py +++ b/test/test_session.py @@ -196,7 +196,7 @@ class TestSession(IntegrationTest): lsid_set = set() listener = OvertCommandListener() client = self.rs_or_single_client(event_listeners=[listener], maxPoolSize=1) - # Retry up to 10 times because there is a known race that can cause multiple + # Retry up to 10 times because there is a known race condition that can cause multiple # sessions to be used: connection check in happens before session check in for _ in range(10): cursor = client.db.test.find({}) @@ -235,7 +235,6 @@ class TestSession(IntegrationTest): for t in tasks: t.join() self.assertIsNone(t.exc) - client.close() lsid_set.clear() for i in listener.started_events: if i.command.get("lsid"): diff --git a/test/test_ssl.py b/test/test_ssl.py index 93a4b4e6e..3ac0a4555 100644 --- a/test/test_ssl.py +++ b/test/test_ssl.py @@ -323,7 +323,7 @@ class TestSSL(IntegrationTest): response = self.client.admin.command(HelloCompat.LEGACY_CMD) - with self.assertRaises(ConnectionFailure): + with self.assertRaises(ConnectionFailure) as cm: connected( self.simple_client( "server", @@ -335,6 +335,8 @@ class TestSSL(IntegrationTest): **self.credentials, # type: ignore[arg-type] ) ) + # PYTHON-5414 Check for "module service_identity has no attribute SICertificateError" + self.assertNotIn("has no attribute", str(cm.exception)) connected( self.simple_client( diff --git a/tools/synchro.py b/tools/synchro.py index aaf7c6836..541231cf7 100644 --- a/tools/synchro.py +++ b/tools/synchro.py @@ -186,6 +186,7 @@ def async_only_test(f: str) -> bool: "test_async_cancellation.py", "test_async_loop_safety.py", "test_async_contextvars_reset.py", + "test_async_loop_unblocked.py", ]