fix: Clean sibling storage after old-style init (#6180)
diff --git a/docs/upgrade.rst b/docs/upgrade.rst
index 236494a..196e999 100644
--- a/docs/upgrade.rst
+++ b/docs/upgrade.rst
@@ -420,14 +420,14 @@
though no C++ object has been constructed there yet. Accessing the storage through such
a pointer as though it contained a live C++ object results in undefined behavior.
- Consequently, until placement-new completes, the binding must not otherwise load or inspect
- the instance as a C++ object. Unsafe access can occur through reentrant argument conversion
- or callback code, nested initialization, another C++ base in a Python multiple-inheritance
- instance, or concurrent access. Mixing old- and new-style constructor overloads does not
- narrow the window. Such access may treat unconstructed storage as a live object and result
- in undefined behavior. To avoid these hazards, use ``py::init()`` factories and
- ``py::pickle()`` for new bindings, and migrate existing placement-new callbacks wherever
- practical.
+ Consequently, while such a constructor overload chain is active, the binding must not otherwise
+ load or inspect an unconstructed value slot as a C++ object. Unsafe access can occur through
+ reentrant argument conversion or callback code, nested initialization, another C++ base in a
+ Python multiple-inheritance instance, or concurrent access. Mixing old- and new-style
+ constructor overloads does not narrow the window. Such access may treat unconstructed storage
+ as a live object and result in undefined behavior. To avoid these hazards, use ``py::init()``
+ factories and ``py::pickle()`` for new bindings, and migrate existing placement-new callbacks
+ wherever practical.
Mirroring the custom constructor changes, ``py::pickle()`` is now the preferred
way to get and set object state. See :ref:`pickling` for details.
diff --git a/include/pybind11/detail/type_caster_base.h b/include/pybind11/detail/type_caster_base.h
index 65b6bd0..14b3feb 100644
--- a/include/pybind11/detail/type_caster_base.h
+++ b/include/pybind11/detail/type_caster_base.h
@@ -545,23 +545,33 @@
/// reference from `docs/advanced/classes.rst`: the broad scope preserves historical behavior,
/// with documented reentrancy, multiple-inheritance, nesting, and concurrency limitations.
///
-/// If construction fails (the holder was never constructed) after storage was lazily allocated
-/// inside this scope, the destructor frees that storage and resets the value pointer, so that the
-/// uninitialized-value guard in `load_value()` stays effective for later uses of the instance.
+/// When the scope exits, the destructor frees storage that was lazily allocated in any value slot
+/// that was empty on entry and whose holder was never constructed. This keeps the uninitialized-
+/// value guard in `load_value()` effective for later uses of the instance, including sibling slots
+/// in a Python multiple-inheritance layout.
class old_style_init_scope {
public:
- explicit old_style_init_scope(value_and_holder *v_h) : v_h_{v_h} {
- if (v_h_ != nullptr) {
- was_active_ = v_h_->inst->old_style_init_active;
- value_was_null_ = v_h_->value_ptr() == nullptr;
- v_h_->inst->old_style_init_active = true;
+ explicit old_style_init_scope(value_and_holder *v_h)
+ : inst_{v_h != nullptr ? v_h->inst : nullptr} {
+ if (inst_ != nullptr) {
+ values_and_holders vhs(inst_);
+ empty_slots_.reserve(vhs.size());
+ for (auto &slot : vhs) {
+ if (slot.value_ptr() == nullptr) {
+ empty_slots_.push_back(slot);
+ }
+ }
+ was_active_ = inst_->old_style_init_active;
+ inst_->old_style_init_active = true;
}
}
~old_style_init_scope() {
- if (v_h_ != nullptr) {
- v_h_->inst->old_style_init_active = was_active_;
- if (value_was_null_ && !v_h_->holder_constructed() && v_h_->value_ptr() != nullptr) {
- v_h_->type->dealloc(*v_h_); // Frees the storage and nulls the value pointer.
+ if (inst_ != nullptr) {
+ inst_->old_style_init_active = was_active_;
+ for (auto &slot : empty_slots_) {
+ if (!slot.holder_constructed() && slot.value_ptr() != nullptr) {
+ slot.type->dealloc(slot); // Frees the storage and nulls the value pointer.
+ }
}
}
}
@@ -569,9 +579,9 @@
old_style_init_scope &operator=(const old_style_init_scope &) = delete;
private:
- value_and_holder *v_h_;
+ instance *inst_;
+ std::vector<value_and_holder> empty_slots_;
bool was_active_ = false;
- bool value_was_null_ = false;
};
PYBIND11_NOINLINE bool isinstance_generic(handle obj, const std::type_info &tp) {
diff --git a/tests/test_class.cpp b/tests/test_class.cpp
index 21ff617..47430d3 100644
--- a/tests/test_class.cpp
+++ b/tests/test_class.cpp
@@ -650,6 +650,7 @@
// This probe intentionally does not dereference the pointer. It documents the narrow scope of
// this fix without itself reading storage before an OldStyleInit lifetime has begun.
m.def("expose_old_style_init_pointer", [](OldStyleInit *value) { return value != nullptr; });
+ m.def("expose_new_no_init_pointer", [](NewNoInit *value) { return value != nullptr; });
}
template <int N>
diff --git a/tests/test_class.py b/tests/test_class.py
index 645b799..638053b 100644
--- a/tests/test_class.py
+++ b/tests/test_class.py
@@ -338,6 +338,35 @@
assert obj.data() == 44
+def test_old_style_init_cleans_multiple_inheritance_sibling_storage():
+ """The old-style compatibility window is instance-wide, so a reentrant load can lazily
+ allocate an unconstructed sibling base slot. Scope cleanup must reset that slot too."""
+
+ class Derived(m.OldStyleInit, m.NewNoInit):
+ pass
+
+ obj = Derived.__new__(Derived)
+ seen = {}
+
+ class LoadSiblingOnIndex:
+ def __index__(self):
+ # Deliberately obtain but do not dereference the sibling's unconstructed storage.
+ seen["exposed"] = m.expose_new_no_init_pointer(obj)
+ return 45
+
+ m.OldStyleInit.__init__(obj, LoadSiblingOnIndex())
+
+ assert seen == {"exposed": True}
+ assert m.OldStyleInit.data(obj) == 45
+ with pytest.raises(ValueError, match="uninitialized"):
+ m.expose_new_no_init_pointer(obj)
+
+ # The sibling slot remains usable after the old-style constructor returns.
+ m.NewNoInit.__init__(obj, 46)
+ assert m.OldStyleInit.data(obj) == 45
+ assert m.NewNoInit.data(obj) == 46
+
+
def test_reentrant_load_during_new_style_init():
"""New-style constructors never need lazy allocation, so passing the half-built instance
to another bound function while `__init__` runs must raise, not hand out garbage."""