Skip to content

fix: do not run keep_alive for an overload that failed argument conversion - #6154

Open
jturney wants to merge 1 commit into
pybind:masterfrom
jturney:fix-keep-alive-failed-overload
Open

fix: do not run keep_alive for an overload that failed argument conversion#6154
jturney wants to merge 1 commit into
pybind:masterfrom
jturney:fix-keep-alive-failed-overload

Conversation

@jturney

@jturney jturney commented Aug 26, 2026

Copy link
Copy Markdown

Description

Calling an overloaded function bound with py::keep_alive<0, N> segfaults whenever the call is not matched by the first overload tried. Minimal reproducer:

struct Tensor {};
struct Holder {
    Tensor t;
    Tensor &from_int(int)            { return t; }
    Tensor &from_string(std::string) { return t; }
};

PYBIND11_MODULE(min, m) {
    py::class_<Tensor>(m, "Tensor");
    py::class_<Holder>(m, "Holder")
        .def(py::init<>())
        .def("make", &Holder::from_int,    py::return_value_policy::reference, py::keep_alive<0, 1>())
        .def("make", &Holder::from_string, py::return_value_policy::reference, py::keep_alive<0, 1>());
}
>>> h.make(1)     # <min.Tensor object at 0x...>
>>> h.make("x")   # Segmentation fault

The crash is EXC_BAD_ACCESS in _Py_TYPE(ob=0x1). Swapping the registration order swaps which call crashes, so it is always the call that reaches an overload after an earlier one failed argument conversion. return_value_policy::reference is not required to trigger it, and removing keep_alive makes it go away.

Cause

The dispatch lambda in cpp_function::initialize runs the post-call hook unconditionally (pybind11.h:596-603):

auto result = call_impl<...>(call, ...);
process_attributes<Extra...>::postcall(call, result);

call_impl bails out at pybind11.h:504 when argument loading fails:

if (!args_converter.load_args(call)) { return PYBIND11_TRY_NEXT_OVERLOAD; }

and that sentinel is ((PyObject *) 1) (detail/common.h:367). For a keep_alive whose nurse or patient is index 0 the work happens in postcall, so keep_alive_impl receives the sentinel as ret, get_arg(0) returns it, and _Py_TYPE dereferences it.

The existing guards do not catch this. The sentinel is neither null nor Py_None, so it passes straight through the checks added in #341.

Fix

A single guard inside keep_alive_impl, beside those checks.

On scope: only the Nurse == 0 || Patient == 0 specialization does its work in postcall. The other specialization runs in precall, against a fully populated call.args, and I verified that keep_alive<1, 2> on the same setup is unaffected. keep_alive is also the only call policy with a non-trivial postcall; the other three in attr.h have empty bodies. So this guard covers every path that can observe the sentinel.

An alternative would be to guard the postcall call site so that no policy runs on the bail-out path. That may be more correct in general, since running a post-call hook for a call that never happened is questionable on its own terms, but it changes behaviour for call policies broadly. I went with the narrow fix and am happy to switch if you prefer the other.

Tests

Added to test_call_policies. The new test crashes the interpreter without the fix, which is rather the point: pybind11 currently has no test that exercises a call policy on a failing overload, and that gap is the likely reason this went unnoticed for so long.

Full suite on macOS/arm64, clang, Python 3.14, against master 5e9611a:

result
master (baseline) 1326 passed, 23 skipped, 2 xfailed, 1 xpassed
this PR 1327 passed, 23 skipped, 2 xfailed, 1 xpassed

Notes

Reproduces on both v3.1.0 and current master. I could not find an existing issue or PR for this. I searched issues and PRs for keep_alive, postcall, and TRY_NEXT_OVERLOAD, and grepped the diffs of all 39 open PRs that touch pybind11.h.

…rsion

Calling an overloaded function bound with `py::keep_alive<0, N>` segfaults
whenever the call is not matched by the first overload tried.

    .def("make", &Holder::from_int,    py::keep_alive<0, 1>())
    .def("make", &Holder::from_string, py::keep_alive<0, 1>());

    h.make(1)    # fine
    h.make("x")  # SIGSEGV

The dispatch lambda in `cpp_function::initialize` invokes the post-call hook
unconditionally:

    auto result = call_impl<...>(call, ...);
    process_attributes<Extra...>::postcall(call, result);

but `call_impl` returns `PYBIND11_TRY_NEXT_OVERLOAD` when `load_args` fails,
and that sentinel is `((PyObject *) 1)` rather than an object. For a
`keep_alive` whose nurse or patient is index 0 the work happens in postcall,
so `keep_alive_impl` receives the sentinel as `ret`, hands it to `get_arg(0)`
and dereferences it in `_Py_TYPE`.

The existing guards do not catch it: the sentinel is neither null nor
`Py_None`, so it passes straight through the checks added in pybind#341.

Guard inside `keep_alive_impl`, next to those checks. Only the
`Nurse == 0 || Patient == 0` specialization does its work in postcall, and
`keep_alive` is the only call policy with a non-trivial postcall, so this
covers every path that can observe the sentinel. `keep_alive<1, 2>` and
friends run in precall against fully populated `call.args` and are unaffected.

The regression test crashes the interpreter without the fix. Reaching the
second overload is what matters: it is the first overload's failed conversion
that produces the sentinel.
@jturney
jturney marked this pull request as ready for review August 26, 2026 19:14
jturney added a commit to jturney/Einsums that referenced this pull request Aug 27, 2026
…say so

Three follow-ups to the space-typed declaration, one of which corrects it.

An index space now carries a `dim_symbol` ("no" for "occ"), and a
space-shaped axis takes its symbol from there. The first cut used the space's
NAME, which is wrong twice over. It makes the saved `symbol_ties` table a list
of tautologies, and it asserts a one-to-one relation between spaces and
extents that does not hold: a ragged space has many extents, which is why
raggedness is spelled with a prefix rather than as the bare name, and a plain
symbol "pao" would have claimed a single extent for a space that may not have
one with nothing to detect the contradiction. A space registered without a dim
symbol is now refused rather than having a name invented for it.

The field costs the saved schema nothing, which was checked and not assumed:
the IR writes space NAMES plus the symbol ties, and resolves every other
IndexSpace field from the loading process's registry.

The whole surface reaches Python, which closes a gap that predates this work:
annotate_dims and annotate_ragged_dim had never been exposed, so a Python
caller could save and load a graph but could not make one rebindable, which is
the entire point of symbolic extents. Also exposed: annotate_space_axis,
bind_ragged_extents, tensor_dim_symbols, the extent and tiling accessors,
SpaceDim, SpaceTiling, fixed, tiles, and the space-shaped factories.

Those factories are bound under their own Python names (declare_zero_tensor_over
and friends) rather than sharing the dims-based name. pybind resolves overloads
at runtime by trying each in turn, and a keep_alive<0, N> on an overload that
fails argument conversion is handed the PYBIND11_TRY_NEXT_OVERLOAD sentinel,
(PyObject *) 1, as its return value and dereferences it. A shared name is
therefore a segfault rather than an ambiguity. Found by writing the Python test
below, reported upstream as pybind/pybind11#6154 with a fix and a regression
test. The exposure is wider than these factories: the codegen puts
keep_alive<0, 1> on every returning method, so any generated Python name with
more than one overload has the same latent crash.

Last, create_* takes a space-typed shape too, and deliberately stops short of
writing dim symbols. The axes are sized and annotated with their spaces, but a
tensor allocated when the call returns cannot be resized by a bind, so a symbol
there would promise a rebindability the storage cannot honour.
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.

1 participant