diff --git a/include/pybind11/detail/internals-inl.h b/include/pybind11/detail/internals-inl.h index a7d6450ee7..586f78ad50 100644 --- a/include/pybind11/detail/internals-inl.h +++ b/include/pybind11/detail/internals-inl.h @@ -143,6 +143,11 @@ PYBIND11_INLINE void translate_local_exception(std::exception_ptr p) { PYBIND11_INLINE void check_internals_local_exception_translator(internals *internals_ptr) { if (internals_ptr) { +# if defined(Py_GIL_DISABLED) && PY_VERSION_HEX >= 0x030E00C1 + // The pre-3.14rc1 fallback mutex can deadlock on reentrant imports. + // Not with_exception_translators(): that calls get_internals(), which runs this hook. + pycritical_section lock(internals_ptr->exception_translator_mutex); +# endif for (auto et : internals_ptr->registered_exception_translators) { if (et == &translate_local_exception) { return; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c23e7668e1..4cbc364e79 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -257,7 +257,8 @@ tests_extra_targets( "pybind11_cross_module_tests") # And add additional targets for other tests. -tests_extra_targets("test_exceptions.py" "cross_module_interleaved_error_already_set") +tests_extra_targets("test_exceptions.py" + "cross_module_interleaved_error_already_set;mod_concurrent_import") tests_extra_targets("test_gil_scoped.py" "cross_module_gil_utils") tests_extra_targets("test_cpp_conduit.py" "exo_planet_pybind11;exo_planet_c_api;home_planet_very_lonely_traveler") diff --git a/tests/mod_concurrent_import.cpp b/tests/mod_concurrent_import.cpp new file mode 100644 index 0000000000..327fff4e76 --- /dev/null +++ b/tests/mod_concurrent_import.cpp @@ -0,0 +1,10 @@ +// Copyright (c) 2026 The pybind Community. + +#include + +namespace py = pybind11; + +// test_exceptions.py needs a module whose *first* import happens inside a test, so nothing else +// may import this one. + +PYBIND11_MODULE(mod_concurrent_import, , py::mod_gil_not_used()) {} diff --git a/tests/test_exceptions.cpp b/tests/test_exceptions.cpp index 0a970065b4..b313b8a86d 100644 --- a/tests/test_exceptions.cpp +++ b/tests/test_exceptions.cpp @@ -424,4 +424,8 @@ TEST_SUBMODULE(exceptions, m) { PyErr_SetObject(PyExc_Exception, exc_inst.ptr()); } }); + + // The on-fetch hook (check_internals_local_exception_translator) only runs when a module is + // imported; test_local_translator_registration_vs_internals_fetch needs to keep running it. + m.def("ensure_internals", []() { py::detail::ensure_internals(); }); } diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index b0135d050e..ec4f8e4e1d 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,7 +1,9 @@ from __future__ import annotations +import importlib import re import sys +import threading import pytest from custom_exceptions import PythonMyException7 @@ -442,3 +444,31 @@ def test_return_exception_void(): with pytest.raises(TypeError) as excinfo: m.return_exception_void() assert "Exception" in str(excinfo.value) + + +@pytest.mark.skipif("not env.PY_GIL_DISABLED", reason="Only relevant without the GIL") +def test_local_translator_registration_vs_internals_fetch(): + # Nothing to assert: the race only shows up under TSan, and only with libc++ or + # MSVC (the hook isn't built with libstdc++). + if "mod_concurrent_import" in sys.modules: + pytest.skip("The first import can only be exercised once per process") + + stop = threading.Event() + errors = [] + + def ensure_internals_loop(): + try: + while not stop.is_set(): + m.ensure_internals() + except Exception as e: + errors.append(e) + + reader = threading.Thread(target=ensure_internals_loop) + reader.start() + try: + importlib.import_module("mod_concurrent_import") + finally: + stop.set() + reader.join() + if errors: + raise errors[0]