Fix race condition with py::make_key_iterator in free threading (#5971)

* Fix race condition with py::make_key_iterator in free threading

The creation of the iterator class needs to be synchronized.

* style: pre-commit fixes

* Use PyCriticalSection_BeginMutex instead of recursive mutex

* style: pre-commit fixes

* Make pycritical_section non-copyable and non-movable

The pycritical_section class is a RAII wrapper that manages a Python
critical section lifecycle:
- Acquires the critical section in the constructor via
  PyCriticalSection_BeginMutex
- Releases it in the destructor via PyCriticalSection_End
- Holds a reference to a pymutex

Allowing copy or move operations would be dangerous:

1. Copy: Both the original and copied objects would call
   PyCriticalSection_End on the same PyCriticalSection object in their
   destructors, leading to double-unlock and undefined behavior.

2. Move: The moved-from object's destructor would still run and attempt
   to end the critical section, while the moved-to object would also try
   to end it, again causing double-unlock.

This follows the same pattern used by other RAII lock guards in the
codebase, such as gil_scoped_acquire and gil_scoped_release, which also
explicitly delete copy/move operations to prevent similar issues.

By explicitly deleting these operations, we prevent accidental misuse
and ensure the critical section is properly managed by a single RAII
object throughout its lifetime.

* Drop Python 3.13t support from CI

Python 3.13t was experimental, while Python 3.14t is not. This PR
uses PyCriticalSection_BeginMutex which is only available in Python
3.14+, making Python 3.13t incompatible with the changes.

Removed all Python 3.13t CI jobs:
- ubuntu-latest, 3.13t (standard-large matrix)
- macos-15-intel, 3.13t (standard-large matrix)
- windows-latest, 3.13t (standard-large matrix)
- manylinux job testing 3.13t

This aligns with the decision to drop Python 3.13t support as
discussed in PR #5971.

* Add Python 3.13 (default) replacement jobs for removed 3.13t jobs

After removing Python 3.13t support (incompatible with PyCriticalSection_BeginMutex
which requires Python 3.14+), we're adding replacement jobs using Python 3.13
(default) to maintain test coverage in key dimensions:

1. ubuntu-latest, Python 3.13: C++20 + DISABLE_HANDLE_TYPE_NAME_DEFAULT_IMPLEMENTATION
   - Replaces: ubuntu-latest, 3.13t with same config
   - Maintains coverage for this specific configuration combination

2. macos-15-intel, Python 3.13: C++11
   - Replaces: macos-15-intel, 3.13t with same config
   - Maintains macOS coverage for Python 3.13

3. manylinux (musllinux), Python 3.13: GIL testing
   - Replaces: manylinux, 3.13t job
   - Maintains manylinux/musllinux container testing coverage

These additions are proposed to get feedback on which jobs should be kept
to maintain appropriate test coverage without the experimental 3.13t builds.

* ci: run in free-threading mode a bit more on 3.14

* Revert "ci: run in free-threading mode a bit more on 3.14"

This reverts commit 91189c9242e787922d26ca467710dcc494871b82.

Reason: https://github.com/pybind/pybind11/pull/5971#issuecomment-3831321903

* Reapply "ci: run in free-threading mode a bit more on 3.14"

This reverts commit f3197de97557a86a9a73df7890be1c227a7c4c59.

After #5972 is/was merged, tests should pass (already tested under #5980).

See also https://github.com/pybind/pybind11/pull/5972#discussion_r2752674989

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Ralf W. Grosse-Kunstleve <rgrossekunst@nvidia.com>
Co-authored-by: Henry Schreiner <HenrySchreinerIII@gmail.com>
Co-authored-by: Ralf W. Grosse-Kunstleve <rwgkio@gmail.com>
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index cda2c21..a0965fa 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -84,7 +84,7 @@
             python-version: '3.12'
             cmake-args: -DPYBIND11_TEST_SMART_HOLDER=ON -DPYBIND11_SIMPLE_GIL_MANAGEMENT=ON
           - runs-on: ubuntu-latest
-            python-version: '3.13t'
+            python-version: '3.14t'
             cmake-args: -DCMAKE_CXX_STANDARD=20 -DPYBIND11_DISABLE_HANDLE_TYPE_NAME_DEFAULT_IMPLEMENTATION=ON
           - runs-on: ubuntu-latest
             python-version: '3.14'
@@ -102,12 +102,12 @@
           - runs-on: macos-15-intel
             python-version: '3.11'
             cmake-args: -DPYBIND11_TEST_SMART_HOLDER=ON
+          - runs-on: macos-15-intel
+            python-version: '3.13'
+            cmake-args: -DCMAKE_CXX_STANDARD=11
           - runs-on: macos-latest
             python-version: '3.12'
             cmake-args: -DCMAKE_CXX_STANDARD=17 -DPYBIND11_DISABLE_HANDLE_TYPE_NAME_DEFAULT_IMPLEMENTATION=ON
-          - runs-on: macos-15-intel
-            python-version: '3.13t'
-            cmake-args: -DCMAKE_CXX_STANDARD=11
           - runs-on: macos-latest
             python-version: '3.14t'
             cmake-args: -DCMAKE_CXX_STANDARD=20
@@ -139,9 +139,6 @@
             python-version: '3.13'
             cmake-args: -DCMAKE_MSVC_RUNTIME_LIBRARY=MultiThreadedDebugDLL
           - runs-on: windows-latest
-            python-version: '3.13t'
-            cmake-args: -DCMAKE_CXX_STANDARD=17
-          - runs-on: windows-latest
             python-version: '3.14'
             cmake-args: -DCMAKE_CXX_STANDARD=20
           - runs-on: windows-latest
@@ -240,7 +237,7 @@
 
 
   manylinux:
-    name: Manylinux on 🐍 3.13t • GIL
+    name: Manylinux on 🐍 3.14t
     if: github.event.pull_request.draft == false
     runs-on: ubuntu-latest
     timeout-minutes: 40
@@ -257,7 +254,7 @@
         run: uv tool install ninja
 
       - name: Configure via preset
-        run: cmake --preset venv -DPYBIND11_CREATE_WITH_UV=python3.13t
+        run: cmake --preset venv -DPYBIND11_CREATE_WITH_UV=python3.14t
 
       - name: Build C++11
         run: cmake --build --preset venv
diff --git a/include/pybind11/detail/internals.h b/include/pybind11/detail/internals.h
index 7cfa5da..b9b0f08 100644
--- a/include/pybind11/detail/internals.h
+++ b/include/pybind11/detail/internals.h
@@ -230,6 +230,7 @@
 #ifdef Py_GIL_DISABLED
 // Wrapper around PyMutex to provide BasicLockable semantics
 class pymutex {
+    friend class pycritical_section;
     PyMutex mutex;
 
 public:
@@ -238,6 +239,23 @@
     void unlock() { PyMutex_Unlock(&mutex); }
 };
 
+class pycritical_section {
+    pymutex &mutex;
+    PyCriticalSection cs;
+
+public:
+    explicit pycritical_section(pymutex &m) : mutex(m) {
+        PyCriticalSection_BeginMutex(&cs, &mutex.mutex);
+    }
+    ~pycritical_section() { PyCriticalSection_End(&cs); }
+
+    // Non-copyable and non-movable to prevent double-unlock
+    pycritical_section(const pycritical_section &) = delete;
+    pycritical_section &operator=(const pycritical_section &) = delete;
+    pycritical_section(pycritical_section &&) = delete;
+    pycritical_section &operator=(pycritical_section &&) = delete;
+};
+
 // Instance map shards are used to reduce mutex contention in free-threaded Python.
 struct instance_map_shard {
     instance_map registered_instances;
@@ -905,7 +923,7 @@
 }
 
 #ifdef Py_GIL_DISABLED
-#    define PYBIND11_LOCK_INTERNALS(internals) std::unique_lock<pymutex> lock((internals).mutex)
+#    define PYBIND11_LOCK_INTERNALS(internals) pycritical_section lock((internals).mutex)
 #else
 #    define PYBIND11_LOCK_INTERNALS(internals)
 #endif
@@ -934,7 +952,7 @@
                    get_local_internals().registered_exception_translators)) {
     auto &internals = get_internals();
 #ifdef Py_GIL_DISABLED
-    std::unique_lock<pymutex> lock((internals).exception_translator_mutex);
+    pycritical_section lock((internals).exception_translator_mutex);
 #endif
     auto &local_internals = get_local_internals();
     return cb(internals.registered_exception_translators,
diff --git a/include/pybind11/pybind11.h b/include/pybind11/pybind11.h
index 02d2e72..f88fc20 100644
--- a/include/pybind11/pybind11.h
+++ b/include/pybind11/pybind11.h
@@ -3173,6 +3173,7 @@
     using state = detail::iterator_state<Access, Policy, Iterator, Sentinel, ValueType, Extra...>;
     // TODO: state captures only the types of Extra, not the values
 
+    PYBIND11_LOCK_INTERNALS(get_internals());
     if (!detail::get_type_info(typeid(state), false)) {
         class_<state>(handle(), "iterator", pybind11::module_local())
             .def(