From 93457e597b75e0a61ea41541f43e4938cd089ea5 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 7 Oct 2026 12:24:22 -0400 Subject: [PATCH 1/5] ci: test PyPy 8.0.0 (3.11 and 3.12) Fix two problems found with PyPy 3.12: - Deallocating a py::dynamic_attr() instance aborted, because PyPy 3.12's _PyObject_GetDictPtr() cannot be called from tp_dealloc. Read the dict slot from tp_dictoffset on PyPy instead. - memoryview::from_buffer() passed temporary shape/strides arrays, which PyPy keeps without copying. Use PyPy's inline _shape/_strides arrays. Assisted-by: ClaudeCode:claude-opus-5-5 --- .github/workflows/ci.yml | 10 ++++++++-- include/pybind11/detail/class-inl.h | 17 ++++++++++++++--- include/pybind11/detail/class.h | 3 +++ include/pybind11/pytypes-inl.h | 13 +++++++++++++ tests/test_multiple_inheritance.py | 6 +++++- 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b300bd13c..c9a508333e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,8 +48,11 @@ jobs: python-version: '3.14t' cmake-args: -DCMAKE_CXX_STANDARD=17 -DPYBIND11_TEST_SMART_HOLDER=ON - runs-on: ubuntu-latest - python-version: 'pypy-3.11-v7.3.23' + python-version: 'pypy-3.11-v8.0.0' cmake-args: -DCMAKE_CXX_STANDARD=17 + - runs-on: ubuntu-latest + python-version: 'pypy-3.12-v8.0.0' + cmake-args: -DCMAKE_CXX_STANDARD=20 - runs-on: ubuntu-latest python-version: 'graalpy-25.0' cmake-args: -DCMAKE_CXX_STANDARD=20 @@ -138,7 +141,7 @@ jobs: python-version: '3.15' cmake-args: -DCMAKE_CXX_STANDARD=17 - runs-on: macos-latest - python-version: 'pypy-3.11-v7.3.23' + python-version: 'pypy-3.12-v8.0.0' - runs-on: macos-latest python-version: 'graalpy-25.0' - runs-on: macos-latest @@ -172,6 +175,9 @@ jobs: - runs-on: windows-latest python-version: 'pypy3.11' cmake-args: -DCMAKE_CXX_STANDARD=20 + - runs-on: windows-latest + python-version: 'pypy3.12' + cmake-args: -DCMAKE_CXX_STANDARD=17 # The setup-python action currently doesn't have graalpy for windows # See https://github.com/actions/setup-python/pull/880 diff --git a/include/pybind11/detail/class-inl.h b/include/pybind11/detail/class-inl.h index 57d12fee44..47746f77d8 100644 --- a/include/pybind11/detail/class-inl.h +++ b/include/pybind11/detail/class-inl.h @@ -407,6 +407,17 @@ PYBIND11_INLINE void clear_patients(PyObject *self) { } } +PYBIND11_INLINE PyObject **instance_dict_ptr(PyObject *self) { +#if defined(PYPY_VERSION) + // PyPy 3.12's _PyObject_GetDictPtr() aborts if called from tp_dealloc. + auto offset = Py_TYPE(self)->tp_dictoffset; + return offset > 0 ? reinterpret_cast(reinterpret_cast(self) + offset) + : nullptr; +#else + return _PyObject_GetDictPtr(self); +#endif +} + PYBIND11_INLINE void clear_instance(PyObject *self) { auto *instance = reinterpret_cast(self); @@ -436,7 +447,7 @@ PYBIND11_INLINE void clear_instance(PyObject *self) { PyObject_ClearWeakRefs(self); } - PyObject **dict_ptr = _PyObject_GetDictPtr(self); + PyObject **dict_ptr = instance_dict_ptr(self); if (dict_ptr) { Py_CLEAR(*dict_ptr); } @@ -525,7 +536,7 @@ extern "C" PYBIND11_INLINE int pybind11_traverse(PyObject *self, visitproc visit return ret; } #else - PyObject *&dict = *_PyObject_GetDictPtr(self); + PyObject *&dict = *instance_dict_ptr(self); Py_VISIT(dict); #endif // https://docs.python.org/3/c-api/typeobj.html#c.PyTypeObject.tp_traverse @@ -537,7 +548,7 @@ extern "C" PYBIND11_INLINE int pybind11_clear(PyObject *self) { #if PY_VERSION_HEX >= 0x030D0000 PyObject_ClearManagedDict(self); #else - PyObject *&dict = *_PyObject_GetDictPtr(self); + PyObject *&dict = *instance_dict_ptr(self); Py_CLEAR(dict); #endif return 0; diff --git a/include/pybind11/detail/class.h b/include/pybind11/detail/class.h index 74e5ef0593..8716223d68 100644 --- a/include/pybind11/detail/class.h +++ b/include/pybind11/detail/class.h @@ -122,6 +122,9 @@ void add_patient(PyObject *nurse, PyObject *patient); void clear_patients(PyObject *self); +/// Returns the address of the instance `__dict__` slot, or nullptr if there is none. +PyObject **instance_dict_ptr(PyObject *self); + /// Clears all internal data from the instance and removes it from registered instances in /// preparation for deallocation. void clear_instance(PyObject *self); diff --git a/include/pybind11/pytypes-inl.h b/include/pybind11/pytypes-inl.h index f2eea1070f..f603c0b673 100644 --- a/include/pybind11/pytypes-inl.h +++ b/include/pybind11/pytypes-inl.h @@ -265,8 +265,21 @@ PYBIND11_INLINE memoryview memoryview::from_buffer(void *ptr, view.itemsize = itemsize; view.format = const_cast(format); view.ndim = static_cast(ndim); +#if defined(PYPY_VERSION) + // PyPy (at least up to 8.0.0) only copies shape and strides that point to its inline arrays. + if (ndim > PyBUF_MAX_NDIM) { + pybind11_fail("memoryview: too many dimensions"); + } + for (size_t i = 0; i < ndim; ++i) { + view._shape[i] = (*shape)[i]; + view._strides[i] = (*strides)[i]; + } + view.shape = view._shape; + view.strides = view._strides; +#else view.shape = shape->data(); view.strides = strides->data(); +#endif view.suboffsets = nullptr; view.internal = nullptr; PyObject *obj = PyMemoryView_FromBuffer(&view); diff --git a/tests/test_multiple_inheritance.py b/tests/test_multiple_inheritance.py index e413d32fa0..10932b5dd8 100644 --- a/tests/test_multiple_inheritance.py +++ b/tests/test_multiple_inheritance.py @@ -266,9 +266,13 @@ def test_mi_static_properties(): def test_mi_dynamic_attributes(): """Mixing bases with and without dynamic attribute support""" - for d in (m.VanillaDictMix1(), m.VanillaDictMix2()): + for cls in (m.VanillaDictMix1, m.VanillaDictMix2): + d = cls() d.dynamic = 1 assert d.dynamic == 1 + del d + # The dict must be cleared without crashing (PyPy 3.12) + pytest.gc_collect() def test_mi_unaligned_base(): From 1a1ebe9e8d69654d089ec19d8b12156ad26c3598 Mon Sep 17 00:00:00 2001 From: Henry Schreiner Date: Wed, 7 Oct 2026 13:35:08 -0400 Subject: [PATCH 2/5] tests: collect PyPrintDestructor inside its test A later PyPy GC at an arbitrary point can run the Python-calling destructor and abort. Assisted-by: ClaudeCode:claude-opus-5-5 --- tests/test_class.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_class.py b/tests/test_class.py index 638053b494..81bf67e6f7 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -622,6 +622,8 @@ class PyNonFinalFinalChild(m.IsNonFinalFinal): def test_exception_rvalue_abort(): with pytest.raises(RuntimeError): m.PyPrintDestructor().throw_something() + # The destructor calls Python; on PyPy, a later GC at an arbitrary point can abort + pytest.gc_collect() # https://github.com/pybind/pybind11/issues/1568 From f584dca185822c525f5dce3b798a502579c3f08c Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 17 Oct 2026 13:58:10 -0700 Subject: [PATCH 3/5] fix: limit PyPy dict deallocation workaround to 8.0.x PyPy 8.0.1 fixes the deallocation bug, but 8.0.x releases share an extension ABI. Retain the workaround throughout that minor series so extensions built with newer headers can still run on 8.0.0. Register PYPY_VERSION_NUM with the inline-header configuration checker. --- include/pybind11/detail/class-inl.h | 7 +++++-- tools/check_inl_headers.py | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/include/pybind11/detail/class-inl.h b/include/pybind11/detail/class-inl.h index 47746f77d8..5cbaee50c3 100644 --- a/include/pybind11/detail/class-inl.h +++ b/include/pybind11/detail/class-inl.h @@ -408,8 +408,11 @@ PYBIND11_INLINE void clear_patients(PyObject *self) { } PYBIND11_INLINE PyObject **instance_dict_ptr(PyObject *self) { -#if defined(PYPY_VERSION) - // PyPy 3.12's _PyObject_GetDictPtr() aborts if called from tp_dealloc. +#if defined(PYPY_VERSION_NUM) && (PYPY_VERSION_NUM & 0xFFFF0000) == 0x08000000 + // PyPy 8.0.0's _PyObject_GetDictPtr() can abort when called from tp_dealloc. + // The bug was fixed in 8.0.1, but all 8.0.x releases share an extension ABI, + // so an extension built against a fixed release can still run on 8.0.0. + // Keep this workaround for the entire 8.0.x series. auto offset = Py_TYPE(self)->tp_dictoffset; return offset > 0 ? reinterpret_cast(reinterpret_cast(self) + offset) : nullptr; diff --git a/tools/check_inl_headers.py b/tools/check_inl_headers.py index 6787fb740b..1de93b4768 100755 --- a/tools/check_inl_headers.py +++ b/tools/check_inl_headers.py @@ -23,6 +23,7 @@ "PYBIND11_HAS_STRING_VIEW", "PYBIND11_PRECOMPILED", "PYPY_VERSION", + "PYPY_VERSION_NUM", "PY_MAJOR_VERSION", "PY_MINOR_VERSION", "PY_VERSION_HEX", From 9f74660f41859bf04f1c4a6251071e2d6a75a303 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 17 Oct 2026 14:02:27 -0700 Subject: [PATCH 4/5] ci: test PyPy 8.0.1 while retaining 8.0.0 regression coverage Update the Linux Python 3.11 and macOS Python 3.12 jobs to PyPy 8.0.1. Keep Linux Python 3.12 on 8.0.0 to exercise the deallocation and memoryview workarounds without adding jobs. --- .github/workflows/ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9a508333e..1248b96629 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,8 +48,9 @@ jobs: python-version: '3.14t' cmake-args: -DCMAKE_CXX_STANDARD=17 -DPYBIND11_TEST_SMART_HOLDER=ON - runs-on: ubuntu-latest - python-version: 'pypy-3.11-v8.0.0' + python-version: 'pypy-3.11-v8.0.1' cmake-args: -DCMAKE_CXX_STANDARD=17 + # Keep 8.0.0 to test the deallocation and memoryview workarounds (PR #6197). - runs-on: ubuntu-latest python-version: 'pypy-3.12-v8.0.0' cmake-args: -DCMAKE_CXX_STANDARD=20 @@ -141,7 +142,7 @@ jobs: python-version: '3.15' cmake-args: -DCMAKE_CXX_STANDARD=17 - runs-on: macos-latest - python-version: 'pypy-3.12-v8.0.0' + python-version: 'pypy-3.12-v8.0.1' - runs-on: macos-latest python-version: 'graalpy-25.0' - runs-on: macos-latest From 3d087e14196e3d8226c36aeed65316cc98d149a7 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sun, 18 Oct 2026 16:00:43 -0700 Subject: [PATCH 5/5] ci: retain a PyPy 7 Linux job Keep a pinned PyPy 7.3.23 / Python 3.11 job alongside the PyPy 8 matrix while PyPy 7 remains supported. Assisted-by: Codex --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b7e373318..607f612a68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,10 @@ jobs: - runs-on: ubuntu-latest python-version: '3.14t' cmake-args: -DCMAKE_CXX_STANDARD=17 -DPYBIND11_TEST_SMART_HOLDER=ON + # Keep one PyPy 7 job while that runtime remains supported. + - runs-on: ubuntu-latest + python-version: 'pypy-3.11-v7.3.23' + cmake-args: -DCMAKE_CXX_STANDARD=17 - runs-on: ubuntu-latest python-version: 'pypy-3.11-v8.0.1' cmake-args: -DCMAKE_CXX_STANDARD=17