fix: only add string_view life support for transient sources (#6096)

* revert: "revert: add life support to handles cast to string_view (#6092)"

This re-applies #6092 (reverting #6097) so the follow-up fixes in this PR can build on it.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix: don't throw from string_view life support outside a bound function

PR #6092 added loader_life_support::add_patient(src) to keep the source
object alive when loading a string view, fixing a real use-after-free when
a container of views is built from a non-sequence iterable (e.g. a
generator): list_caster materializes a temporary tuple that owns the
strings and destroys it when load() returns, before the bound function
body runs.

add_patient throws when there is no life support frame, so casting to a
view outside a bound function (e.g. a manual py::cast<std::string_view>)
now raises instead of relying on the caller-owned source, a regression
from #6092.

For these view-into-src cases registration is best effort: inside a bound
function it keeps src alive (fixing the UAF), and outside one the caller
owns src's lifetime as before. Add try_add_patient(), which returns false
instead of throwing when there is no frame, and use it at the three view
load sites. add_patient() keeps its strict contract for value-creating
conversions.

Assisted-by: ClaudeCode:claude-opus-4.8

* fix: only add string_view life support for transient sources

Refine the previous commit. Best-effort registration (try_add_patient)
silently produces a dangling view when a container of views is built from
a generator outside a bound function: there the materialized temporary is
released before the view is used, and with no frame nothing keeps it
alive. Such a cast cannot be made safe, so it should fail loudly, while a
view into a durable, caller-owned object needs no life support at all.

The view caster cannot tell a durable source from a pybind11-managed
transient one; that provenance lives in the container caster. Introduce an
ambient transient_source_guard that the list, set, map, and array casters
set around their generator/materialized paths, and have the string caster
keep the source alive only when loading from a transient source (via the
throwing add_patient, so try_add_patient is no longer needed). This means:

- views into durable sources (direct arguments, sequences, manual casts)
  add no life support and no longer throw outside a bound function, and
- a generator used outside a frame throws, rather than silently dangling.

The guard restores (rather than clears) the previous value, so a durable
container nested in a transient one is correctly treated as transient.

Verified with AddressSanitizer: the in-frame generator case is clean, the
out-of-frame durable cases succeed, and the out-of-frame generator case
throws.

Assisted-by: ClaudeCode:claude-opus-4.8

* Revert "fix: only add string_view life support for transient sources"

This reverts commit e18b8346a28c125964fcb9d192e6643d5ef3b420.

* test: cover string_view argument life support

* test: cover generated and nested string_view lifetimes

* test: cover temporary-backed string_view casts

* test: explain string_view lifetime regression tests

* docs: clarify string_view lifetime requirements

* docs: explain life support for custom view casters

---------

Co-authored-by: Ralf W. Grosse-Kunstleve <rgrossekunst@nvidia.com>
diff --git a/docs/advanced/cast/custom.rst b/docs/advanced/cast/custom.rst
index 786192b..de673b2 100644
--- a/docs/advanced/cast/custom.rst
+++ b/docs/advanced/cast/custom.rst
@@ -120,6 +120,24 @@
     For further information on the ``return_value_policy`` argument of ``cast`` refer to :ref:`return_value_policies`.
     To learn about the ``convert`` argument of ``load`` see :ref:`nonconverting_arguments`.
 
+.. note::
+
+    If a custom ``load()`` produces a non-owning view of storage owned by a
+    Python object, and keeping that object alive is sufficient to keep the
+    storage valid, register the owner (often ``src``) with
+    ``pybind11::detail::loader_life_support::try_add_patient(owner)``. During a
+    bound-function call, this keeps the owner alive until the call returns. With
+    no active call frame, it returns ``false`` and the caller remains
+    responsible for the lifetime.
+
+    If ``load()`` instead creates a temporary Python object to own the view's
+    storage, use the strict ``loader_life_support::add_patient(owner)``. Outside
+    a bound-function call, it raises :class:`cast_error` rather than allowing a
+    dangling view. Neither form permits C++ code to retain the view after the
+    call without separate lifetime management. These ``detail`` helpers are
+    intended only for type-caster implementations. See
+    :ref:`string_view_lifetime` for a concrete example.
+
 .. warning::
 
     When using custom type casters, it's important to declare them consistently
diff --git a/docs/advanced/cast/stl.rst b/docs/advanced/cast/stl.rst
index 1e17bc3..6abe8b4 100644
--- a/docs/advanced/cast/stl.rst
+++ b/docs/advanced/cast/stl.rst
@@ -17,6 +17,10 @@
 can have implications on the program semantics and performance. Please read the
 next sections for more details and alternative approaches that avoid this.
 
+Copying the container does not make non-owning element types own their data.
+In particular, containers of C++ string views have additional
+:ref:`string_view_lifetime` requirements.
+
 .. note::
 
     Arbitrary nesting of any of these types is possible.
diff --git a/docs/advanced/cast/strings.rst b/docs/advanced/cast/strings.rst
index 271716b..2b24d7c 100644
--- a/docs/advanced/cast/strings.rst
+++ b/docs/advanced/cast/strings.rst
@@ -280,6 +280,8 @@
 no way to capture them in a C++ character type.
 
 
+.. _string_view_lifetime:
+
 C++17 string views
 ==================
 
@@ -289,6 +291,30 @@
 UTF-16-encoded data, and a returned ``std::string_view`` will be decoded as
 UTF-8).
 
+A string view does not own its character data. When a view is loaded as an
+argument to a pybind11-bound function, pybind11 keeps the Python object that
+provides the data alive until the function returns. This also applies to views
+nested in automatically converted STL containers. The C++ function must not
+retain any such view after it returns unless it separately guarantees that the
+backing storage remains alive and valid.
+
+Lifetime support keeps the Python object alive, but does not prevent its storage
+from being invalidated. For example, if C++ releases the GIL or calls back into
+Python, resizing a backing ``bytearray`` while the view is in use can invalidate
+the view.
+
+A direct Python-to-C++ :func:`py::cast` made when no bound-function call is
+active has no such lifetime support. When a cast to a non-owning view succeeds,
+the caller must keep the backing Python object alive, with its storage
+unchanged, for as long as the view is used. For a container of views, this
+requirement applies to every element: retain the elements directly or through
+an unmodified owning container, and do not cast an iterable that creates
+temporary elements.
+
+Some view conversions require temporary backing storage, for example to encode
+text. Outside a bound-function call, such conversions raise
+:class:`cast_error` instead of returning a dangling view.
+
 References
 ==========
 
diff --git a/docs/advanced/pycpp/object.rst b/docs/advanced/pycpp/object.rst
index 93e1a94..d8661c2 100644
--- a/docs/advanced/pycpp/object.rst
+++ b/docs/advanced/pycpp/object.rst
@@ -76,6 +76,10 @@
 
 When conversion fails, both directions throw the exception :class:`cast_error`.
 
+When casting to a non-owning type such as ``std::string_view``, the Python
+source may need to remain alive after a successful cast. See
+:ref:`string_view_lifetime` for details.
+
 .. _python_libs:
 
 Accessing Python libraries from C++
diff --git a/include/pybind11/cast.h b/include/pybind11/cast.h
index 62ca45a..9ab6e33 100644
--- a/include/pybind11/cast.h
+++ b/include/pybind11/cast.h
@@ -525,6 +525,11 @@
                 return false;
             }
             value = StringType(buffer, static_cast<size_t>(size));
+            if (IsView) {
+                // `src` owns the buffer; keep it alive if inside a bound function,
+                // otherwise the caller is responsible for its lifetime.
+                loader_life_support::try_add_patient(src);
+            }
             return true;
         }
 
@@ -602,6 +607,9 @@
                 pybind11_fail("Unexpected PYBIND11_BYTES_AS_STRING() failure.");
             }
             value = StringType(bytes, (size_t) PYBIND11_BYTES_SIZE(src.ptr()));
+            if (IsView) {
+                loader_life_support::try_add_patient(src);
+            }
             return true;
         }
         if (PyByteArray_Check(src.ptr())) {
@@ -612,6 +620,9 @@
                 pybind11_fail("Unexpected PyByteArray_AsString() failure.");
             }
             value = StringType(bytearray, (size_t) PyByteArray_Size(src.ptr()));
+            if (IsView) {
+                loader_life_support::try_add_patient(src);
+            }
             return true;
         }
 
diff --git a/include/pybind11/detail/type_caster_base.h b/include/pybind11/detail/type_caster_base.h
index 8fbf700..b6d03ca 100644
--- a/include/pybind11/detail/type_caster_base.h
+++ b/include/pybind11/detail/type_caster_base.h
@@ -81,11 +81,26 @@
         }
     }
 
+    /// Keep `h` alive until the current patient frame is destroyed, if there is one.
+    /// Returns false when called outside a bound function (no frame). Use this, rather
+    /// than `add_patient`, when failing to register is acceptable because the caller
+    /// owns the source's lifetime outside the call framework (e.g. a view that points
+    /// into an existing Python object, as opposed to a freshly created temporary).
+    PYBIND11_NOINLINE static bool try_add_patient(handle h) {
+        loader_life_support *frame = tls_current_frame();
+        if (!frame) {
+            return false;
+        }
+        if (frame->keep_alive.insert(h.ptr()).second) {
+            Py_INCREF(h.ptr());
+        }
+        return true;
+    }
+
     /// This can only be used inside a pybind11-bound function, either by `argument_loader`
     /// at argument preparation time or by `py::cast()` at execution time.
     PYBIND11_NOINLINE static void add_patient(handle h) {
-        loader_life_support *frame = tls_current_frame();
-        if (!frame) {
+        if (!try_add_patient(h)) {
             // NOTE: It would be nice to include the stack frames here, as this indicates
             // use of pybind11::cast<> outside the normal call framework, finding such
             // a location is challenging. Developers could consider printing out
@@ -94,10 +109,6 @@
                              "do Python -> C++ conversions which require the creation "
                              "of temporary values");
         }
-
-        if (frame->keep_alive.insert(h.ptr()).second) {
-            Py_INCREF(h.ptr());
-        }
     }
 };
 
diff --git a/tests/test_stl.cpp b/tests/test_stl.cpp
index 8bddbb1..b5d1979 100644
--- a/tests/test_stl.cpp
+++ b/tests/test_stl.cpp
@@ -582,6 +582,24 @@
           [](const std::list<std::string> &) { return 2; });
     m.def("func_with_string_or_vector_string_arg_overload", [](const std::string &) { return 3; });
 
+#ifdef PYBIND11_HAS_STRING_VIEW
+    m.def("func_with_string_views", [](const std::vector<std::string_view> &svs) {
+        py::list l;
+        for (std::string_view sv : svs) {
+            l.append(sv);
+        }
+        return l;
+    });
+    m.def("string_view_life_support_check",
+          [](const std::vector<std::string_view> &, int, const py::list &destroyed) {
+              return destroyed.size();
+          });
+    m.def("nested_string_view_life_support_check",
+          [](const std::vector<std::vector<std::string_view>> &, int, const py::list &destroyed) {
+              return destroyed.size();
+          });
+#endif
+
     class Placeholder {
     public:
         Placeholder() { print_created(this); }
diff --git a/tests/test_stl.py b/tests/test_stl.py
index b04f55c..c75ccb8 100644
--- a/tests/test_stl.py
+++ b/tests/test_stl.py
@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import weakref
+
 import pytest
 
 import env  # noqa: F401
@@ -28,6 +30,132 @@
     # Test regression caused by 936: pointers to stl containers weren't castable
     assert m.cast_ptr_vector() == ["lvalue", "lvalue"]
 
+    if hasattr(m, "func_with_string_views"):
+
+        def gen():
+            return ("a" + str(x) for x in range(10000, 10010))
+
+        expected = list(gen())
+        assert m.func_with_string_views(gen()) == expected
+        assert m.func_with_string_views(x.encode() for x in gen()) == expected
+        assert (
+            m.func_with_string_views(bytearray(x.encode()) for x in gen()) == expected
+        )
+
+
+@pytest.mark.skipif(
+    not hasattr(m, "string_view_life_support_check"), reason="no <string_view>"
+)
+@pytest.mark.skipif("env.GRAALPY", reason="Cannot reliably trigger GC")
+def test_string_view_life_support_during_argument_conversion():
+    # Design background: PR #6096, "Why these lifetime tests are deliberately
+    # implementation-aware".
+    # This test uses conversion of a later argument as a checkpoint between
+    # loading the string views and entering the C++ function. Argument casters
+    # run left-to-right: after the first argument creates the views, the second
+    # argument's __index__ clears the list that owned their Python strings and
+    # forces GC. The C++ probe checks only whether those strings were destroyed,
+    # never the potentially dangling views. Zero during the call proves that
+    # loader life support worked; dead weakrefs afterward prove that it did not
+    # keep the strings alive too long.
+    destroyed = []
+
+    class TrackedString(str):
+        pass
+
+    source = [TrackedString("first"), TrackedString("second")]
+    weakrefs = [weakref.ref(item, lambda _: destroyed.append(None)) for item in source]
+
+    # Clear the only Python owners after the views have loaded, but before the
+    # bound function is called.
+    class ClearSourceOnIndex:
+        def __index__(self):
+            source.clear()
+            pytest.gc_collect()
+            return 0
+
+    assert (
+        m.string_view_life_support_check(source, ClearSourceOnIndex(), destroyed) == 0
+    )
+    assert source == []
+    pytest.gc_collect()
+    assert len(destroyed) == 2
+    assert all(ref() is None for ref in weakrefs)
+
+
+@pytest.mark.skipif(
+    not hasattr(m, "string_view_life_support_check"), reason="no <string_view>"
+)
+@pytest.mark.skipif("env.GRAALPY", reason="Cannot reliably trigger GC")
+@pytest.mark.parametrize(
+    ("element_type", "values"),
+    [
+        pytest.param(str, ("first", "second"), id="str"),
+        pytest.param(bytes, (b"first", b"second"), id="bytes"),
+        pytest.param(bytearray, (b"first", b"second"), id="bytearray"),
+    ],
+)
+def test_string_view_life_support_for_generator(element_type, values):
+    destroyed = []
+
+    class Tracked(element_type):
+        def __del__(self):
+            destroyed.append(None)
+
+    def source():
+        for value in values:
+            yield Tracked(value)
+
+    class CollectGarbageOnIndex:
+        def __index__(self):
+            pytest.gc_collect()
+            return 0
+
+    assert (
+        m.string_view_life_support_check(source(), CollectGarbageOnIndex(), destroyed)
+        == 0
+    )
+    pytest.gc_collect()
+    assert len(destroyed) == len(values)
+
+
+@pytest.mark.skipif(
+    not hasattr(m, "nested_string_view_life_support_check"),
+    reason="no <string_view>",
+)
+@pytest.mark.skipif("env.GRAALPY", reason="Cannot reliably trigger GC")
+def test_string_view_life_support_for_nested_containers():
+    # Design background: PR #6096, "Why these lifetime tests are deliberately
+    # implementation-aware". This uses the later-argument checkpoint described
+    # in test_string_view_life_support_during_argument_conversion. Here,
+    # clearing the outer list also releases the inner lists, verifying that life
+    # support reaches every Python string backing a view in the recursively
+    # converted std::vector<std::vector<std::string_view>>.
+    destroyed = []
+
+    class TrackedString(str):
+        def __del__(self):
+            destroyed.append(None)
+
+    source = [
+        [TrackedString("first"), TrackedString("second")],
+        [TrackedString("third"), TrackedString("fourth")],
+    ]
+
+    class ClearSourceOnIndex:
+        def __index__(self):
+            source.clear()
+            pytest.gc_collect()
+            return 0
+
+    assert (
+        m.nested_string_view_life_support_check(source, ClearSourceOnIndex(), destroyed)
+        == 0
+    )
+    assert source == []
+    pytest.gc_collect()
+    assert len(destroyed) == 4
+
 
 def test_deque():
     """std::deque <-> list"""
diff --git a/tests/test_with_catch/test_interpreter.cpp b/tests/test_with_catch/test_interpreter.cpp
index 4103c0f..daa1041 100644
--- a/tests/test_with_catch/test_interpreter.cpp
+++ b/tests/test_with_catch/test_interpreter.cpp
@@ -509,3 +509,32 @@
 
     py::initialize_interpreter();
 }
+
+#ifdef PYBIND11_HAS_STRING_VIEW
+TEST_CASE("Casting to a string_view outside a bound function") {
+    // Regression for PR #6092: view casters add the source to loader_life_support, but
+    // outside a bound function there is no frame. The caller owns the source's lifetime
+    // here, so the cast must succeed rather than throw.
+    py::str unicode("hello");
+    py::bytes bytes_obj("world", 5);
+    auto bytearray_obj
+        = py::reinterpret_steal<py::object>(PyByteArray_FromStringAndSize("bytes", 5));
+
+    REQUIRE(py::cast<std::string_view>(unicode) == "hello");
+    REQUIRE(py::cast<std::string_view>(bytes_obj) == "world");
+    REQUIRE(py::cast<std::string_view>(bytearray_obj) == "bytes");
+
+    // Wide string views require an encoded temporary. With no loader life-support
+    // frame, returning a view into that temporary must fail.
+    REQUIRE_THROWS_AS(py::cast<std::u16string_view>(unicode), py::cast_error);
+    REQUIRE_THROWS_AS(py::cast<std::u32string_view>(unicode), py::cast_error);
+
+    // Bound-function dispatch provides a frame that keeps both temporaries alive.
+    auto accepts_wide_views
+        = py::cpp_function([](std::u16string_view value16, std::u32string_view value32) {
+              return value16 == std::u16string_view(u"hello")
+                     && value32 == std::u32string_view(U"hello");
+          });
+    REQUIRE(accepts_wide_views(unicode, unicode).cast<bool>());
+}
+#endif