Skip to content

Throw TypeError when subclasses forget to call __init__ - #2152

Merged
wjakob merged 1 commit into
pybind:masterfrom
robotpy:fail-when-no-init
Jul 7, 2020
Merged

wjakob merged 1 commit into
pybind:masterfrom
robotpy:fail-when-no-init

Conversation

@virtuald

@virtuald virtuald commented Apr 5, 2020

Copy link
Copy Markdown
Contributor

Forgetting to call __init__ in inherited classes is one of the biggest issues my users run into (and it's frustrating because it typically causes a segfault), I'd love to see this merged. It would make pybind11 so much more usable!

... this probably causes a minor performance impact when creating new objects. I would expect it to be in the noise, though I haven't looked at the timing yet.

@wjakob

wjakob commented Apr 26, 2020

Copy link
Copy Markdown
Member

This change looks great, and I am happy to merge it. Performance impact should be minimal as you say. But can you please fix pep8? (See the failing "STYLE" test).

@wjakob

wjakob commented Apr 26, 2020

Copy link
Copy Markdown
Member

In particular, there is a long line with the error message string that should be split.

@virtuald
virtuald force-pushed the fail-when-no-init branch from 5e5d3cf to 8383222 Compare May 2, 2020 04:30
@virtuald

virtuald commented May 2, 2020

Copy link
Copy Markdown
Contributor Author

FIxed, sorry for the delay!

@virtuald

Copy link
Copy Markdown
Contributor Author

Ready for merge @wjakob , thanks!

@virtuald
virtuald force-pushed the fail-when-no-init branch from 8383222 to 674a400 Compare June 22, 2020 02:36
@virtuald

Copy link
Copy Markdown
Contributor Author

Still ready. :)

@virtuald

Copy link
Copy Markdown
Contributor Author

Ping.

@virtuald

virtuald commented Jul 3, 2020

Copy link
Copy Markdown
Contributor Author

Ping? :(

@YannickJadoul YannickJadoul left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As gently pinged by @virtuald on Gitter, this should be ready to finish, as it was already reviewed.

So this functionality is not present when a custom metaclass is used? For completeness, it might be nice to mention that in the docs, still.

@wjakob
wjakob merged commit 1b0bf35 into pybind:master Jul 7, 2020
@wjakob

wjakob commented Jul 7, 2020

Copy link
Copy Markdown
Member

Thanks @virtuald, and for the reminder @YannickJadoul.

@virtuald
virtuald deleted the fail-when-no-init branch July 8, 2020 16:51
@virtuald

virtuald commented Jul 8, 2020

Copy link
Copy Markdown
Contributor Author

@YannickJadoul the docs I added indirectly say what you want?

The default pybind11 metaclass will throw a TypeError when it detects
that __init__ was not called by a derived class.

Implying that if you set your own metaclass then you'll get different behavior... ?

@YannickJadoul

Copy link
Copy Markdown
Collaborator

@YannickJadoul the docs I added indirectly say what you want?

OK, yes, that's a bit implicit, but good enough. Using custom metaclasses comes with a lot of caveats anyway, currently.

@rwgk

rwgk commented Aug 21, 2020 •

Copy link
Copy Markdown
Collaborator

It turns out this PR #2152 leads to a test breakage related to this OSS deepmind code:

https://github.com/deepmind/open_spiel/blob/30ab4d23fd306358e874904b9dfe1dd3d52c5bcd/open_spiel/python/pybind11/pyspiel.cc#L311

The situation top-down:

  • Google has non-OSS code like this: class SomeGame(pyspiel.Game):
  • py::class_<Game, std::shared_ptr<Game>> game(m, "Game"); (the OSS link above)
  • class Game : public std::enable_shared_from_this<Game> { ... }; (link below)
  • Game has no public constructor but 3 factory functions like: std::shared_ptr<const Game> LoadGame(const std::string& game_string); (same file line 882)

https://github.com/deepmind/open_spiel/blob/30ab4d23fd306358e874904b9dfe1dd3d52c5bcd/open_spiel/spiel.h#L633

When SomeGame is instantiated, pybind11 raises pyspiel.Game.__init__() must be called when overriding __init__.
But there is no __init__ that can be called.
What's the recommended solution to this issue?

@bstaletic

Copy link
Copy Markdown
Collaborator

But there is no __init__ that can be called.

Are you sure? "No __init__" is different than "no constructor".

>>> __import__("foo").S.__init__
<slot wrapper '__init__' of 'foo.S' objects>
>>> __import__("foo").S()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: foo.S: No constructor defined!

@rwgk

rwgk commented Aug 21, 2020

Copy link
Copy Markdown
Collaborator

When naively adding a call to __int__ I'm getting:

    pyspiel.Game.__init__(self)
TypeError: SomeGame: No constructor defined!

Looking at the wrapper code (pyspiel.cc link in previous comment), there isn't an init defined.

But I'm not sure where to start fixing. Fake an init (if that even makes sense)? Change pybind11 to not raise in this situation? Some other trick?

@henryiii

Copy link
Copy Markdown
Collaborator

This is just checking to see if this has been initialized; currently it is not. If pyspiel.Game is not default constructible, why would SomeGame be? It needs to call one of the factory functions, or a constructor of some sort, or otherwise pyspiel.Game is not fully constructed.

@rwgk

rwgk commented Aug 21, 2020

Copy link
Copy Markdown
Collaborator

This is just checking to see if this has been initialized; currently it is not. If pyspiel.Game is not default constructible, why would SomeGame be? It needs to call one of the factory functions, or a constructor of some sort, or otherwise pyspiel.Game is not fully constructed.

Thanks Henry, I'll dig in deeper to see what they are doing currently. I know eventually they are calling pyspiel.load_game(), but with some tricks in between.

@virtuald

Copy link
Copy Markdown
Contributor Author

It's possible that inheriting from a class implemented only by a factory function might not be properly constructing the holder? Seems weird.

@YannickJadoul

Copy link
Copy Markdown
Collaborator

I've now just spent half an hour looking around into this code and pybind11's dark magic, thinking whether there ought to be a way to surpass this check when a class is used as "interface", but it feels rather dangerous since it allows to access uninitialized data from Python (triggering UB from Python).
(I've also been playing around with __new__ and calling a factory constructor, I haven't managed yet. This feels like something that could be nice to have, as well.)

There's a way to override isinstance, btw, https://docs.python.org/3/reference/datamodel.html#customizing-instance-and-subclass-checks, if this might help.

The docs should be updated indeed; that part must've been overlooked when merging this PR. I'll go over it and see if I can fix it.

Appending a link to the error messages would be ideal: one click and aha!

I'm not sure this is something we'd want in the error message, though. This is a message that users of a pybind11-library might get to see, and pybind11's docs are meant as a reference for the developers. You don't want to confuse Python users with C++ thing, if you ask me.

I was not a fan of the error message either (since it's basically just 2 steps: a. you need to call __init__ when subclassing, b. you can't call __init__ because there is no constructor => a + b means you can't subclass), but from the perspective of the Python user of a pybind11-library, this can potentially be more useful, yes. So if it's easy enough to add, this seems OK to me.

(While we're at it, it might be nice to have a more specific anchor or subsection to point to.)

Careful with this, though. Inheritance in pybind11 is complex and can be subtle, so I'd prefer users would read the whole section. And if someone already knows how pybind11 interacts with inheritance and subclassing, it should be easy enough to scan that section and find the relevant part, no?

@YannickJadoul

Copy link
Copy Markdown
Collaborator

@rwgk Actually, in the case of pyspiel, a "trampoline" class might be the way to go? This is basically meant as the glue between C++ and Python. It could also provide __init__ without making a bare pyspiel.Game itself constructable:

class X {
public:
        static X create() { return X(42); }
        int getZ() const { return z; }

        virtual ~X() = default;

private:
        X(int y) : z(y) {}

        int z;
};


void f(const X &x) {
        py::print(x.getZ());
}


class PyX : public X {
public:
        PyX() : X(X::create()) {}
};

PYBIND11_MODULE(example, m)
{
        py::class_<X, PyX>(m, "X")
                .def_property_readonly("z", &X::getZ)
                .def(py::init([]() { throw py::type_error("Cannot construct an X object without subclassing."); return static_cast<X*>(nullptr); },
                              []() { return PyX(); }));

        m.def("create", &X::create);
        m.def("f", &f);
}

This results in:

Python 3.8.0 (default, Oct 28 2019, 16:14:01) 
[GCC 8.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import example
>>> example.X
<class 'example.X'>
>>> example.X()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: Cannot construct an X object without subclassing.
>>> class Y:
...     
KeyboardInterrupt
>>> class Y(example.X):
...     pass
... 
>>> Y()
<__main__.Y object at 0x7f1bdfc8a360>
>>> class Y(example.X):
...     def __init__(self):
...             example.X.__init__(self)
... 
>>> Y()
<__main__.Y object at 0x7f1bdfc93f40>
>>> 

@YannickJadoul

Copy link
Copy Markdown
Collaborator

See #2429.

@rwgk

rwgk commented Aug 23, 2020

Copy link
Copy Markdown
Collaborator

It could also provide __init__ without making a bare pyspiel.Game itself constructable:

Hi @YannickJadoul, my current solution is this:

    py::class_<Game, std::shared_ptr<Game>> game(m, "Game");
    game.def("num_distinct_actions", &Game::NumDistinctActions)
+      .def(py::init([](const std::string& game_string) {
+       return std::const_pointer_cast<Game>(LoadGame(game_string));
+      }))

There is a list of known/allowed names to pick from. It works with one I picked randomly. I asked for advice about creating a "bare" game, to use your word for it. Let's see what they say.

To have an __init__ without args I could add an overload with a hard-wired game_string, e.g. based on their recommendation:

+      .def(py::init([]() {
+       return std::const_pointer_cast<Game>(LoadGame("backgammon"));
+      }))

Mostly out of curiosity, I also tried this:

+      .def(py::init([]() { return std::shared_ptr<Game>{}; }))

It builds fine but pyspiel.Game() segfaults straightaway, in the __init__ call itself (not the destructor, I convinced myself). I don't know what dereferences the pointer and why.

@YannickJadoul

Copy link
Copy Markdown
Collaborator

It builds fine but pyspiel.Game() segfaults straightaway, in the __init__ call itself (not the destructor, I convinced myself). I don't know what dereferences the pointer and why.

Nice, you bumped into a bug! :-D I managed to figure out what goes wrong; see #2430.

To have an __init__ without args I could add an overload with a hard-wired game_string, e.g. based on their recommendation:

Right, I understand. I was mostly trying to point out the trampoline class, which allows to add the __init__ only when subclassing. So pyspiel.Game itself would still not be default-constructible, and it would still force the use of these factory methods.

@virtuald

Copy link
Copy Markdown
Contributor Author

Ha, I found a hacky way to determine if the constructor was overridden or not. I'll make a PR.

OpenSpiel pushed a commit to google-deepmind/open_spiel that referenced this pull request Aug 24, 2020
Hidden bug discovered while preparing for a third_party/pybind11 update
(testing with current pybind11 github master branch).

Backward compatible.

Relevant pybind11 change: pybind/pybind11#2152

PiperOrigin-RevId: 327643330
Change-Id: Ic867f6d1e1932c3898d4a6f8fb63acde4739204b
OpenSpiel pushed a commit to google-deepmind/open_spiel that referenced this pull request Aug 27, 2020
Backward compatible change preparing for pybind11 update.

Relevant pybind11 change:
* pybind/pybind11#2152
* Throw TypeError when subclasses forget to call __init__

PiperOrigin-RevId: 328182655
Change-Id: I8dc8cd69ee328f95bdca58b0f9045d65d11307a8
rwgk added a commit to rwgk/pybind11 that referenced this pull request Jul 31, 2023
… *>()` introduced with PR pybind#2152

The `reinterpret_cast<instance *>(self)` is unsafe if `__new__` is mocked,
which was actually found in the wild: the mock returned `None` for `self`.
This was inconsequential because `inst` is currently cast straight back to
`PyObject *` to compute `all_type_info()`, which is empty if `self` is not
a pybind11 `instance`, and then `inst` is never dereferenced. However, the
unsafe detour through `instance *` is easily avoided and the updated
implementation is less prone to accidents while debugging or refactoring.
rwgk added a commit that referenced this pull request Nov 8, 2023
#4762)

* Equivalent of google/clif@5718e4d

* Resolve clang-tidy errors.

* Moving test_PPCCInit() first changes the behavior!

* Resolve new Clang dev C++11 errors:

```
The CXX compiler identification is Clang 17.0.0
```

```
pytypes.h:1615:23: error: identifier '_s' preceded by whitespace in a literal operator declaration is deprecated [-Werror,-Wdeprecated-literal-operator]
```

```
cast.h:1380:26: error: identifier '_a' preceded by whitespace in a literal operator declaration is deprecated [-Werror,-Wdeprecated-literal-operator]
```

* Resolve gcc 4.8.5 error:

```
pytypes.h:1615:12: error: missing space between '""' and suffix identifier
```

* Specifically exclude `__clang__`

* Snapshot of debugging code (does NOT pass pre-commit checks).

* Revert "Snapshot of debugging code (does NOT pass pre-commit checks)."

This reverts commit 1d4f9ff.

* [ci skip] Order Dependence Demo

* Revert "[ci skip] Order Dependence Demo"

This reverts commit d37b540.

* One way to deal with the order dependency issue. This is not the best way, more like a proof of concept.

* Move test_PC() first again.

* Add `all_type_info_add_base_most_derived_first()`, use in `all_type_info_populate()`

* Revert "One way to deal with the order dependency issue. This is not the best way, more like a proof of concept."

This reverts commit eb09c6c.

* clang-tidy fixes (automatic)

* Add `is_redundant_value_and_holder()` and use to avoid forcing `__init__` overrides when they are not needed.

* Streamline implementation and avoid unsafe `reinterpret_cast<instance *>()` introduced with PR #2152

The `reinterpret_cast<instance *>(self)` is unsafe if `__new__` is mocked,
which was actually found in the wild: the mock returned `None` for `self`.
This was inconsequential because `inst` is currently cast straight back to
`PyObject *` to compute `all_type_info()`, which is empty if `self` is not
a pybind11 `instance`, and then `inst` is never dereferenced. However, the
unsafe detour through `instance *` is easily avoided and the updated
implementation is less prone to accidents while debugging or refactoring.

* Fix actual undefined behavior exposed by previous changes.

It turns out the previous commit message is incorrect, the `inst` pointer is actually dereferenced, in the `value_and_holder` ctor here:

https://github.com/pybind/pybind11/blob/f3e0602802c7840992c97f4960515777cad6a5c7/include/pybind11/detail/type_caster_base.h#L262-L263

```
259     // Main constructor for a found value/holder:
260     value_and_holder(instance *i, const detail::type_info *type, size_t vpos, size_t index)
261         : inst{i}, index{index}, type{type},
262           vh{inst->simple_layout ? inst->simple_value_holder
263                                  : &inst->nonsimple.values_and_holders[vpos]} {}
```

* Add test_mock_new()

* Experiment: specify indirect bases

* Revert "Experiment: specify indirect bases"

This reverts commit 4f90d85.

* Add `all_type_info_check_for_divergence()` and some tests.

* Call `all_type_info_check_for_divergence()` also from `type_caster_generic::load_impl<>`

* Resolve clang-tidy error:

```
include/pybind11/detail/type_caster_base.h:795:21: error: the 'empty' method should be used to check for emptiness instead of 'size' [readability-container-size-empty,-warnings-as-errors]
                if (matching_bases.size() != 0) {
                    ^~~~~~~~~~~~~~~~~~~~~~~~~~
                    !matching_bases.empty()
```

* Revert "Resolve clang-tidy error:"

This reverts commit df27188.

* Revert "Call `all_type_info_check_for_divergence()` also from `type_caster_generic::load_impl<>`"

This reverts commit 5f5fd6a.

* Revert "Add `all_type_info_check_for_divergence()` and some tests."

This reverts commit 0a9599f.
rwgk added a commit to rwgk/pybind11clif that referenced this pull request Jan 28, 2024
* Call from new `tp_init_intercepted()` (adopting mechanism first added in PyCLIF: google/clif@7cba87d).

* Remove `pybind11_meta_call()` (which was added with pybind/pybind11#2152).
rwgk added a commit to rwgk/pybind11clif that referenced this pull request Jan 28, 2024
* Call from new `tp_init_intercepted()` (adopting mechanism first added in PyCLIF: google/clif@7cba87d).

* Remove `pybind11_meta_call()` (which was added with pybind/pybind11#2152).
rwgk added a commit to google/pybind11clif that referenced this pull request Feb 1, 2024
…ng __init__` safety feature to work for any metaclass. (#30095)

* Also wrap with `py::metaclass((PyObject *) &PyType_Type)`

* Transfer additional tests from PyCLIF python_multiple_inheritance_test.py

* Expand tests to fully cover wrapping with alternative metaclasses.

* * Factor out `ensure_base_init_functions_were_called()`.

* Call from new `tp_init_intercepted()` (adopting mechanism first added in PyCLIF: google/clif@7cba87d).

* Remove `pybind11_meta_call()` (which was added with pybind/pybind11#2152).

* Bug fix (maybe actually two bugs?): simplify condition to `type->tp_init != tp_init_intercepted`

* Removing `Py_DECREF(self)` that leads to MSAN failure (Google toolchain).

```
==6380==WARNING: MemorySanitizer: use-of-uninitialized-value
    #0 0x5611589c9a58 in Py_DECREF third_party/python_runtime/v3_11/Include/object.h:537:9
...

  Uninitialized value was created by a heap deallocation
    #0 0x5611552757b0 in free third_party/llvm/llvm-project/compiler-rt/lib/msan/msan_interceptors.cpp:218:3
    #1 0x56115898e06b in _PyMem_RawFree third_party/python_runtime/v3_11/Objects/obmalloc.c:154:5
    #2 0x56115898f6ad in PyObject_Free third_party/python_runtime/v3_11/Objects/obmalloc.c:769:5
    #3 0x561158271bcc in PyObject_GC_Del third_party/python_runtime/v3_11/Modules/gcmodule.c:2407:5
    #4 0x7f21224b070c in pybind11_object_dealloc third_party/pybind11/include/pybind11/detail/class.h:483:5
    #5 0x5611589c2ed0 in subtype_dealloc third_party/python_runtime/v3_11/Objects/typeobject.c:1463:5
...
```

* IncludeCleaner fixes (Google toolchain).

* Restore `type->tp_call = pybind11_meta_call;` for PyPy only.

* pytest.skip("ensure_base_init_functions_were_called() does not work with PyPy and Python `type` as metaclass")

* Do not intercept our own `tp_init` function (`pybind11_object_init`).

* Add `derived_tp_init_registry` weakref-based cleanup.

* Replace `assert()` with `if` to resolve erroneous `lambda capture 'type' is not used` diagnostics (many CI jobs; seems to be a clang issue).

* Add `derived_tp_init_registry()->count(type) == 0` condition.

* Changes based on feedback from @rainwoodman

* Use PYBIND11_INIT_SAFETY_CHECKS_VIA_* macros, based on suggestion from @rainwoodman
rwgk added a commit to rwgk/pybind11 that referenced this pull request Jun 12, 2024
…e (#30056)

* Snapshot of pybind#4762 applied to pywrapcc

* Universal `bases.size() != vhs.size()` (not as `assert()`)

* Revert "Universal `bases.size() != vhs.size()` (not as `assert()`)"

This reverts commit 4c2407d17e982e1512f43ad89bf8752c0d2c7fe0.

* Streamline implementation and avoid unsafe `reinterpret_cast<instance *>()` introduced with PR pybind#2152

The `reinterpret_cast<instance *>(self)` is unsafe if `__new__` is mocked,
which was actually found in the wild: the mock returned `None` for `self`.
This was inconsequential because `inst` is currently cast straight back to
`PyObject *` to compute `all_type_info()`, which is empty if `self` is not
a pybind11 `instance`, and then `inst` is never dereferenced. However, the
unsafe detour through `instance *` is easily avoided and the updated
implementation is less prone to accidents while debugging or refactoring.

* Fix actual undefined behavior exposed by previous changes.

It turns out the previous commit message is incorrect, the `inst` pointer is actually dereferenced, in the `value_and_holder` ctor here:

https://github.com/pybind/pybind11/blob/f3e0602802c7840992c97f4960515777cad6a5c7/include/pybind11/detail/type_caster_base.h#L262-L263

```
259     // Main constructor for a found value/holder:
260     value_and_holder(instance *i, const detail::type_info *type, size_t vpos, size_t index)
261         : inst{i}, index{index}, type{type},
262           vh{inst->simple_layout ? inst->simple_value_holder
263                                  : &inst->nonsimple.values_and_holders[vpos]} {}
```

* Add test_mock_new()
virtuald added a commit to virtuald/nanobind that referenced this pull request Nov 24, 2025
virtuald added a commit to virtuald/nanobind that referenced this pull request Nov 24, 2025
virtuald added a commit to virtuald/nanobind that referenced this pull request Nov 24, 2025
amjames added a commit to amjames/pybind11 that referenced this pull request Aug 28, 2026
…ting python object

fixes: pybind#6153

Objects initialized with `cls.__new__(cls)` (`cls` is a pybind11 bound
type). Will not have the C++ object allocated. When hitting `load_value`
storage is allocated but not initialized, calling a virtual method will
load a garbage vptr and segfault. This is similar to pybind#2152, but the
guard in metaclass `__call__` is not triggered when using `__new__`.

Protect against giving a pointer to garbage in all cases except the
`__init__` + `__setstate__` path.

Authored with claude
rwgk added a commit that referenced this pull request Sep 15, 2026
* fix: Guard against using a uninitialized value after `__new__` allocating python object

fixes: #6153

Objects initialized with `cls.__new__(cls)` (`cls` is a pybind11 bound
type). Will not have the C++ object allocated. When hitting `load_value`
storage is allocated but not initialized, calling a virtual method will
load a garbage vptr and segfault. This is similar to #2152, but the
guard in metaclass `__call__` is not triggered when using `__new__`.

Protect against giving a pointer to garbage in all cases except the
`__init__` + `__setstate__` path.

Authored with claude

* fix: free lazily allocated storage on failed init and only permit lazy allocation for old-style constructors

If an old-style placement-new `__init__`/`__setstate__` failed after
`self` was loaded, the lazily allocated storage stayed behind with a
null-holder instance, so the uninitialized-value guard never fired again
and later use read uninitialized memory. `instance_construction_scope`
now tracks the constructor's `value_and_holder` and frees storage that
was lazily allocated during a construction that did not complete.

Also arm the scope only when the overload chain contains an old-style
constructor. New-style constructors receive `self` directly and never
need lazy allocation, so reentrant loads of the half-built instance now
raise `ValueError` instead of handing out uninitialized storage.

Assisted-by: ClaudeCode:claude-fable-5
Claude-Session: https://claude.ai/code/session_01TQXCSykMn5EL7sc6VgTUTC

* fix: isolate old-style constructor storage

Track construction per value-and-holder, grant a one-shot loader-frame permission only to the exact legacy constructor self conversion, and keep its raw storage private until the native callback returns.

Reject reentrant, nested, cross-base, and cross-thread loads while preserving overload fallback, failure cleanup, pickle setstate callbacks, and repeated initialization behavior.

* test: skip constructor thread test on Emscripten

* fix: bump internals version to 13

The new detail::instance construction state has cross-DSO semantics that internals-v12 modules do not understand. Isolate the incompatible domains for v3.2.0 and document that future structural or semantic instance changes require another bump.

* Revert "fix: bump internals version to 13"

This reverts commit 14e32ae.

* fix: recover from legacy constructor storage collisions

* test: fix collision subprocess imports

* refactor: simplify old-style constructor storage tracking

The loader frame already identifies the constructor candidate, so the
one-shot `self` permission only needs a frame match and a claimed flag.
This removes both argument guard classes, the changes to cast.h, and the
per-call TLS lookups they added.

Also:
- Hoist deallocate_instance_value to a detail free function and use it
  from instance_construction_scope.
- Take the dispatcher's constructor lock before the construction scope and
  drop the nested critical sections it made redundant.
- Keep the non-constructor path inline: the loader destructor checks for
  storage before the out-of-line cleanup, and the construction scope
  defaults to not started.
- Commit old-style storage once in cpp_function::initialize, gated on
  is_constructor.
- Share one __index__ probe across the reentrancy tests and turn the
  subprocess script into a plain function.

Assisted-by: ClaudeCode:claude-fable-5-1

* test: reject later self alias during old-style init

* fix: restrict old-style constructor self permission to self's own load phase

The one-shot `self` permission granted by `loader_life_support` matched only the
frame and the value slot, not the *phase* of the load. The slot is identified by
the instance, so any later argument that aliases the same, still-unconstructed
`self` matched too, consumed the reservation, and reached C++ over raw storage.

Track the frame's phase instead: authorize the load of positional argument 0
(the typed-`self` variant) and casts performed from within the C++ callable (the
legacy `py::object`-self variant), and deny every load during conversion of
positional arguments >= 1. `argument_loader` reports the argument index, and the
dispatcher flips the frame to the callable phase once loading is done. The frame
pointer is resolved once in the dispatcher, where `is_constructor` is known, so
no non-constructor call pays a thread-local lookup.

This restores the restriction that 89a5f72 dropped, re-enabling the regression
test added in 7a7e9f3, and adds three more tests. Both new negative tests
assert the decisive observable rather than only that the callback was entered,
and both use types chosen so that a build where the guard has regressed reports
an assertion failure instead of crashing during teardown:

- A later argument typed as a *base* of the class under construction. It shares
  the value slot, so it matched, and the reservation was then sized from the
  base's `type_info`: 16 bytes for a 144-byte derived object. Because the claim
  is one-shot it also denied `self` its own storage, so the callback could not
  placement-new at all; the value was nevertheless committed, and destroying it
  ran a virtual destructor over never-constructed memory. The test asserts that
  no reservation was made at the base's size.
- The still-unconstructed `self` reached through a container argument. Here
  `stl.h`'s element caster copy-constructs, so the read of uninitialized memory
  happens inside pybind11 and no binding author can guard against it. The test
  counts copy constructions whose source was raw storage and asserts zero; the
  instrumented copy constructor does not read its source, so the test itself
  performs no uninitialized read.
- A positive test pinning the case that must keep working: a later argument that
  is a different, already-constructed instance of the same class.

Assisted-by: ClaudeCode:claude-opus-5

* style: pre-commit fixes

* style: clang-tidy fixes

The Clang-Tidy job failed on two `modernize-use-default-member-init`
diagnostics in tests/test_class.cpp, both introduced by this branch:

    tests/test_class.cpp:167:18: error: use default member initializer
      for 'payload' [modernize-use-default-member-init,-warnings-as-errors]
    tests/test_class.cpp:177:9: error: use default member initializer
      for 'value' [modernize-use-default-member-init,-warnings-as-errors]

Applied exactly the replacements clang-tidy emitted:

- `AliasStealDerived::payload` gains a `{}` default member initializer
  and drops `payload{}` from the constructor initializer list. Both
  value-initialize the array.
- `ContainerAliasItem::value` gains a `{-1}` default member initializer
  and the copy constructor drops `value(-1)`. The converting constructor
  keeps `value(v)`, which overrides the default, so both constructors
  still produce the values the container-alias test asserts on.

No behavior change; clang-tidy is not installed locally, so the fix-its
were transcribed from the CI diagnostic rather than auto-applied, and
the translation unit was compiled clean at -std=c++17 with the CI
warning set.

Assisted-by: ClaudeCode:claude-opus-5

* fix: GraalPY exceptions

Both GraalPy jobs failed on the same assertion in the legacy-v12
collision test:

    tests/test_class.py:469: assert stats() == (3, 3, constructed + 1, constructed + 1)

That is the final check, reached after `del obj` and two `gc.collect()`
calls. GraalPy is not refcounted and does not guarantee finalization
from `gc.collect()`, so the destruction counters lag and the assertion
fails while every preceding assertion in the loop passes.

Gate only that assertion behind `if not env.GRAALPY:`. This follows the
existing convention in the suite, where GC-timing-dependent checks are
exempted on GraalPy with the same "Cannot reliably trigger GC" reason
(test_call_policies.py, test_callbacks.py,
test_class_sh_trampoline_shared_ptr_cpp_arg.py, and others).

Nothing this branch introduces stops being tested on GraalPy. The gated
line only observes ordinary teardown of a normal, fully constructed
retry object. The rollback properties the test exists for are pinned by
the assertions above it, which still run everywhere: after rollback both
collision allocations are freed with no spurious destruction, and after
the retry exactly one allocation is live with the private value's
destructor having run neither early nor twice.

Assisted-by: ClaudeCode:claude-opus-5

* test: pin the two gaps identified in the load-phase review

Adds coverage for the two limitations called out in the review of the
load-phase restriction. Both tests pass, pinning today's behavior; both
fail against `master`'s headers, which is what makes them meaningful.

test_old_style_init_value_error_hides_later_overload

  Two old-style candidates take the same two Python arguments. The
  first one's argument 1 is the `self` alias that the construction
  guard rejects; the second matches the same call and constructs.
  The guard reports rejection with `value_error`, and only
  `reference_cast_error` becomes PYBIND11_TRY_NEXT_OVERLOAD, so the
  throw escapes the overload loop and the second candidate is never
  attempted.

  Verified counterfactual, same test files built against master's
  headers: master reaches the second candidate and constructs
  (`entered == ["second candidate entered"]`); here the call raises
  ValueError with `entered == []`.

  Note this is a new trigger for pre-existing behavior rather than a
  new behavior: master's casters already throw `value_error` from load
  paths with the same non-fallthrough consequence.

test_old_style_init_callable_phase_grant_is_not_self_specific

  While the callable runs, the one-shot grant is keyed on the value
  slot, not on the `self` handle, so a cast of `stash[0]` claims the
  reservation and the genuine `self` cast then fails. Narrowing the
  grant to "a cast of the `self` object" would not close this: the
  claiming cast targets the same Python object as `self`, so the two
  are indistinguishable at cast time.

  Verified counterfactual: on master both casts succeed
  (`["stash cast claimed the reservation", "self cast succeeded"]`)
  because every load lazily allocates. The one-shot reservation is
  therefore a narrowing of master's behavior, and this gap is the
  residue rather than a regression.

Neither callback inspects the reference it obtains over storage whose
lifetime has not begun, so the tests themselves stay free of undefined
behavior. Both verify the object is still retryable afterwards.

Assisted-by: ClaudeCode:claude-opus-5

* perf: only test the old-style frame pointer where the phase can change

Addresses the review suggestion to stop paying the null check once per
argument.

The literal form suggested, `I == 0 &&`, is not safe: `begin_argument_load`
is what moves the frame from `self_argument` to `later_argument`, so
skipping it for arguments 1 and up leaves the phase at `self_argument` for
the whole argument list. That re-opens exactly the hole 955cb19 closed. It
regresses four tests:

    test_old_style_init_does_not_authorize_later_self_alias
    test_old_style_init_does_not_authorize_base_typed_later_alias
    test_old_style_init_does_not_authorize_self_alias_inside_container
    test_old_style_init_value_error_hides_later_overload

Gate on `I < 2` instead. The phase only changes at argument 0 and argument
1; from argument 2 on it is already `later_argument`, so those arguments
need no call and no test. Two checks per call rather than one, but it is
the minimum that preserves the invariant. All 55 tests pass.

`I` is a template parameter, so no `if constexpr` is needed and none can be
used: pybind11 still supports C++11 and `if constexpr` is a C++17
extension there. A plain `if` on a constant condition already folds
completely. clang -O2, the `I == 5` instantiation of a reduction of this
function tail-calls straight through with no pointer test emitted, while
`I == 0` and `I == 1` keep theirs.

MSVC C4127 (constant conditional) is already disabled file-wide at the top
of cast.h, and the header compiles clean at -std=c++11/14/17/20 with
-Wall -Wextra -Wpedantic -Wconversion -Werror.

Assisted-by: ClaudeCode:claude-opus-5

* docs: describe status_value_constructing with the other status bits

The non-simple layout comment enumerated status_holder_constructed and
status_instance_registered but not status_value_constructing, which was
added alongside them. Addresses the review comment on that block.

Also states what the bit means for readers of the value pointer: while it
is set, the pointer must not be treated as denoting a live C++ object.
That is the invariant the rest of this change depends on, and the status
byte is where someone will look for it.

Comment-only. Longest line is 94 columns, within the 99-column limit, so
clang-format does not reflow it.

Assisted-by: ClaudeCode:claude-opus-5

* style: pre-commit fixes

* style: clang-tidy/format

* fix: narrow uninitialized-instance guard to direct misuse

Return to the minimal scope needed for #6153: ordinary loads of a wrapper with no constructed C++ value raise ValueError, while overload chains containing deprecated placement-new constructors retain their historical lazy allocation. Failed old-style construction also frees lazily allocated storage so the object remains guarded and retryable.

Remove the expanded per-value construction protocol, including private candidate storage, argument and callable phases, cross-thread locking, and stale-v12 collision recovery. Those mechanisms attempted to make deprecated placement-new construction safe under reentry rather than fixing direct __new__ misuse.

Accordingly, remove tests requiring special handling for later arguments (both distinct instances and self aliases), base-typed and container aliases, nested initialization, Python multiple-inheritance bases, concurrent access, mixed old/new overloads, and old-style __setstate__ reentry. Also remove tests pinning protocol-specific overload fallthrough, callable-phase grants, and legacy-v12 collision cleanup.

Keep focused coverage for direct __new__ misuse, reentry during new-style construction, deprecated __init__/__setstate__ compatibility, failed-construction cleanup, and successful retry.

* test: characterize old-style reentrant load limitation

Keep one pointer-only probe for the historical broad lazy-allocation window. It records the known hazard that a reentrant load can expose a pointer to unconstructed storage, without inspecting or dereferencing that storage.

The cleanup and retry assertions remain in place so the behavior retained by the minimal fix stays covered.

* docs: document deprecated placement-new limitations

Explain that the compatibility window spans an entire constructor overload chain and can expose unconstructed storage during reentrant conversion or callbacks, nested initialization, Python multiple inheritance, or concurrent access.

Recommend new-style constructor and pickle APIs, and add source-level references at the compatibility flag and scope so future changes encounter the accepted limitations and rationale before attempting to narrow the window.

---------

Co-authored-by: Henry Schreiner <henryfs@princeton.edu>
Co-authored-by: Ralf W. Grosse-Kunstleve <rgrossekunst@nvidia.com>
Co-authored-by: Andrew M. James <ajames@openteams.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use pybind11 metaclass to detect when __init__ has not been called from subclass

6 participants