Skip to content

fix: never run keep_alive for a failed overload or failed return conversion - #6183

Open
henryiii wants to merge 9 commits into
pybind:masterfrom
henryiii:fix-keep-alive-failed-overload
Open

henryiii wants to merge 9 commits into
pybind:masterfrom
henryiii:fix-keep-alive-failed-overload

Conversation

@henryiii

@henryiii henryiii commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Description

This PR contains the commit from, and closes #6154 by @jturney. It adds more commits on top.

#6154: a py::keep_alive with index 0 on an overloaded function causes a segfault if an earlier overload fails argument conversion. The dispatcher calls postcall with the PYBIND11_TRY_NEXT_OVERLOAD sentinel ((PyObject *) 1), and keep_alive_impl dereferences it.

The added commits extend that fix:

  • The dispatcher now runs postcall only for the overload that matched. Before, it also ran postcall with the sentinel for an overload that failed argument conversion.
  • The precall hook now runs after argument loading (load_args) and final caster extraction (cast_op), before guard construction and the C++ call. An argument-to-argument keep_alive (for example keep_alive<1, 2>) therefore cannot retain a patient for an overload rejected during conversion, including a late C++ reference-conversion failure. A keep_alive error still stops the call before it has side effects.
  • call_guard construction now follows caster extraction for every binding, including those without keep_alive. Conversion rejected after load_args constructs no guard. Python-dependent extraction therefore runs before a gil_scoped_release guard releases the GIL. The invocation wrapper's by-value parameters are initialized before its local guard and destroyed after it; forwarding into the actual callable remains inside the guard scope.
  • keep_alive_impl skips a null return handle. A failed return-value conversion now raises its own error, not "Could not activate keep_alive!".
  • The dispatcher owns the converted return value throughout postcall. An exception from any postcall hook, including an index-0 keep_alive or a custom attribute, releases that value.
  • The precall and postcall documentation now says when each runs, and that the postcall handle is null if return-value conversion failed.

Custom process_attribute specializations follow the same ordering: precall after successful argument conversion, and postcall only for the overload that matched. keep_alive is the only built-in attribute that uses precall.

Tests cover the return value as nurse and as patient, rejected sole and constructor candidates, the argument-to-argument case, late reference-conversion failures, failed return conversion, throwing precall/postcall hooks, GIL-release ordering, and guard construction and destruction relative to argument extraction and invocation parameters.

The shared per-signature dispatch path receives precall through a function pointer. An indirect hook call can remain when the compiler retains the outlined helper; optimization can eliminate an empty hook call. A GCC 13.3 -O2 probe eliminated it without LTO. This is not a runtime latency measurement.

Closes #6154

Suggested changelog entry:

  • Fixed a segfault when a function with py::keep_alive fails argument conversion. py::keep_alive and custom process_attribute::precall hooks now run after successful argument loading and caster extraction; postcall runs only for a matched overload, and a throwing postcall hook releases the converted return value. Call guards now start after argument conversion, including caster extraction, so a candidate rejected during conversion does not construct a guard.

📚 Documentation preview 📚: https://pybind11--6183.org.readthedocs.build/


📚 Documentation preview 📚: https://pybind11--6183.org.readthedocs.build/

jturney and others added 4 commits September 23, 2026 13:34
…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.
…ersion

Move keep_alive activation entirely to postcall, so an argument-to-argument
keep_alive no longer fires in precall for an overload that later fails
argument conversion. Extend the postcall guard to also skip a null return
handle, so a failed return-value conversion raises its own error instead of
"Could not activate keep_alive!". Document that postcall can receive the
PYBIND11_TRY_NEXT_OVERLOAD sentinel or a null handle.

Tests cover the sentinel as nurse and as patient, the argument-to-argument
case, and the failed return conversion, and no longer make the test object
its own patient.

Assisted-by: ClaudeCode:claude-fable-5
Claude-Session: https://claude.ai/code/session_01WRPhwYhrJKvFXU2NWe1p3W
Moving keep_alive<N, P> (N, P != 0) to postcall meant a keep_alive error
was raised after the function ran, so its side effects stayed. Run the
precall hook in call_impl after load_args succeeds instead, so a failed
overload still does not trigger it and an error still stops the call.

Also release the return value when a postcall keep_alive throws, as the
dispatcher drops it.

Assisted-by: ClaudeCode:claude-opus-5-5
Assisted-by: ClaudeCode:claude-opus-5-5

@itamaro itamaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

thanks for folding #6154 in! we hit the overload-fallback segfault on 3.1.0 and have been carrying a backport of 66811ce, so this is timely :)

nit: the changelog entry should probably mention that custom process_attribute::precall now runs after argument conversion.

Comment thread include/pybind11/pybind11.h Outdated
Comment on lines 601 to 607
extract_guard_t<Extra...>,
cast_in>(call, detail::function_ref<Return(Args...)>(cap->f));
cast_in>(call,
detail::function_ref<Return(Args...)>(cap->f),
&process_attributes<Extra...>::precall);

/* Invoke call policy post-call hook */
process_attributes<Extra...>::postcall(call, result);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

now that precall runs only for the matched overload, should postcall skip PYBIND11_TRY_NEXT_OVERLOAD too?

Comment thread include/pybind11/attr.h Outdated
Comment on lines +414 to +415
/// The handle is not always a valid object: it is the PYBIND11_TRY_NEXT_OVERLOAD sentinel
/// if argument conversion failed, and null if the call or return-value conversion failed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: a throwing call never reaches postcall, so null only comes from a failed return conversion. Maybe "…and null if return-value conversion failed"?

Comment thread include/pybind11/pybind11.h Outdated
Comment on lines +3394 to +3403
// With index 0, this runs in postcall, which the dispatcher runs even when the overload
// produced no value: `ret` is the PYBIND11_TRY_NEXT_OVERLOAD sentinel ((PyObject *) 1) if
// the overload bailed out of `load_args`, and null if the return-value conversion failed
// (with the real error already set). There is no relationship to establish then. The
// sentinel would pass the guards below and be dereferenced, and a null `ret` would be
// reported as "Could not activate keep_alive!", masking the real error.
const bool uses_ret = Nurse == 0 || Patient == 0;
if (uses_ret && (!ret || ret.ptr() == PYBIND11_TRY_NEXT_OVERLOAD)) {
return;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

nit: maybe note that uses_ret is needed because the precall path passes handle()? Otherwise it looks simplifiable

assert m.without_gil() == "GIL released"


def test_keep_alive_failed_overload():

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

could we cover a rejected sole candidate too? It also crashed on 3.1.0

     with pytest.raises(TypeError):
         m.keep_alive_single(obj, "x")
     m.def(
         "keep_alive_single",
         [](const KeepAliveOverload &, int) { return KeepAliveOverload(); },
         py::keep_alive<0, 1>());

The dispatcher now runs postcall only for the overload that matched, like
precall. Add a test for a rejected sole candidate, and clarify the
postcall and keep_alive_impl comments.

Assisted-by: ClaudeCode:claude-opus-5-5
brentDSL pushed a commit to devstreamlabs/sofa-python3-feedstock that referenced this pull request Oct 4, 2026
pybind11 3.1.0 runs keep_alive's postcall on the try-next-overload
sentinel, so any binding with keep_alive<0, N> segfaults when called with
arguments that match no overload -- Node.addObject(1.0) in SofaPython3.
Fixed upstream by pybind/pybind11#6183. The runtime test now checks that
such a call raises TypeError.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@brentDSL

brentDSL commented Oct 4, 2026

Copy link
Copy Markdown

Downstream confirmation from SofaPython3 (SOFA's bindings), which hits this through Node.addObject, bound with keep_alive<0, 2>.

SOFA master + SofaPython3 master built from source on CPython 3.14.7 (linux-64, conda-forge toolchain):

pybind11 v3.1.0 this PR (a7e62c8)
node.addObject(1.0) segfault TypeError
Bindings.Sofa.Tests segfault at test 107 (test_createObjectInvalid) 148/148 pass
Bindings.Modules.Tests 9/11 (2 failures from scipy missing in my env, unrelated) 11/11 pass
Bindings.SofaRuntime.Tests 2/2 2/2
Bindings.SofaTypes.Tests 6/6 6/6

A minimal keep_alive<0, 1> reproducer raises TypeError with v3.0.4 and segfaults with v3.1.0 on both 3.12 and 3.14, consistent with the regression arriving with #5887. In the meantime we are building conda-forge's sofa-python3 against pybind11 <3.1. Thanks for the thorough fix.

@rwgk

rwgk commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

I just started a codex session for merging master and reviewing this PR. It'll probably run for a while, add commits, and run the CI.

I'll post another comment when I'm done reviewing the new work myself.

Run precall after cast_op and parameter conversion without adding copies or moves, and keep it before guard construction. Protect the owned return throughout postcall so any throwing hook releases it.

Extend regression coverage for reference rejection, converting overloads, constructors, return retention, custom hooks and return conversion errors.
@rwgk

rwgk commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

codex GPT-6.1-Sol ultra

Work report for pybind11 PR #6183, requested October 7, 2026.

Reviewed the original PR head a7e62c8 after reading both maintenance notes, the PR description, downstream confirmation, and existing review discussions. The review covered dispatcher overload resolution, argument and return conversion, keep_alive lifetime ownership, constructors, custom attribute hooks, call guards, portability, and the precompiled mode.

Merging master was warranted because upstream had moved the non-template implementations into pybind11-inl.h, leaving a real conflict in pybind11.h. Merged current upstream master 46bfd99, resolved the conflict by moving the PR's keep_alive_impl changes into the extracted implementation header, and preserved its inline/export annotations. Merge commit 0b45bce was validated independently before review fixes.

Two remaining correctness issues were reproduced and fixed in commit 5dec644:

  1. Successful load_args did not ensure successful C++ argument conversion. A registered-type caster can load None and then reject it when extracting a C++ reference. The PR ran precall between these steps, so a rejected candidate could retain its child. The hook now runs after the final caster operations and parameter construction, before guard construction. The added function_ref::invoke_with_guard preserves the existing by-value parameter boundary and copy elision; the argument_loader overloads expand casts directly into that entry point. Guards that release the GIL remain after precall.
  2. Result cleanup covered only exceptions thrown by keep_alive_impl. A custom postcall that threw after creating a result still leaked that result; the helper also decremented a handle whose ownership belonged to its caller. The dispatcher now owns every true call result through the complete postcall sequence with a py::object guard, and the helper no longer decrements that borrowed handle.

The regression run against the merged PR, before production fixes, had 3 failures and 19 passes: late-reference patient retention, an unexpected precall event for that rejected reference, and a missing child destructor after a throwing custom postcall. All three pass with the fixes.

Expanded call-policy tests also cover the converting overload pass, rejected constructor overloads, actual retention with the return as nurse and patient, both failed-return-conversion directions and their underlying unregistered-type cause, hook behavior for successful and throwing calls, null postcall results, and GIL-release guard ordering. Replaced the error-call global counter with caller-owned event lists.

Local validation used GCC 13.3.0, C++20, and CPython 3.14.4 at 23116f998f6:

Revision Default Python Free-threaded Python Catch2 in each environment
Merge commit 1,366 passed; 2 skipped 1,347 passed; 21 skipped 28 cases; 1,633 assertions passed
Final fixes 1,376 passed; 2 skipped 1,357 passed; 21 skipped 28 cases; 1,633 assertions passed

The corrected focused call-policy/copy-move run passed all 32 cases. The full suite caught extra tuple/pair moves in an intermediate wrapper; the final invocation method preserves the original counts, with the existing assertions unchanged. Separate checks against the actual function_ref header passed under C++11 and C++17. All repository pre-commit run --all-files checks passed after automatic formatting. The skipped tests and 24 pytest deprecation warnings per full run concern existing optional/GIL cases and iterable parametrization.

Pushed both commits with a normal fast-forward push to henryiii/pybind11:fix-keep-alive-failed-overload. Verified GitHub's head is 5dec644 and the PR is mergeable. The local branch retains its existing henryiii/fix-keep-alive-failed-overload upstream. GitHub MCP was used for PR discussion and initial CI inspection; local git and gh were used for fetching, merging, publication, and ongoing CI monitoring.

Final CI verification at 2026-10-07 08:45:13 UTC confirmed GitHub still points to 5dec644aa110a1b94f0db40b1bf269a2c7c5d9bb, with 79 successful check runs, 2 expected skipped checks, and all 3 external statuses successful. No pending, failed, cancelled, or timed-out checks remain. The expected skips are the upstream Python nightly job and PyPI upload.

Workflow Final result Attempt
CI success 1
CIBW success 1
Config success 1
Format success 1
Pip success 1
Read the Docs PR preview success 1
Upstream skipped 1

External statuses also passed: pre-commit.ci - pr, docs/readthedocs.org:pybind11, continuous-integration/appveyor/pr.

All four precompiled-mode jobs passed, covering Ubuntu Python 3.13, Ubuntu free-threaded Python 3.14, Windows Python 3.13, and macOS Python 3.13. The macOS C++11 job, PyPy and GraalPy configurations, free-threaded configurations, and wheel jobs also passed. The Windows regular Python 3.14/C++20 environment associated with #6174 passed on its first attempt. No CI failures occurred, so no deflaking reruns or additional CI-fix commits were needed.

Final handoff verification: the worktree is clean, and the local branch, its existing upstream, and the remote PR branch all point to the tested commit. No ABI data layout or platform ABI ID changes were introduced by the review fixes. Custom attribute hooks follow the documented timing: precall after argument conversion, postcall only for a matched call, and a null postcall handle when return conversion fails. No findings remain unresolved from this review.

@rwgk

rwgk commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

This is an independent Fable 5 review:

Review of PR #6183 (local branch fix-keep-alive-failed-overload, HEAD 5dec644, 7 commits on top of master 46bfd99).

Verdict: approve, with a few non-blocking comments. The fix is correct, the design keeps the code-sharing property of the outlined call_impl, and the tests are thorough. I verified this locally rather than by reading alone.

What I checked:

  • Built and ran test_call_policies plus nine neighbouring test modules (copy_move, gil_scoped, class, factory_constructors, etc.) with GCC in Debug with PYBIND11_WERROR=ON. All 245 tests pass. Pre-commit hooks pass on every changed file.
  • Rebuilt with master's headers and the branch's tests. Every one of the eight new tests fails or segfaults without the fix. The retention test and the original overload test reproduce the issue fix: do not run keep_alive for an overload that failed argument conversion #6154 segfault.
  • Code size in Release: text grew by about 0.2 percent for the test module with only the headers changed. In Debug it is about 3.5 percent, which is just the extra unoptimised frame per signature. Not a concern.

Comments, in rough priority order:

  • Undocumented behaviour change for call_guard. Previously the guard was constructed before the cast_op conversions, because the old path passed Guard{} as an argument into the converting function. Now invoke_with_guard in include/pybind11/detail/function_ref.h:109 evaluates all cast_op calls first, then the hook, then the guard. I think this is an improvement, since argument extraction and reference_cast_error now happen with the GIL held, and it matches the pseudocode in the docs. But it affects every call_guard user, not only keep_alive, so it should be stated in the PR description and the changelog entry.
  • Dead code in argument_loader. The one-argument call overloads and the call_impl taking Guard && in include/pybind11/cast.h:2165 to 2221 are no longer called anywhere in pybind11. Either remove them or add a comment saying they are kept for external callers. Related: the new two-argument call is generic in Func but only compiles for function_ref, because it needs invoke_with_guard. A one-line comment there explaining why the hook lives in function_ref (to force conversion before the hook without materialising possibly non-movable results, see [BUG]: Possible 3.1.0 regression when returning an object that has a copy constructor but no move constructor #6142) would help the next reader, since the rationale is currently only in pybind11.h.
  • PR description precision. It says precall now runs "after load_args succeeds". It actually runs after load_args and after the cast_op conversions, which is what makes the late_reference tests pass. The comments in attr.h and pybind11.h already say this correctly, so only the description needs the tweak.
  • Per-call cost. Every bound function now makes an indirect call through the precall pointer, even with no attributes, where before the empty default inlined away. I don't see a way to avoid it without a trait that user-defined process_attribute specialisations would have to opt into, so I'd accept it, but it is worth one sentence in the PR.
  • Naming nit. The same concept is called precall, before, and pre_call across the three headers. Picking one would be nicer.
  • Tests. The del c refcount checks without a collect follow the existing conventions in this file and have the PyPy xfail, so fine. The custom process_attribute hooks test is a good addition and covers the GIL-released case too.

Nothing I found blocks merging. The two items I would actually ask for before merge are the changelog mention of the call_guard ordering change and a decision on the dead argument_loader overloads.

rwgk added 2 commits October 7, 2026 09:21
Remove unused argument-loader invocation overloads, standardize precall naming, and explain the function_ref parameter boundary and copy elision.

Document guard ordering and test caster rejection, guarded calls, by-value parameter destruction, and extraction before GIL release.

This branch has not been deployed

No deployments
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.

5 participants