fix: do not run keep_alive for an overload that failed argument conversion - #6154
fix: do not run keep_alive for an overload that failed argument conversion#6154jturney wants to merge 1 commit into
Conversation
…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.
…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.
espressolee
left a comment
There was a problem hiding this comment.
I independently built base 5e9611aacc0bdd2054aa36800055014ebcd8e805 and exact head 3536003c234e605df76bc3c70276072bfee65b66 on macOS/arm64 with CPython 3.14.6 and 3.14.6t, in both Debug and NDEBUG builds.
The patch does fix the reported sentinel crash. Both keep_alive<0, 1> (return is nurse) and the symmetric keep_alive<1, 0> (return is patient) crash on the base after the first overload rejects an argument; all four head configurations return normally. The PR test is 9/9 on 3.14 and 9/9 on 3.14t. The full exact-head Python suite is 1307 passed / 43 skipped / 2 xfailed / 1 xpassed, and the C++ suite is 34 cases / 11357 assertions passed. One CMake installed_function integration target aborts during Python 3.14.6 finalization, but the exact base reproduces the same failure, so I do not attribute that to this PR.
I found two adjacent pre-existing gaps that matter to the scope choice in the PR description:
-
Non-return
keep_alivestill runs for a rejected overload.process_attributes<...>::precall(call)runs atpybind11.h:588-590, beforeload_args(). With a first overload carryingkeep_alive<1, 2>and rejecting its third argument, followed by a successful overload with no policy, the rejected overload still attaches patient 2 to nurse 1. On the exact head, one failed attempt retains the patient until the nurse dies; 1000 failed attempts retain 1000/1000 patients. A matched second-overload-only control retains 0/1000. This is identical on 3.14/3.14t and Debug/Release. -
A failed return conversion still invokes postcall with a null result. A custom return caster that sets an exception and returns an empty handle behaves normally without
keep_alive(TypeError: Unable to convert function return value...). Withkeep_alive<0, 1>, exact head aborts in Debug atpybind11_fail'sassert(!PyErr_Occurred()); Release masks the conversion error withRuntimeError: Could not activate keep_alive!. The sentinel-only guard atpybind11.h:3364does not cover this null/error result.
Both gaps are present on the base, so they are not regressions introduced by the patch. The narrow guard is sound if this PR is explicitly scoped to the return-index PYBIND11_TRY_NEXT_OVERLOAD crash and the two gaps are tracked separately. If the intended scope is the current broader title—do not run keep_alive for an overload that failed argument conversion—then the non-return specialization also needs to move after successful conversion.
As a feasibility check only, I moved all keep_alive activation to successful postcall and skipped null/error results in a scratch tree. That closed the 10-case normal/free-threaded Debug/Release matrix, preserved successful lifetime relationships and conversion fallback, and kept the existing call-policy tests at 9/9. I am leaving this as a commented review rather than a blocking review because the two residuals predate the submitted diff.
|
Review with Fable: 🤖 AI text below 🤖 Code review is done. The fix itself is verified correct — the sentinel guard is right, and the test does crash without it. Six findings, ranked: Correctness (same bug family):
Test robustness:
Altitude / cleanup:
|
|
Hi @jturney, you made a fork of a fork ( |
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:The crash is
EXC_BAD_ACCESSin_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::referenceis not required to trigger it, and removingkeep_alivemakes it go away.Cause
The dispatch lambda in
cpp_function::initializeruns the post-call hook unconditionally (pybind11.h:596-603):auto result = call_impl<...>(call, ...); process_attributes<Extra...>::postcall(call, result);call_implbails out atpybind11.h:504when argument loading fails:and that sentinel is
((PyObject *) 1)(detail/common.h:367). For akeep_alivewhose nurse or patient is index 0 the work happens inpostcall, sokeep_alive_implreceives the sentinel asret,get_arg(0)returns it, and_Py_TYPEdereferences 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 == 0specialization does its work inpostcall. The other specialization runs inprecall, against a fully populatedcall.args, and I verified thatkeep_alive<1, 2>on the same setup is unaffected.keep_aliveis also the only call policy with a non-trivialpostcall; the other three inattr.hhave empty bodies. So this guard covers every path that can observe the sentinel.An alternative would be to guard the
postcallcall 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: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, andTRY_NEXT_OVERLOAD, and grepped the diffs of all 39 open PRs that touchpybind11.h.