Merge tag 'v3.0.4' into stable

v3.0.4 release
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 03cb023..2149685 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -188,7 +188,7 @@
         allow-prereleases: true
 
     - name: Install uv
-      uses: astral-sh/setup-uv@v7
+      uses: astral-sh/setup-uv@v8.0.0
       with:
         enable-cache: true
 
@@ -302,7 +302,7 @@
         debug: ${{ matrix.python-debug }}
 
     - name: Update CMake
-      uses: jwlawson/actions-setup-cmake@v2.1
+      uses: jwlawson/actions-setup-cmake@v2.2
 
     - name: Valgrind cache
       if: matrix.valgrind
@@ -570,7 +570,7 @@
       run: python3 -m pip install --upgrade pip
 
     - name: Update CMake
-      uses: jwlawson/actions-setup-cmake@v2.1
+      uses: jwlawson/actions-setup-cmake@v2.2
 
     - name: Configure
       shell: bash
@@ -906,7 +906,7 @@
           ${{ matrix.python == '3.13' && runner.os == 'Windows' }}
 
     - name: Update CMake
-      uses: jwlawson/actions-setup-cmake@v2.1
+      uses: jwlawson/actions-setup-cmake@v2.2
 
     - name: Prepare MSVC
       uses: ilammy/msvc-dev-cmd@v1.13.0
@@ -956,7 +956,7 @@
         architecture: x86
 
     - name: Update CMake
-      uses: jwlawson/actions-setup-cmake@v2.1
+      uses: jwlawson/actions-setup-cmake@v2.2
 
     - name: Prepare MSVC
       uses: ilammy/msvc-dev-cmd@v1.13.0
@@ -1007,7 +1007,7 @@
       run: python3 -m pip install -r tests/requirements.txt
 
     - name: Update CMake
-      uses: jwlawson/actions-setup-cmake@v2.1
+      uses: jwlawson/actions-setup-cmake@v2.2
 
     - name: Configure C++20
       run: >
@@ -1189,7 +1189,7 @@
           python-version: ${{ matrix.python }}
 
       - name: Update CMake
-        uses: jwlawson/actions-setup-cmake@v2.1
+        uses: jwlawson/actions-setup-cmake@v2.2
 
       - name: Install ninja-build tool
         uses: seanmiddleditch/gha-setup-ninja@v6
diff --git a/.github/workflows/configure.yml b/.github/workflows/configure.yml
index 931c0bf..e849421 100644
--- a/.github/workflows/configure.yml
+++ b/.github/workflows/configure.yml
@@ -56,7 +56,7 @@
         python-version: 3.11
 
     - name: Install uv
-      uses: astral-sh/setup-uv@v7
+      uses: astral-sh/setup-uv@v8.0.0
 
     - name: Prepare env
       run: uv pip install --python=python --system -r tests/requirements.txt
@@ -64,7 +64,7 @@
     # An action for adding a specific version of CMake:
     #   https://github.com/jwlawson/actions-setup-cmake
     - name: Setup CMake ${{ matrix.cmake }}
-      uses: jwlawson/actions-setup-cmake@v2.1
+      uses: jwlawson/actions-setup-cmake@v2.2
       with:
         cmake-version: ${{ matrix.cmake }}
 
diff --git a/.github/workflows/nightlies.yml b/.github/workflows/nightlies.yml
index 98211ca..d7ab9b0 100644
--- a/.github/workflows/nightlies.yml
+++ b/.github/workflows/nightlies.yml
@@ -25,7 +25,7 @@
           fetch-depth: 0
 
       - name: Install uv
-        uses: astral-sh/setup-uv@v7
+        uses: astral-sh/setup-uv@v8.0.0
 
       - name: Build SDist and wheels
         run: |
diff --git a/.github/workflows/pip.yml b/.github/workflows/pip.yml
index 981884a..c52857c 100644
--- a/.github/workflows/pip.yml
+++ b/.github/workflows/pip.yml
@@ -31,7 +31,7 @@
         python-version: 3.8
 
     - name: Install uv
-      uses: astral-sh/setup-uv@v7
+      uses: astral-sh/setup-uv@v8.0.0
 
     - name: Prepare env
       run: uv pip install --system -r tests/requirements.txt
@@ -55,7 +55,7 @@
         python-version: 3.8
 
     - name: Install uv
-      uses: astral-sh/setup-uv@v7
+      uses: astral-sh/setup-uv@v8.0.0
 
     - name: Prepare env
       run: uv pip install --system -r tests/requirements.txt twine nox
diff --git a/.github/workflows/reusable-standard.yml b/.github/workflows/reusable-standard.yml
index 6e22d0f..e53c275 100644
--- a/.github/workflows/reusable-standard.yml
+++ b/.github/workflows/reusable-standard.yml
@@ -51,7 +51,7 @@
         run: brew install boost
 
       - name: Install uv
-        uses: astral-sh/setup-uv@v7
+        uses: astral-sh/setup-uv@v8.0.0
         with:
           enable-cache: true
 
diff --git a/.github/workflows/tests-cibw.yml b/.github/workflows/tests-cibw.yml
index bf53431..f7bca76 100644
--- a/.github/workflows/tests-cibw.yml
+++ b/.github/workflows/tests-cibw.yml
@@ -22,7 +22,7 @@
         submodules: true
         fetch-depth: 0
 
-    - uses: pypa/cibuildwheel@v3.3
+    - uses: pypa/cibuildwheel@v3.4
       env:
         PYODIDE_BUILD_EXPORTS: whole_archive
       with:
@@ -45,7 +45,7 @@
     # We have to uninstall first because GH is now using a local tap to build cmake<4, iOS needs cmake>=4
     - run: brew uninstall cmake && brew install cmake
 
-    - uses: pypa/cibuildwheel@v3.3
+    - uses: pypa/cibuildwheel@v3.4
       env:
         CIBW_PLATFORM: ios
         CIBW_SKIP: cp314-*  # https://github.com/pypa/cibuildwheel/issues/2494
@@ -70,7 +70,7 @@
       if: contains(matrix.runs-on, 'macos')
       run: echo "CIBW_TEST_COMMAND=" >> "$GITHUB_ENV"
 
-    - uses: pypa/cibuildwheel@v3.3
+    - uses: pypa/cibuildwheel@v3.4
       env:
         CIBW_PLATFORM: android
       with:
diff --git a/.github/workflows/upstream.yml b/.github/workflows/upstream.yml
index 051cffc..51354d6 100644
--- a/.github/workflows/upstream.yml
+++ b/.github/workflows/upstream.yml
@@ -36,7 +36,7 @@
       run: sudo apt-get install libboost-dev
 
     - name: Update CMake
-      uses: jwlawson/actions-setup-cmake@v2.1
+      uses: jwlawson/actions-setup-cmake@v2.2
 
     - name: Run pip installs
       run: |
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5ee5846..637dc3f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -25,14 +25,14 @@
 
 # Clang format the codebase automatically
 - repo: https://github.com/pre-commit/mirrors-clang-format
-  rev: "v22.1.0"
+  rev: "v22.1.2"
   hooks:
   - id: clang-format
     types_or: [c++, c, cuda]
 
 # Ruff, the Python auto-correcting linter/formatter written in Rust
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.15.4
+  rev: v0.15.9
   hooks:
   - id: ruff-check
     args: ["--fix", "--show-fixes"]
@@ -40,7 +40,7 @@
 
 # Check static types with mypy
 - repo: https://github.com/pre-commit/mirrors-mypy
-  rev: "v1.19.1"
+  rev: "v1.20.0"
   hooks:
   - id: mypy
     args: []
@@ -112,7 +112,7 @@
 # Use tools/codespell_ignore_lines_from_errors.py
 # to rebuild .codespell-ignore-lines
 - repo: https://github.com/codespell-project/codespell
-  rev: "v2.4.1"
+  rev: "v2.4.2"
   hooks:
   - id: codespell
     exclude: "(.supp|^pyproject.toml)$"
@@ -122,7 +122,7 @@
 # Use mirror because pre-commit autoupdate confuses tags in the upstream repo.
 # See https://github.com/crate-ci/typos/issues/390
 - repo: https://github.com/adhtruong/mirrors-typos
-  rev: "v1.44.0"
+  rev: "v1.45.0"
   hooks:
   - id: typos
     args: []
@@ -151,7 +151,7 @@
 
 # Check schemas on some of our YAML files
 - repo: https://github.com/python-jsonschema/check-jsonschema
-  rev: 0.37.0
+  rev: 0.37.1
   hooks:
   - id: check-readthedocs
   - id: check-github-workflows
diff --git a/docs/changelog.md b/docs/changelog.md
index f993034..ecff970 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -13,6 +13,34 @@
 entry" block in pull request descriptions.
 
 
+## Version 3.0.4 (April 18, 2026)
+
+Bug fixes:
+
+- Fixed test builds with installed Eigen 5 by improving `Eigen3` CMake package detection.
+  [#6036](https://github.com/pybind/pybind11/pull/6036)
+
+- Fixed move semantics of `scoped_ostream_redirect` to preserve buffered output and avoid crashes when moved redirects restore stream buffers.
+  [#6033](https://github.com/pybind/pybind11/pull/6033)
+
+- Fixed `py::dynamic_attr()` traversal on Python 3.13+ to correctly propagate `PyObject_VisitManagedDict()` results.
+  [#6032](https://github.com/pybind/pybind11/pull/6032)
+
+- Fixed `std::shared_ptr<T>` fallback casting to avoid unnecessary copy-constructor instantiation in `reference_internal` paths.
+  [#6028](https://github.com/pybind/pybind11/pull/6028)
+
+CI:
+
+- Updated `setup-uv` to the maintained GitHub Action tag scheme.
+  [#6035](https://github.com/pybind/pybind11/pull/6035)
+
+- Updated pre-commit hooks.
+  [#6029](https://github.com/pybind/pybind11/pull/6029)
+
+- Updated GitHub Actions dependencies, including `actions-setup-cmake` and `cibuildwheel`.
+  [#6027](https://github.com/pybind/pybind11/pull/6027)
+
+
 ## Version 3.0.3 (March 31, 2026)
 
 Bug fixes:
diff --git a/include/pybind11/cast.h b/include/pybind11/cast.h
index f07abfe..9ebabce 100644
--- a/include/pybind11/cast.h
+++ b/include/pybind11/cast.h
@@ -1026,7 +1026,7 @@
         }
 
         if (parent) {
-            return type_caster_base<type>::cast(
+            return type_caster_generic::cast_non_owning(
                 srcs, return_value_policy::reference_internal, parent);
         }
 
diff --git a/include/pybind11/detail/class.h b/include/pybind11/detail/class.h
index 4b7422e..8b9d0b8 100644
--- a/include/pybind11/detail/class.h
+++ b/include/pybind11/detail/class.h
@@ -578,7 +578,10 @@
 /// dynamic_attr: Allow the garbage collector to traverse the internal instance `__dict__`.
 extern "C" inline int pybind11_traverse(PyObject *self, visitproc visit, void *arg) {
 #if PY_VERSION_HEX >= 0x030D0000
-    PyObject_VisitManagedDict(self, visit, arg);
+    int ret = PyObject_VisitManagedDict(self, visit, arg);
+    if (ret) {
+        return ret;
+    }
 #else
     PyObject *&dict = *_PyObject_GetDictPtr(self);
     Py_VISIT(dict);
diff --git a/include/pybind11/detail/common.h b/include/pybind11/detail/common.h
index 7158923..38cb01d 100644
--- a/include/pybind11/detail/common.h
+++ b/include/pybind11/detail/common.h
@@ -19,7 +19,7 @@
 /* -- start version constants -- */
 #define PYBIND11_VERSION_MAJOR 3
 #define PYBIND11_VERSION_MINOR 0
-#define PYBIND11_VERSION_MICRO 3
+#define PYBIND11_VERSION_MICRO 4
 // ALPHA = 0xA, BETA = 0xB, GAMMA = 0xC (release candidate), FINAL = 0xF (stable release)
 // - The release level is set to "alpha" for development versions.
 //   Use 0xA0 (LEVEL=0xA, SERIAL=0) for development versions.
@@ -27,7 +27,7 @@
 #define PYBIND11_VERSION_RELEASE_LEVEL PY_RELEASE_LEVEL_FINAL
 #define PYBIND11_VERSION_RELEASE_SERIAL 0
 // String version of (micro, release level, release serial), e.g.: 0a0, 0b1, 0rc1, 0
-#define PYBIND11_VERSION_PATCH 3
+#define PYBIND11_VERSION_PATCH 4
 /* -- end version constants -- */
 
 #if !defined(Py_PACK_FULL_VERSION)
diff --git a/include/pybind11/detail/type_caster_base.h b/include/pybind11/detail/type_caster_base.h
index b0c59e1..8fbf700 100644
--- a/include/pybind11/detail/type_caster_base.h
+++ b/include/pybind11/detail/type_caster_base.h
@@ -1004,6 +1004,18 @@
         return cast(srcs, policy, parent, copy_constructor, move_constructor, existing_holder);
     }
 
+    static handle cast_non_owning(const cast_sources &srcs,
+                                  return_value_policy policy,
+                                  handle parent,
+                                  const void *existing_holder = nullptr) {
+        // Reference-like policies alias an existing C++ object instead of creating
+        // a new one, so copy/move constructor callbacks must remain null here.
+        assert(policy == return_value_policy::reference
+               || policy == return_value_policy::reference_internal
+               || policy == return_value_policy::automatic_reference);
+        return cast(srcs, policy, parent, nullptr, nullptr, existing_holder);
+    }
+
     PYBIND11_NOINLINE static handle cast(const cast_sources &srcs,
                                          return_value_policy policy,
                                          handle parent,
diff --git a/include/pybind11/iostream.h b/include/pybind11/iostream.h
index 44261e8..df7fa3c 100644
--- a/include/pybind11/iostream.h
+++ b/include/pybind11/iostream.h
@@ -131,7 +131,22 @@
         setp(d_buffer.get(), d_buffer.get() + buf_size - 1);
     }
 
-    pythonbuf(pythonbuf &&) = default;
+    pythonbuf(pythonbuf &&other) noexcept
+        : buf_size(other.buf_size), d_buffer(std::move(other.d_buffer)),
+          pywrite(std::move(other.pywrite)), pyflush(std::move(other.pyflush)) {
+        const auto pending = (other.pbase() != nullptr && other.pptr() != nullptr)
+                                 ? static_cast<int>(other.pptr() - other.pbase())
+                                 : 0;
+        if (d_buffer != nullptr) {
+            // Rebuild the put area from the transferred storage.
+            setp(d_buffer.get(), d_buffer.get() + buf_size - 1);
+            pbump(pending);
+        } else {
+            setp(nullptr, nullptr);
+        }
+        // Prevent the moved-from destructor from flushing through moved-out handles.
+        other.setp(nullptr, nullptr);
+    }
 
     /// Sync before destroy
     ~pythonbuf() override { _sync(); }
@@ -169,6 +184,7 @@
     std::streambuf *old;
     std::ostream &costream;
     detail::pythonbuf buffer;
+    bool active = true;
 
 public:
     explicit scoped_ostream_redirect(std::ostream &costream = std::cout,
@@ -178,10 +194,22 @@
         old = costream.rdbuf(&buffer);
     }
 
-    ~scoped_ostream_redirect() { costream.rdbuf(old); }
+    ~scoped_ostream_redirect() {
+        if (active) {
+            costream.rdbuf(old);
+        }
+    }
 
     scoped_ostream_redirect(const scoped_ostream_redirect &) = delete;
-    scoped_ostream_redirect(scoped_ostream_redirect &&other) = default;
+    // NOLINTNEXTLINE(performance-noexcept-move-constructor)
+    scoped_ostream_redirect(scoped_ostream_redirect &&other)
+        : old(other.old), costream(other.costream), buffer(std::move(other.buffer)),
+          active(other.active) {
+        if (active) {
+            costream.rdbuf(&buffer); // Re-point stream to our buffer
+            other.active = false;
+        }
+    }
     scoped_ostream_redirect &operator=(const scoped_ostream_redirect &) = delete;
     scoped_ostream_redirect &operator=(scoped_ostream_redirect &&) = delete;
 };
diff --git a/pyproject.toml b/pyproject.toml
index 6a30098..03214c6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -202,6 +202,10 @@
 quater = "quater"
 optin = "optin"
 othr = "othr"
+# NumPy uses "writeable" in public API names and flags.
+writeable = "writeable"
+Writeable = "Writeable"
+WRITEABLE = "WRITEABLE"
 
 #[tool.typos.type.cpp.extend-words]
 setp = "setp"
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index e87b1e9..fc08a90 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -167,6 +167,7 @@
     test_operator_overloading
     test_pickling
     test_potentially_slicing_weak_ptr
+    test_pytorch_shared_ptr_cast_regression
     test_python_multiple_inheritance
     test_pytypes
     test_scoped_critical_section
@@ -299,10 +300,17 @@
 
   else()
     find_package(Eigen3 3.2.7 QUIET CONFIG)
+    if(NOT Eigen3_FOUND)
+      find_package(Eigen3 5 QUIET CONFIG)
+    endif()
+    set(EIGEN3_FOUND ${Eigen3_FOUND})
+    set(EIGEN3_VERSION ${Eigen3_VERSION})
 
     if(NOT EIGEN3_FOUND)
       # Couldn't load via target, so fall back to allowing module mode finding, which will pick up
       # tools/FindEigen3.cmake
+      # This MODULE-mode fallback is for older Eigen 3 setups; Eigen 5 is expected to be found
+      # via the CONFIG-mode probes above.
       find_package(Eigen3 3.2.7 QUIET)
     endif()
   endif()
diff --git a/tests/test_class.cpp b/tests/test_class.cpp
index 2030cd6..84efb80 100644
--- a/tests/test_class.cpp
+++ b/tests/test_class.cpp
@@ -104,6 +104,10 @@
         ~NoConstructorNew() { print_destroyed(this); }
     };
 
+    struct DynamicAttr {
+        DynamicAttr() = default;
+    };
+
     py::class_<NoConstructor>(m, "NoConstructor")
         .def_static("new_instance", &NoConstructor::new_instance, "Return an instance");
 
@@ -112,6 +116,8 @@
         .def_static("__new__",
                     [](const py::object &) { return NoConstructorNew::new_instance(); });
 
+    py::class_<DynamicAttr>(m, "DynamicAttr", py::dynamic_attr()).def(py::init<>());
+
     // test_pass_unique_ptr
     struct ToBeHeldByUniquePtr {};
     py::class_<ToBeHeldByUniquePtr, std::unique_ptr<ToBeHeldByUniquePtr>>(m, "ToBeHeldByUniquePtr")
diff --git a/tests/test_class.py b/tests/test_class.py
index fae6a31..201c7e3 100644
--- a/tests/test_class.py
+++ b/tests/test_class.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import gc
 import sys
 from unittest import mock
 
@@ -18,6 +19,13 @@
     return sys.getrefcount(ob)
 
 
+MANAGED_DICT_GET_REFERRERS_SUPPORTED = (
+    env.CPYTHON
+    and sys.version_info >= (3, 13, 13)
+    and (sys.version_info < (3, 14) or sys.version_info >= (3, 14, 4))
+)
+
+
 def test_obj_class_name():
     expected_name = "UserType" if env.PYPY else "pybind11_tests.UserType"
     assert m.obj_class_name(UserType(1)) == expected_name
@@ -45,6 +53,16 @@
     assert cstats.alive() == 0
 
 
+@pytest.mark.skipif(
+    not MANAGED_DICT_GET_REFERRERS_SUPPORTED,
+    reason="Requires CPython 3.13.13+ or 3.14.4+ managed dict traversal support",
+)
+def test_get_referrers():
+    instance = m.DynamicAttr()
+    instance.a = "test"
+    assert instance in gc.get_referrers(instance.__dict__)
+
+
 def test_instance_new():
     instance = m.NoConstructorNew()  # .__new__(m.NoConstructor.__class__)
 
diff --git a/tests/test_class_sh_property.py b/tests/test_class_sh_property.py
index 4a7b77c..b8a5933 100644
--- a/tests/test_class_sh_property.py
+++ b/tests/test_class_sh_property.py
@@ -204,3 +204,15 @@
     legacy = obj.legacy
     legacy.value = 13
     assert obj.legacy.value == 13
+
+
+def test_non_smart_holder_member_type_with_smart_holder_owner_aliases_member_multiple_reads():
+    obj = m.ShWithSimpleStructMember()
+
+    a = obj.legacy
+    b = obj.legacy
+
+    a.value = 13
+
+    assert b.value == 13
+    assert obj.legacy.value == 13
diff --git a/tests/test_iostream.cpp b/tests/test_iostream.cpp
index 421eaa2..7484e73 100644
--- a/tests/test_iostream.cpp
+++ b/tests/test_iostream.cpp
@@ -123,4 +123,40 @@
         .def("stop", &TestThread::stop)
         .def("join", &TestThread::join)
         .def("sleep", &TestThread::sleep);
+
+    m.def("move_redirect_output", [](const std::string &msg_before, const std::string &msg_after) {
+        py::scoped_ostream_redirect redir1(std::cout, py::module_::import("sys").attr("stdout"));
+        std::cout << msg_before << std::flush;
+        py::scoped_ostream_redirect redir2(std::move(redir1));
+        std::cout << msg_after << std::flush;
+    });
+
+    m.def("move_redirect_output_unflushed",
+          [](const std::string &msg_before, const std::string &msg_after) {
+              py::scoped_ostream_redirect redir1(std::cout,
+                                                 py::module_::import("sys").attr("stdout"));
+              std::cout << msg_before;
+              py::scoped_ostream_redirect redir2(std::move(redir1));
+              std::cout << msg_after << std::flush;
+          });
+
+    // Redirect a stream whose original rdbuf is nullptr, then move the redirect.
+    // Verifies that nullptr is correctly restored (not confused with a moved-from sentinel).
+    m.def("move_redirect_null_rdbuf", [](const std::string &msg) {
+        std::ostream os(nullptr);
+        py::scoped_ostream_redirect redir1(os, py::module_::import("sys").attr("stdout"));
+        os << msg << std::flush;
+        py::scoped_ostream_redirect redir2(std::move(redir1));
+        os << msg << std::flush;
+        // After redir2 goes out of scope, os.rdbuf() should be restored to nullptr.
+    });
+
+    m.def("get_null_rdbuf_restored", [](const std::string &msg) -> bool {
+        std::ostream os(nullptr);
+        {
+            py::scoped_ostream_redirect redir(os, py::module_::import("sys").attr("stdout"));
+            os << msg << std::flush;
+        }
+        return os.rdbuf() == nullptr;
+    });
 }
diff --git a/tests/test_iostream.py b/tests/test_iostream.py
index 791b9e0..857e0b5 100644
--- a/tests/test_iostream.py
+++ b/tests/test_iostream.py
@@ -284,6 +284,31 @@
     assert stream2.getvalue() == msg2
 
 
+def test_move_redirect(capsys):
+    m.move_redirect_output("before_move", "after_move")
+    stdout, stderr = capsys.readouterr()
+    assert stdout == "before_moveafter_move"
+    assert not stderr
+
+
+def test_move_redirect_unflushed(capsys):
+    m.move_redirect_output_unflushed("before_move", "after_move")
+    stdout, stderr = capsys.readouterr()
+    assert stdout == "before_moveafter_move"
+    assert not stderr
+
+
+def test_move_redirect_null_rdbuf(capsys):
+    m.move_redirect_null_rdbuf("hello")
+    stdout, stderr = capsys.readouterr()
+    assert stdout == "hellohello"
+    assert not stderr
+
+
+def test_null_rdbuf_restored():
+    assert m.get_null_rdbuf_restored("test")
+
+
 @pytest.mark.skipif(sys.platform.startswith("emscripten"), reason="Requires threads")
 def test_threading():
     with m.ostream_redirect(stdout=True, stderr=False):
diff --git a/tests/test_pytorch_shared_ptr_cast_regression.cpp b/tests/test_pytorch_shared_ptr_cast_regression.cpp
new file mode 100644
index 0000000..1425962
--- /dev/null
+++ b/tests/test_pytorch_shared_ptr_cast_regression.cpp
@@ -0,0 +1,62 @@
+#include "pybind11_tests.h"
+
+#include <memory>
+#include <string>
+
+#if defined(__clang__)
+#    if __has_warning("-Wdeprecated-copy-with-user-provided-dtor")
+#        pragma clang diagnostic error "-Wdeprecated-copy-with-user-provided-dtor"
+#    endif
+#    if __has_warning("-Wdeprecated-copy-with-dtor")
+#        pragma clang diagnostic error "-Wdeprecated-copy-with-dtor"
+#    endif
+#endif
+
+namespace test_pytorch_regressions {
+
+// Directly extracted from PyTorch patterns that regressed in CI.
+struct TracingState : std::enable_shared_from_this<TracingState> {
+    TracingState() = default;
+    ~TracingState() = default;
+    int value = 0;
+};
+
+const std::shared_ptr<TracingState> &get_tracing_state() {
+    static std::shared_ptr<TracingState> state = std::make_shared<TracingState>();
+    return state;
+}
+
+struct InterfaceType {
+    ~InterfaceType() = default;
+    int value = 0;
+};
+using InterfaceTypePtr = std::shared_ptr<InterfaceType>;
+
+struct CompilationUnit {
+    InterfaceTypePtr iface = std::make_shared<InterfaceType>();
+
+    InterfaceTypePtr get_interface(const std::string &) const { return iface; }
+};
+
+} // namespace test_pytorch_regressions
+
+TEST_SUBMODULE(pybind11_pytorch_regressions, m) {
+    using namespace test_pytorch_regressions;
+
+    py::class_<TracingState, std::shared_ptr<TracingState>>(m, "TracingState")
+        .def(py::init<>())
+        .def_readwrite("value", &TracingState::value);
+
+    m.def("_get_tracing_state", []() { return get_tracing_state(); });
+
+    py::class_<InterfaceType, InterfaceTypePtr>(m, "InterfaceType")
+        .def(py::init<>())
+        .def_readwrite("value", &InterfaceType::value);
+
+    py::class_<CompilationUnit, std::shared_ptr<CompilationUnit>>(m, "CompilationUnit")
+        .def(py::init<>())
+        .def("get_interface",
+             [](const std::shared_ptr<CompilationUnit> &self, const std::string &name) {
+                 return self->get_interface(name);
+             });
+}
diff --git a/tests/test_pytorch_shared_ptr_cast_regression.py b/tests/test_pytorch_shared_ptr_cast_regression.py
new file mode 100644
index 0000000..b7c393b
--- /dev/null
+++ b/tests/test_pytorch_shared_ptr_cast_regression.py
@@ -0,0 +1,25 @@
+from __future__ import annotations
+
+from pybind11_tests import pybind11_pytorch_regressions as m
+
+
+def test_pytorch_like_get_tracing_state_aliases_singleton_shared_ptr():
+    a = m._get_tracing_state()
+    b = m._get_tracing_state()
+
+    a.value = 17
+
+    assert b.value == 17
+    assert m._get_tracing_state().value == 17
+
+
+def test_pytorch_like_compilation_unit_get_interface_aliases_member_shared_ptr():
+    cu = m.CompilationUnit()
+
+    a = cu.get_interface("iface")
+    b = cu.get_interface("iface")
+
+    a.value = 23
+
+    assert b.value == 23
+    assert cu.get_interface("iface").value == 23