fix: re-acquire the GIL in class_::init_instance before instance registration - #6172
Conversation
…ation 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.
The iOS/Android wheel CI jobs build the test suite with -std=gnu++11.
… 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.
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.
|
Full disclosure: my mental model of the free-threaded machinery is very incomplete/vague; so I need to rely heavily on LLM help. @colesbury for visibility This is what codex gpt-5.6-sol medium found: I found one worthwhile adjacent issue; I’d ask about it before approving.
Otherwise, the fix looks sound:
Minor description nit: plain |
|
I'm not 100% sure too, let me just test this in free threading python to see thr best option here. |
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.
|
Confirmed on my side: the acquire (thread-state attach) really is needed on free-threaded builds too, and it is not about the instance map.
Verified with 3.14.7t (
So I dropped the guards in both |
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.
|
@trim21 could you please merge master (no specific reason; just to be up-to-date) and address the small suggestions below? codex re-review: The code concern is resolved, and the current head’s full CI is green—including Python 3.14t and 3.15t. I found no blocking correctness issue. I would approve now. Before merging, I’d request a small prose cleanup:
|
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.
|
done |
Description
With a factory-based
py::initcombined withpy::call_guard<py::gil_scoped_release>,class_::init_instanceruns while the GIL is released: thecall_guardobject is constructed inargument_loader::call_impl(cast.h) and stays alive across theconstruct()call that invokesinit_instance. This produces two distinct failures:GIL builds —
register_instancemutatesinternals.registered_instances(with_instance_maphas no locking on GIL builds; only free-threaded builds use a sharded mutex) concurrently with GIL-holding threads. The corruption eventually surfaces as:Free-threaded builds —
gil_scoped_releasecallsPyEval_SaveThread(), which detaches the thread state.init_instancethen reachesPyCriticalSection_BeginMutex(viaget_type_info,detail/internals.h) with no attached thread state and segfaults, deterministically, even with a single thread. The sharded instance-map mutex does not help here, because the problem is not the map race.This affects both
init_instanceoverloads (classic holder and smart_holder). Plainpy::init<Args...>constructors are not affected, because the dispatcher callsinit_instanceafter the call guard has already been destroyed; the same holds for the legacydef("__init__")path.Minimal reproducer
With 8 threads this aborts within a few seconds with
Tried to deallocate unregistered instance!. A gdb backtrace of the abort showspybind11_object_dealloc -> deregister_instance -> pybind11_failwhile another thread is inside the GIL-released factory call, and the failing deallocation finds a corrupted/missing entry inregistered_instances(the racing thread inserted entries into theunordered_multimapwithout the GIL, breaking the map structure). The same construction pattern segfaults on free-threaded builds even without any concurrency.Fix
Call
gil_scoped_acquireat the top of bothclass_::init_instanceoverloads. On GIL builds this is a no-op if the GIL is already held, and guarantees thatregister_instanceandinit_holderrun with the GIL held. On free-threaded builds it attaches the thread state again without taking a global lock, so the instance-map critical section is entered with an attached thread state and free-threaded threads are not serialized.The alternative of moving the call guard scope so registration happens outside it would require splitting the fused "construct + init_instance" flow of every factory
py::initflavor (init.h), since the holder produced by the factory is moved into the instance byinit_instanceitself; acquiring insideinit_instancecovers all current and future call sites instead.A regression test is added to
tests/test_gil_scoped.cpp/.py: 8 threads x 100 constructions through a factorypy::initwithpy::call_guard<py::gil_scoped_release>(with a 1 ms sleep in the factory to widen the race window). Without the fix it aborts (GIL builds) or segfaults (free-threaded builds); with the fix it passes on both, so the test is no longer skipped on free-threaded builds.Suggested changelog entry:
class_::init_instancewhen a factorypy::initis combined withpy::call_guard<py::gil_scoped_release>: acquire the GIL (attach the thread state on free-threaded builds) before registering the instance. Previously GIL builds could abort with "Tried to deallocate unregistered instance!" and free-threaded builds could segfault, even single-threaded.