Skip to content

[BUG]: Access to a __new__-created instance (C++ constructor never ran) dispatches through unconstructed storage instead of raising #6153

Description

@kennethkcox

Required prerequisites

What version (or hash if on master) of pybind11 are you using?

since v2.6 (#2152)

Problem description

Summary

Since v2.6 (#2152) the default metaclass throws TypeError when a Python subclass overrides __init__ without calling the bound C++ constructor, precisely to prevent the documented "uninitialized C++ portion → undefined behavior" footgun. That guard lives in pybind11_meta_call(), i.e. the metaclass __call__ path.

Calling cls.__new__(cls) directly bypasses __call__, so the guard never runs. The instance is returned with its C++ storage never constructed, and the first native access to it takes the lazy ::operator new path in detail/type_caster_base.h load_value() and dispatches into unconstructed storage. A virtual call then dereferences an uninitialized vtable pointer and the process dies with SIGSEGV.

This matters because cls.__new__(cls) is not an exotic operation: it is exactly what pickle's NEWOBJ opcode does. So any binding that is (de)serialized can reach the documented UB with no subclass and no misuse in sight.

Reproducible example code

### Reproduction (standalone, no downstream library)

`repro.cpp`:


#include <pybind11/pybind11.h>
#include <string>
namespace py = pybind11;

struct Widget {
    int magic = 0x0000ABCD;
    std::string name = "constructed";
    virtual ~Widget() = default;
    virtual int compute() const { return magic; }   // virtual -> vtable dispatch
    int get_magic() const { return magic; }          // non-virtual field read
    const std::string &get_name() const { return name; }
};

PYBIND11_MODULE(repro, m) {
    py::class_<Widget>(m, "Widget")
        .def(py::init<>())
        .def("compute", &Widget::compute)
        .def("get_magic", &Widget::get_magic)
        .def("get_name", &Widget::get_name);
}


Build:


python -m venv .venv && ./.venv/bin/pip install pybind11
EXT=$(./.venv/bin/python -c "import sysconfig;print(sysconfig.get_config_var('EXT_SUFFIX'))")
c++ -O3 -std=c++17 -fPIC -shared $(./.venv/bin/python -m pybind11 --includes) \
    repro.cpp -o repro$EXT -undefined dynamic_lookup


Behavior, each case in a fresh interpreter:


# A. subclass overrides __init__ and skips base init  -> guard fires (correct)
class Sub(repro.Widget):
    def __init__(self): pass
Sub()                    # TypeError: repro.Widget.__init__() must be called when overriding __init__

# B. base type via __new__ directly                    -> no guard, segfault
u = repro.Widget.__new__(repro.Widget)
u.compute()              # SIGSEGV

# C. subclass via __new__ directly                     -> no guard, segfault
class Sub2(repro.Widget):
    def __init__(self): repro.Widget.__init__(self)
Sub2.__new__(Sub2).compute()   # SIGSEGV


Observed on **pybind11 3.1.0, Python 3.14.6, macOS arm64 (Darwin 25.5.0), Apple clang**:

| Case | Guard | Result |
|---|---|---|
| A — subclass skips base `__init__` (the #2152 case) | `TypeError` | safe |
| B — `Widget.__new__(Widget)` | none | SIGSEGV (exit 139) |
| C — `Sub.__new__(Sub)` | none | SIGSEGV (exit 139) |

Only the virtual call crashes. Non-virtual reads on the same object return `0` / `""` on this platform (the allocator happens to hand back a zeroed block; on glibc they return garbage instead), so the uninitialized storage is directly observable and which access faults is layout/allocator dependent.

### Why this is worth closing rather than "don't do that"

The docs already state this is UB, and #2152 already committed to catching it. The gap is only that the catch sits in the metaclass call path while `__new__` walks around it. Since `NEWOBJ` is the standard pickle mechanism, the UB is reachable from ordinary serialization, not just from a user forgetting `super().__init__()`. A concrete downstream instance: `torch.distributed.tensor.placement_types.Shard.__new__(Shard).is_shard()` segfaults on torch 2.10–2.13.

### Suggested direction

Zero-initializing the lazy allocation would only turn the UB into a dependable null-vptr crash, since the first virtual call still dereferences it. The behavior that restores the invariant is to treat native access to a holder that was never constructed as an error (raise), rather than lazily allocating raw storage in `load_value()`. Happy to help with a PR or a regression test.

Is this a regression? Put the last known working version here if it is.

Not a regression

Metadata

Metadata

Assignees

No one assigned

    Labels

    triageNew bug, unverified

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions