From 142c1eb4db78f90b379ff0bea066ca26f5018081 Mon Sep 17 00:00:00 2001 From: espressolee <70549809+espressolee@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:27:51 +0800 Subject: [PATCH 1/2] fix(free-threading): cast `self` directly in `cpp_function::dispatcher` Part of #6159 (item 59). Under Py_GIL_DISABLED, cpp_function::dispatcher() took the internals lock on every bound call: it reached its function_record through function_record_ptr_from_PyObject(), which calls get_function_record_PyTypeObject() (PYBIND11_LOCK_INTERNALS on internals.mutex). dispatcher is installed in exactly one place (initialize_generic), and the PyCFunction there is created with m_self set to the function_record_PyObject allocated a few lines above, so self is always that object. The new function_record_ptr_from_dispatcher_self() states that invariant once and casts directly; its assert compares the versioned tp_name instead of calling is_function_record_PyObject(), which would reacquire the lock in non-NDEBUG builds. is_function_record_PyObject() and function_record_ptr_from_PyObject() are unchanged, so the call sites that can see a foreign object keep the checked conversion. Adds test_dispatch_does_not_need_internals_lock (free-threaded builds only): a native thread holds internals.mutex through raw PyCFunction helpers and the test checks that a bound call returns before the holder's watchdog releases the mutex, with a positive control proving the held mutex is the one internals users take. Fails without the fix, passes with it. Assisted-by: Claude Code:claude-opus-5 --- .../detail/function_record_pyobject.h | 28 +++- include/pybind11/pybind11.h | 2 +- tests/test_thread.cpp | 152 ++++++++++++++++++ tests/test_thread.py | 59 +++++++ 4 files changed, 233 insertions(+), 8 deletions(-) diff --git a/include/pybind11/detail/function_record_pyobject.h b/include/pybind11/detail/function_record_pyobject.h index 42b0e96be7..2bda33bf11 100644 --- a/include/pybind11/detail/function_record_pyobject.h +++ b/include/pybind11/detail/function_record_pyobject.h @@ -12,6 +12,7 @@ #include "common.h" +#include #include #include @@ -102,6 +103,14 @@ inline PyTypeObject *get_function_record_PyTypeObject() { return py_type_obj; } +// This works across extension modules, and does not need the internals lock. +// Note that tp_name is versioned. +inline bool function_record_PyTypeObject_name_matches(PyTypeObject *obj_type) { + return strcmp(obj_type->tp_name, function_record_PyTypeObject_methods::tp_qualname_impl) == 0 + || strcmp(obj_type->tp_name, function_record_PyTypeObject_methods::tp_plainname_impl) + == 0; +} + inline bool is_function_record_PyObject(PyObject *obj) { if (PyType_Check(obj) != 0) { return false; @@ -114,13 +123,7 @@ inline bool is_function_record_PyObject(PyObject *obj) { if (obj_type == frtype) { return true; } - // This works across extension modules. Note that tp_name is versioned. - if (strcmp(obj_type->tp_name, function_record_PyTypeObject_methods::tp_qualname_impl) == 0 - || strcmp(obj_type->tp_name, function_record_PyTypeObject_methods::tp_plainname_impl) - == 0) { - return true; - } - return false; + return function_record_PyTypeObject_name_matches(obj_type); } inline function_record *function_record_ptr_from_PyObject(PyObject *obj) { @@ -130,6 +133,17 @@ inline function_record *function_record_ptr_from_PyObject(PyObject *obj) { return nullptr; } +// The `self` of cpp_function::dispatcher() is always the function_record_PyObject that was +// created for that dispatcher in cpp_function::initialize_generic(), so the type check can be +// skipped. is_function_record_PyObject() is deliberately not used here: it calls +// get_function_record_PyTypeObject(), which acquires the internals lock on every call. +inline function_record *function_record_ptr_from_dispatcher_self(PyObject *self) { + assert(self != nullptr); + assert(PyType_Check(self) == 0); + assert(function_record_PyTypeObject_name_matches(Py_TYPE(self))); + return (reinterpret_cast(self))->cpp_func_rec; +} + inline object function_record_PyObject_New() { auto *py_func_rec = PyObject_New(function_record_PyObject, get_function_record_PyTypeObject()); if (py_func_rec == nullptr) { diff --git a/include/pybind11/pybind11.h b/include/pybind11/pybind11.h index 3687983460..bcfb541dd9 100644 --- a/include/pybind11/pybind11.h +++ b/include/pybind11/pybind11.h @@ -967,7 +967,7 @@ class cpp_function : public function { static PyObject * dispatcher(PyObject *self, PyObject *const *args_in_arr, size_t nargsf, PyObject *kwnames_in) { using namespace detail; - const function_record *overloads = function_record_ptr_from_PyObject(self); + const function_record *overloads = function_record_ptr_from_dispatcher_self(self); assert(overloads != nullptr); /* Iterator over the list of potentially admissible overloads */ diff --git a/tests/test_thread.cpp b/tests/test_thread.cpp index 131bd87710..f64a166850 100644 --- a/tests/test_thread.cpp +++ b/tests/test_thread.cpp @@ -8,11 +8,16 @@ */ #include +#include #include #include "pybind11_tests.h" #include +#include +#include +#include +#include #include #if defined(PYBIND11_HAS_STD_BARRIER) @@ -35,6 +40,144 @@ struct IntStruct { struct EmptyStruct {}; EmptyStruct SharedInstance; +#ifdef Py_GIL_DISABLED + +// Holds detail::internals::mutex on a native thread that never touches the Python C API, so that +// a Python-level call can be checked for independence from the internals lock. The lock is a +// no-op unless Py_GIL_DISABLED, hence the #ifdef. See test_dispatch_does_not_need_internals_lock. +class internals_lock_holder { +public: + internals_lock_holder() = default; + internals_lock_holder(const internals_lock_holder &) = delete; + internals_lock_holder &operator=(const internals_lock_holder &) = delete; + ~internals_lock_holder() { + if (thread.joinable()) { + request_release_and_join(); + } + } + + // Returns once the native thread holds the internals mutex. The thread releases it when + // release_and_join() is called, or after `watchdog` if nobody asks: that keeps the process + // moving when the call under test blocks on the mutex. + void start(py::detail::pymutex &internals_mutex, std::chrono::milliseconds watchdog) { + if (thread.joinable()) { + throw std::runtime_error("internals_lock_holder is already started"); + } + held = false; + release_requested = false; + released_by_watchdog = false; + thread = std::thread([this, &internals_mutex, watchdog]() { + internals_mutex.lock(); + { + std::lock_guard lock(m); + held = true; + } + cv.notify_all(); + { + std::unique_lock lock(m); + released_by_watchdog + = !cv.wait_for(lock, watchdog, [this]() { return release_requested; }); + } + internals_mutex.unlock(); + }); + std::unique_lock lock(m); + cv.wait(lock, [this]() { return held; }); + } + + // Returns true if the watchdog had already released the mutex. + bool release_and_join() { + if (!thread.joinable()) { + throw std::runtime_error("internals_lock_holder is not started"); + } + return request_release_and_join(); + } + +private: + bool request_release_and_join() { + { + std::lock_guard lock(m); + release_requested = true; + } + cv.notify_all(); + thread.join(); + return released_by_watchdog; + } + + std::mutex m; + std::condition_variable cv; + bool held = false; + bool release_requested = false; + bool released_by_watchdog = false; + std::thread thread; +}; + +internals_lock_holder &get_internals_lock_holder() { + static internals_lock_holder holder; + return holder; +} + +// Deliberately raw PyCFunctions, not pybind11 bindings: these must be callable while the +// internals mutex is held, and whether a pybind11 dispatch can be is exactly what the test checks. +PyObject *start_internals_lock_holder(PyObject *, PyObject *watchdog_seconds_obj) { + const double watchdog_seconds = PyFloat_AsDouble(watchdog_seconds_obj); + if (watchdog_seconds == -1.0 && PyErr_Occurred() != nullptr) { + return nullptr; + } + const auto watchdog = std::chrono::duration_cast( + std::chrono::duration(watchdog_seconds)); + // Looked up while attached; the holder thread itself never uses the Python C API. + auto &internals_mutex = py::detail::get_internals().mutex; + std::string error; + PyThreadState *save = PyEval_SaveThread(); + try { + get_internals_lock_holder().start(internals_mutex, watchdog); + } catch (const std::exception &e) { + error = e.what(); + } + PyEval_RestoreThread(save); + if (!error.empty()) { + PyErr_SetString(PyExc_RuntimeError, error.c_str()); + return nullptr; + } + Py_RETURN_NONE; +} + +PyObject *release_internals_lock_holder(PyObject *, PyObject *) { + bool released_by_watchdog = false; + std::string error; + PyThreadState *save = PyEval_SaveThread(); + try { + released_by_watchdog = get_internals_lock_holder().release_and_join(); + } catch (const std::exception &e) { + error = e.what(); + } + PyEval_RestoreThread(save); + if (!error.empty()) { + PyErr_SetString(PyExc_RuntimeError, error.c_str()); + return nullptr; + } + return PyBool_FromLong(released_by_watchdog ? 1 : 0); +} + +// Positive control for the test: a call that is known to take the internals lock. +PyObject *take_internals_lock(PyObject *, PyObject *) { + try { + py::detail::with_internals([](py::detail::internals &) {}); + } catch (py::error_already_set &e) { + e.restore(); + return nullptr; + } + Py_RETURN_NONE; +} + +PyMethodDef internals_lock_methods[] + = {{"start_internals_lock_holder", start_internals_lock_holder, METH_O, nullptr}, + {"release_internals_lock_holder", release_internals_lock_holder, METH_NOARGS, nullptr}, + {"take_internals_lock", take_internals_lock, METH_NOARGS, nullptr}, + {nullptr, nullptr, 0, nullptr}}; + +#endif // Py_GIL_DISABLED + } // namespace TEST_SUBMODULE(thread, m) { @@ -103,6 +246,15 @@ TEST_SUBMODULE(thread, m) { #endif m.def("acquire_gil", []() { py::gil_scoped_acquire gil_acquired; }); + // The call under test for test_dispatch_does_not_need_internals_lock: the smallest possible + // bound function, so that the only pybind11 machinery involved is cpp_function::dispatcher(). + m.def("dispatch_noop", []() {}); +#ifdef Py_GIL_DISABLED + if (PyModule_AddFunctions(m.ptr(), internals_lock_methods) != 0) { + throw py::error_already_set(); + } +#endif + // NOTE: std::string_view also uses loader_life_support to ensure that // the string contents remain alive, but that's a C++ 17 feature. } diff --git a/tests/test_thread.py b/tests/test_thread.py index d302c382c2..c6bfdeee79 100644 --- a/tests/test_thread.py +++ b/tests/test_thread.py @@ -1,11 +1,15 @@ from __future__ import annotations +import os +import subprocess import sys +import textwrap import threading import pytest import env +import pybind11_tests from pybind11_tests import thread as m @@ -78,3 +82,58 @@ def __del__(self): m.acquire_gil() m.test_pythread_state_clear_destructor(Foo) + + +@pytest.mark.skipif(sys.platform.startswith("emscripten"), reason="Requires threads") +@pytest.mark.skipif(env.ANDROID or env.IOS, reason="Requires subprocess support") +@pytest.mark.skipif( + not env.PY_GIL_DISABLED, reason="The internals lock is a no-op with the GIL" +) +def test_dispatch_does_not_need_internals_lock(): + """A bound call must not block on the internals mutex while another thread holds it. + + Regression test for #6159 (item 59): cpp_function::dispatcher() used to reach its + function_record through get_function_record_PyTypeObject(), which takes the internals lock + on every call. Runs in a subprocess because on a regression the call under test blocks + until the holder's watchdog fires. + """ + script = textwrap.dedent( + f""" + import sys + + sys.path.insert(0, {os.path.dirname(pybind11_tests.__file__)!r}) + + from pybind11_tests import thread as m + + m.dispatch_noop() # Warm up any one-time initialization. + + # Positive control: the holder holds the very mutex that internals users take, so a + # call that needs it can only return after the holder's watchdog fires. + m.start_internals_lock_holder(1.0) + m.take_internals_lock() + assert m.release_internals_lock_holder(), "take_internals_lock() did not block" + + # The call under test: a plain bound call must not need the internals lock at all. + m.start_internals_lock_holder(5.0) + m.dispatch_noop() + released_by_watchdog = m.release_internals_lock_holder() + assert not released_by_watchdog, "cpp_function::dispatcher() needed the internals lock" + """ + ) + try: + proc = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + timeout=60, + check=False, + ) + except subprocess.TimeoutExpired as ex: + pytest.fail( + f"Subprocess did not finish within {ex.timeout} s (deadlock?).\n" + f"Output:\n{ex.stdout}\n{ex.stderr}" + ) + assert proc.returncode == 0, ( + f"Subprocess failed with exit code {proc.returncode}.\n" + f"Output:\n{proc.stdout}\n{proc.stderr}" + ) From b6b7952c1a6afeeb0130334d13aa0de48a7ff50b Mon Sep 17 00:00:00 2001 From: espressolee <70549809+espressolee@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:36:14 +0800 Subject: [PATCH 2/2] test: put the source tests dir on the subprocess path pybind11_tests imports custom_exceptions from the source tests directory at init, so the subprocess must see that directory as well as the build directory, as test_custom_type_setup.py does. Assisted-by: Claude Code:claude-opus-5 --- tests/test_thread.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_thread.py b/tests/test_thread.py index c6bfdeee79..d688f9ee91 100644 --- a/tests/test_thread.py +++ b/tests/test_thread.py @@ -101,6 +101,7 @@ def test_dispatch_does_not_need_internals_lock(): f""" import sys + sys.path.insert(0, {os.path.dirname(env.__file__)!r}) sys.path.insert(0, {os.path.dirname(pybind11_tests.__file__)!r}) from pybind11_tests import thread as m