diff --git a/.github/workflows/dist.yml b/.github/workflows/dist.yml index be172864dc..14c253fe73 100644 --- a/.github/workflows/dist.yml +++ b/.github/workflows/dist.yml @@ -39,8 +39,8 @@ jobs: - [ubuntu-latest, "manylinux_ppc64le", "cp3*-manylinux_ppc64le"] - [ubuntu-latest, "manylinux_s390x", "cp3*-manylinux_s390x"] - [ubuntu-latest, "manylinux_i686", "cp3*-manylinux_i686"] - - [windows-2019, "win_amd6", "cp3*-win_amd64"] - - [windows-2019, "win32", "cp3*-win32"] + - [windows-2022, "win_amd6", "cp3*-win_amd64"] + - [windows-2022, "win32", "cp3*-win32"] - [macos-14, "macos", "cp*-macosx_*"] steps: diff --git a/.github/workflows/release-python.yml b/.github/workflows/release-python.yml index 9cce310d91..cbb2322fe8 100644 --- a/.github/workflows/release-python.yml +++ b/.github/workflows/release-python.yml @@ -16,7 +16,7 @@ env: # Changes per repo PRODUCT_NAME: PyMongo # Changes per branch - EVERGREEN_PROJECT: mongo-python-driver + EVERGREEN_PROJECT: mongo-python-driver-release # Constant # inputs will be empty on a scheduled run. so, we only set dry_run # to 'false' when the input is set to 'false'. diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index b0d4e7cf2e..1d58c0d5fb 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -17,16 +17,5 @@ jobs: uses: actions/checkout@v4 with: persist-credentials: false - - name: Setup Rust - uses: actions-rust-lang/setup-rust-toolchain@9d7e65c320fdb52dcd45ffaa68deb6c02c8754d9 # v1 - - name: Get zizmor - run: cargo install zizmor - name: Run zizmor 🌈 - run: zizmor --format sarif . > results.sarif - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@60168efe1c415ce0f5521ea06d5c2062adbeed1b # v3 - with: - sarif_file: results.sarif - category: zizmor + uses: zizmorcore/zizmor-action@1c7106082dbc1753372e3924b7da1b9417011a21 diff --git a/doc/changelog.rst b/doc/changelog.rst index 80d1c4e2f0..ca08190d79 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -1,6 +1,39 @@ Changelog ========= +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. +- Fixed a bug that resulted in confusing error messages after hostname verification errors when using PyOpenSSL. + +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/_csot.py b/pymongo/_csot.py index 06c6b68ac9..c5681e345a 100644 --- a/pymongo/_csot.py +++ b/pymongo/_csot.py @@ -32,6 +32,12 @@ DEADLINE: ContextVar[float] = ContextVar("DEADLINE", default=float("inf")) +def reset_all() -> None: + TIMEOUT.set(None) + RTT.set(0.0) + DEADLINE.set(float("inf")) + + def get_timeout() -> Optional[float]: return TIMEOUT.get(None) diff --git a/pymongo/_version.py b/pymongo/_version.py index d3f1a7529d..f830344048 100644 --- a/pymongo/_version.py +++ b/pymongo/_version.py @@ -18,7 +18,7 @@ import re from typing import List, Tuple, Union -__version__ = "4.13.0" +__version__ = "4.13.3.dev0" def get_version_tuple(version: str) -> Tuple[Union[int, str], ...]: diff --git a/pymongo/network_layer.py b/pymongo/network_layer.py index 6f1bb9a357..78eefc7177 100644 --- a/pymongo/network_layer.py +++ b/pymongo/network_layer.py @@ -286,6 +286,7 @@ async def _async_socket_receive( _PYPY = "PyPy" in sys.version +_WINDOWS = sys.platform == "win32" def wait_for_read(conn: Connection, deadline: Optional[float]) -> None: @@ -337,7 +338,8 @@ def receive_data(conn: Connection, length: int, deadline: Optional[float]) -> me while bytes_read < length: try: # Use the legacy wait_for_read cancellation approach on PyPy due to PYTHON-5011. - if _PYPY: + # also use it on Windows due to PYTHON-5405 + if _PYPY or _WINDOWS: wait_for_read(conn, deadline) if _csot.get_timeout() and deadline is not None: conn.set_conn_timeout(max(deadline - time.monotonic(), 0)) @@ -359,6 +361,7 @@ def receive_data(conn: Connection, length: int, deadline: Optional[float]) -> me raise _OperationCancelled("operation cancelled") from None if ( _PYPY + or _WINDOWS or not conn.is_sdam and deadline is not None and deadline - time.monotonic() < 0 diff --git a/pymongo/periodic_executor.py b/pymongo/periodic_executor.py index 323debdce2..ed369a2b21 100644 --- a/pymongo/periodic_executor.py +++ b/pymongo/periodic_executor.py @@ -23,6 +23,7 @@ import weakref from typing import Any, Optional +from pymongo import _csot from pymongo._asyncio_task import create_task from pymongo.lock import _create_lock @@ -93,6 +94,8 @@ def skip_sleep(self) -> None: self._skip_sleep = True async def _run(self) -> None: + # The CSOT contextvars must be cleared inside the executor task before execution begins + _csot.reset_all() while not self._stopped: if self._task and self._task.cancelling(): # type: ignore[unused-ignore, attr-defined] raise asyncio.CancelledError diff --git a/pymongo/pool_shared.py b/pymongo/pool_shared.py index 308ecef349..905f1a4d18 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 asyncio.TimeoutError as e: + sock.close() + err = socket.timeout("timed out") + err.__cause__ = e except OSError as e: - err = 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 0d4f27cf55..08fe99c889 100644 --- a/pymongo/pyopenssl_context.py +++ b/pymongo/pyopenssl_context.py @@ -420,9 +420,9 @@ def wrap_socket( 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_contextvars_reset.py b/test/asynchronous/test_async_contextvars_reset.py new file mode 100644 index 0000000000..c6e825bbdf --- /dev/null +++ b/test/asynchronous/test_async_contextvars_reset.py @@ -0,0 +1,41 @@ +# 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 AsyncPeriodicExecutors do not copy ContextVars from their parents.""" +from __future__ import annotations + +import asyncio +import sys +from test.asynchronous.utils import async_get_pool +from test.utils_shared import delay, one + +sys.path[0:0] = [""] + +from test.asynchronous import AsyncIntegrationTest + + +class TestAsyncContextVarsReset(AsyncIntegrationTest): + async def test_context_vars_are_reset_in_executor(self): + if sys.version_info < (3, 12): + self.skipTest("Test requires asyncio.Task.get_context (added in Python 3.12)") + + await self.client.db.test.insert_one({"x": 1}) + for server in self.client._topology._servers.values(): + for context in [ + c + for c in server._monitor._executor._task.get_context() + if c.name in ["TIMEOUT", "RTT", "DEADLINE"] + ]: + self.assertIn(context.get(), [None, float("inf"), 0.0]) + await self.client.db.test.delete_many({}) diff --git a/test/asynchronous/test_async_loop_unblocked.py b/test/asynchronous/test_async_loop_unblocked.py new file mode 100644 index 0000000000..86f934b798 --- /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_ssl.py b/test/asynchronous/test_ssl.py index 023ee91680..a05bc9379d 100644 --- a/test/asynchronous/test_ssl.py +++ b/test/asynchronous/test_ssl.py @@ -323,7 +323,7 @@ async def test_cert_ssl_validation_hostname_matching(self): 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 @@ async def test_cert_ssl_validation_hostname_matching(self): **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_ssl.py b/test/test_ssl.py index 93a4b4e6ec..3ac0a4555a 100644 --- a/test/test_ssl.py +++ b/test/test_ssl.py @@ -323,7 +323,7 @@ def test_cert_ssl_validation_hostname_matching(self): 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 @@ def test_cert_ssl_validation_hostname_matching(self): **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 bfe8f71125..05a1fe3199 100644 --- a/tools/synchro.py +++ b/tools/synchro.py @@ -185,6 +185,8 @@ def async_only_test(f: str) -> bool: "test_concurrency.py", "test_async_cancellation.py", "test_async_loop_safety.py", + "test_async_contextvars_reset.py", + "test_async_loop_unblocked.py", ]