fix: re-acquire the GIL in class_::init_instance before instance registration (#6172)
* fix: re-acquire GIL in init_instance for thread-safe instance registration
With a factory-based py::init combined with py::call_guard<py::gil_scoped_release>,
init_instance -> register_instance runs while the GIL is released, racing on
internals.registered_instances with GIL-holding threads. This corrupts the
instance map and leads to 'pybind11_object_dealloc(): Tried to deallocate
unregistered instance!' -> std::terminate. Acquire the GIL (no-op if already
held) in both class_::init_instance overloads; free-threaded builds keep using
their sharded mutex and are unaffected.
* tests: avoid std::make_unique in test_gil_scoped (C++14 only)
The iOS/Android wheel CI jobs build the test suite with -std=gnu++11.
* tests: skip test_init_factory_gil_released_concurrent_construction on free-threaded builds
On free-threaded builds py::gil_scoped_release detaches the thread state and the
constructor machinery is not safe when called detached, so the test segfaults
(pre-existing limitation of call_guard<gil_scoped_release>, unrelated to the
init_instance fix). The instance map is mutex-protected on free-threaded builds
anyway, so there is nothing to test there. An early return inside the function
covers the _run_in_process parametrizations, where pytest skip markers do not
apply.
* tests: run the init_instance race regression only as a direct test
The test was in ALL_BASIC_TESTS, so it also ran in the _run_in_process
parametrizations, whose subprocesses impose a 10s timeout; on Windows
(sequential variant) the extra sleep timer granularity and GIL handoff
overhead pushed that over the limit. It is a data-race regression, not a
deadlock check, so define it after ALL_BASIC_TESTS like the
test_run_in_process_* functions and drop the now-unneeded free-threaded
early return.
* chore: retrigger CI (GCC 9 job failed on apt 404 for python3-setuptools, unrelated)
* fix: make init_instance GIL acquisition unconditional
The `#if !defined(Py_GIL_DISABLED)` guards skipped the acquire on free-threaded builds, where `gil_scoped_release` detaches the thread state. `init_instance` then crashes in `PyCriticalSection_BeginMutex` (via `get_type_info`) even single-threaded, because the critical section requires an attached thread state.
`gil_scoped_acquire` attaches the thread state without taking a global lock, so this is safe on free-threaded builds and does not serialize threads.
* test: run init_instance regression on free-threaded builds too
The acquire in init_instance is now unconditional, so the regression test also covers the free-threaded detached-thread-state crash. Drop the PY_GIL_DISABLED skip.
* docs: cover free-threaded thread-state attach in init_instance comments
The unconditional gil_scoped_acquire is also needed on free-threaded builds, where gil_scoped_release detaches the thread state and get_type_info requires it to be attached. Mention this in the init_instance comment and the regression-test docstring.
diff --git a/include/pybind11/pybind11.h b/include/pybind11/pybind11.h
index 3687983..c57312d 100644
--- a/include/pybind11/pybind11.h
+++ b/include/pybind11/pybind11.h
@@ -2799,6 +2799,16 @@
template <typename H = holder_type,
detail::enable_if_t<!detail::is_smart_holder<H>::value, int> = 0>
static void init_instance(detail::instance *inst, const void *holder_ptr) {
+ // A factory-based `py::init` keeps the `py::call_guard<py::gil_scoped_release>`
+ // alive across the `construct()` call that invokes this function, so
+ // `init_instance` may run with the GIL released. Acquire it (a no-op if it is
+ // already held) so that `register_instance` and `init_holder` only touch
+ // `internals.registered_instances` while the GIL is held.
+ //
+ // On free-threaded builds `gil_scoped_release` detaches the thread state instead:
+ // `gil_scoped_acquire` attaches it again without taking a global lock, as required
+ // by the critical section inside `get_type_info`.
+ gil_scoped_acquire gil;
auto v_h = inst->get_value_and_holder(detail::get_type_info(typeid(type)));
if (!v_h.instance_registered()) {
register_instance(inst, v_h.value_ptr(), v_h.type);
@@ -2839,6 +2849,9 @@
// void (*init_instance)(instance *, const void *);
auto *holder_void_ptr = const_cast<void *>(holder_const_void_ptr);
+ // See the comment in the non-smart_holder `init_instance` above.
+ gil_scoped_acquire gil;
+
auto v_h = inst->get_value_and_holder(detail::get_type_info(typeid(type)));
if (!v_h.instance_registered()) {
register_instance(inst, v_h.value_ptr(), v_h.type);
diff --git a/tests/test_gil_scoped.cpp b/tests/test_gil_scoped.cpp
index f136086..191284b 100644
--- a/tests/test_gil_scoped.cpp
+++ b/tests/test_gil_scoped.cpp
@@ -11,6 +11,8 @@
#include "pybind11_tests.h"
+#include <chrono>
+#include <memory>
#include <string>
#include <thread>
@@ -27,6 +29,11 @@
virtual void pure_virtual_func() = 0;
};
+class SlowInit {
+public:
+ explicit SlowInit(int) {}
+};
+
class PyVirtClass : public VirtClass {
void virtual_func() override { PYBIND11_OVERRIDE(void, VirtClass, virtual_func, ); }
void pure_virtual_func() override {
@@ -50,6 +57,16 @@
.def("virtual_func", &VirtClass::virtual_func)
.def("pure_virtual_func", &VirtClass::pure_virtual_func);
+ py::class_<SlowInit>(m, "SlowInit")
+ .def(py::init([](int state) {
+ // Sleep to widen the window in which `init_instance` runs with the GIL
+ // released by the call_guard, making the instance-map race (without the
+ // `init_instance` GIL-acquire fix) much more likely to surface.
+ std::this_thread::sleep_for(std::chrono::milliseconds(1));
+ return std::unique_ptr<SlowInit>(new SlowInit(state));
+ }),
+ py::call_guard<py::gil_scoped_release>());
+
m.def("test_callback_py_obj", [](py::object &func) { func(); });
m.def("test_callback_std_func", [](const std::function<void()> &func) { func(); });
m.def("test_callback_virtual_func", [](VirtClass &virt) { virt.virtual_func(); });
diff --git a/tests/test_gil_scoped.py b/tests/test_gil_scoped.py
index fc998b0..8c0ac95 100644
--- a/tests/test_gil_scoped.py
+++ b/tests/test_gil_scoped.py
@@ -160,6 +160,37 @@
assert len(ALL_BASIC_TESTS) == num_found
+# Defined after ALL_BASIC_TESTS on purpose: this test is a regression for the
+# `gil_scoped_release` + factory `py::init` path, not a deadlock check, so it should not
+# run in the _run_in_process parametrizations above (whose subprocesses impose a 10s
+# timeout; on Windows this test is much slower there due to sleep timer granularity and
+# GIL handoff costs).
+@pytest.mark.skipif(sys.platform.startswith("emscripten"), reason="Requires threads")
+def test_init_factory_gil_released_concurrent_construction():
+ """Concurrent construction via a factory `py::init` with `call_guard<gil_scoped_release>`.
+
+ `init_instance` runs while the GIL is released and must internally acquire the GIL
+ before touching the instance map. Without that fix this aborts with
+ "pybind11_object_dealloc(): Tried to deallocate unregistered instance!" (races on
+ `internals.registered_instances`, which is unguarded on GIL builds). On free-threaded
+ builds the detached thread state instead segfaults in `PyCriticalSection_BeginMutex`
+ (via `get_type_info`), even without concurrency.
+ """
+ num_threads = 8
+ iterations = 100
+
+ def construct_many():
+ for _ in range(iterations):
+ instance = m.SlowInit(0)
+ del instance # Destructor runs with the GIL held (deregistration).
+
+ threads = [threading.Thread(target=construct_many) for _ in range(num_threads)]
+ for thread in threads:
+ thread.start()
+ for thread in threads:
+ thread.join()
+
+
def _intentional_deadlock():
m.intentional_deadlock()