Skip to content

Commit 9c414f5

Browse files
committed
Fix segfault in GC traversal of Python subclasses with weak references (#1206)
When ``nb::is_weak_referenceable()`` is used, nanobind installs ``tp_traverse`` and ``tp_clear`` callbacks to handle garbage collection of instance dictionaries and weak reference lists. When a Python subclass is created, Python may add its own instance dictionary (potentially using managed dictionaries on Python 3.12+) or weak reference list. Python's ``subtype_traverse`` function walks up the MRO and calls our ``tp_traverse`` callback. However, our callback was reading ``tp_dictoffset`` and ``tp_weaklistoffset`` directly from the type object, which includes Python's additions. This caused crashes because: 1. On Python 3.11+, negative offsets are used (measured from the end of the object rather than the beginning) which we didn't handle 2. On Python 3.12+, managed dictionaries require special APIs that we weren't using and that aren't even available in the stable ABI/limited API The fix is to always cache ``dictoffset``/``weaklistoffset`` in ``type_data`` when creating nanobind types, and use these cached values in ``nb_dict_ptr()`` and ``nb_weaklist_ptr()``. This ensures we only access dicts and weaklists that nanobind created, while Python's ``subtype_traverse`` handles any additions made by Python subclasses. This requires an ABI bump due to the addition of ``dictoffset``/``weaklistoffset`` fields to ``type_data`` unconditionally (previously only in ``Py_LIMITED_API`` builds). Fortunately, the ABI was just bumped in commit aa1c9fd. The commit also removes some redundant ``Py_TYPE()`` calls that have a nontrivial cost on stable ABI builds. Fixes #1201
1 parent aa1c9fd commit 9c414f5

File tree

5 files changed

+57
-27
lines changed

5 files changed

+57
-27
lines changed

include/nanobind/nb_class.h

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,8 @@ struct type_data {
128128
};
129129
void (*set_self_py)(void *, PyObject *) noexcept;
130130
bool (*keep_shared_from_this_alive)(PyObject *) noexcept;
131-
#if defined(Py_LIMITED_API)
132131
uint32_t dictoffset;
133132
uint32_t weaklistoffset;
134-
#endif
135133
};
136134

137135
/// Information about a type that is only relevant when it is being created

src/nb_type.cpp

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,13 @@
2121
NAMESPACE_BEGIN(NB_NAMESPACE)
2222
NAMESPACE_BEGIN(detail)
2323

24-
static PyObject **nb_dict_ptr(PyObject *self) {
25-
PyTypeObject *tp = Py_TYPE(self);
26-
#if defined(Py_LIMITED_API)
24+
static PyObject **nb_dict_ptr(PyObject *self, PyTypeObject *tp) {
2725
Py_ssize_t dictoffset = nb_type_data(tp)->dictoffset;
28-
#else
29-
Py_ssize_t dictoffset = tp->tp_dictoffset;
30-
#endif
3126
return dictoffset ? (PyObject **) ((uint8_t *) self + dictoffset) : nullptr;
3227
}
3328

34-
static PyObject **nb_weaklist_ptr(PyObject *self) {
35-
PyTypeObject *tp = Py_TYPE(self);
36-
#if defined(Py_LIMITED_API)
29+
static PyObject **nb_weaklist_ptr(PyObject *self, PyTypeObject *tp) {
3730
Py_ssize_t weaklistoffset = nb_type_data(tp)->weaklistoffset;
38-
#else
39-
Py_ssize_t weaklistoffset = tp->tp_weaklistoffset;
40-
#endif
4131
return weaklistoffset ? (PyObject **) ((uint8_t *) self + weaklistoffset) : nullptr;
4232
}
4333

@@ -47,18 +37,20 @@ static PyGetSetDef inst_getset[] = {
4737
};
4838

4939
static int inst_clear(PyObject *self) {
50-
PyObject **dict = nb_dict_ptr(self);
40+
PyTypeObject *tp = Py_TYPE(self);
41+
PyObject **dict = nb_dict_ptr(self, tp);
5142
if (dict)
5243
Py_CLEAR(*dict);
5344
return 0;
5445
}
5546

5647
static int inst_traverse(PyObject *self, visitproc visit, void *arg) {
57-
PyObject **dict = nb_dict_ptr(self);
48+
PyTypeObject *tp = Py_TYPE(self);
49+
PyObject **dict = nb_dict_ptr(self, tp);
5850
if (dict)
5951
Py_VISIT(*dict);
6052
#if PY_VERSION_HEX >= 0x03090000
61-
Py_VISIT(Py_TYPE(self));
53+
Py_VISIT(tp);
6254
#endif
6355
return 0;
6456
}
@@ -227,16 +219,16 @@ static void inst_dealloc(PyObject *self) {
227219
PyObject_GC_UnTrack(self);
228220

229221
if (t->flags & (uint32_t) type_flags::has_dynamic_attr) {
230-
PyObject **dict = nb_dict_ptr(self);
222+
PyObject **dict = nb_dict_ptr(self, tp);
231223
if (dict)
232224
Py_CLEAR(*dict);
233225
}
234226
}
235227

236228
if (t->flags & (uint32_t) type_flags::is_weak_referenceable &&
237-
nb_weaklist_ptr(self) != nullptr) {
229+
nb_weaklist_ptr(self, tp) != nullptr) {
238230
#if defined(PYPY_VERSION)
239-
PyObject **weaklist = nb_weaklist_ptr(self);
231+
PyObject **weaklist = nb_weaklist_ptr(self, tp);
240232
if (weaklist)
241233
Py_CLEAR(*weaklist);
242234
#else
@@ -1362,13 +1354,10 @@ PyObject *nb_type_new(const type_init_data *t) noexcept {
13621354
if (is_weak_referenceable)
13631355
to->flags |= (uint32_t) type_flags::is_weak_referenceable;
13641356

1365-
#if defined(Py_LIMITED_API)
1366-
/* These must be set unconditionally so that nb_dict_ptr() /
1367-
nb_weaklist_ptr() return null (rather than garbage) on
1368-
objects whose types don't use the corresponding feature. */
1369-
to->dictoffset = (uint32_t) dictoffset;
1370-
to->weaklistoffset = (uint32_t) weaklistoffset;
1371-
#endif
1357+
// Always cache dictoffset/weaklistoffset so nb_dict_ptr()/nb_weaklist_ptr()
1358+
// only access dicts/weaklists created by nanobind, not those added by Python
1359+
to->dictoffset = (uint32_t) dictoffset;
1360+
to->weaklistoffset = (uint32_t) weaklistoffset;
13721361

13731362
if (t->scope != nullptr)
13741363
setattr(t->scope, t_name, result);

tests/test_classes.cpp

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,11 @@ NB_MODULE(test_classes_ext, m) {
644644
nb::is_weak_referenceable(), nb::dynamic_attr())
645645
.def(nb::init<int>());
646646

647+
// test50_weakref_with_slots_subclass
648+
struct StructWithWeakrefsOnly : Struct { };
649+
nb::class_<StructWithWeakrefsOnly, Struct>(m, "StructWithWeakrefsOnly", nb::is_weak_referenceable())
650+
.def(nb::init<int>());
651+
647652
union Union {
648653
int i;
649654
float f;

tests/test_classes.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,3 +945,38 @@ def my_init(self):
945945
def test49_static_property_override():
946946
assert t.StaticPropertyOverride.x == 42
947947
assert t.StaticPropertyOverride2.x == 43
948+
949+
def test50_weakref_with_slots_subclass():
950+
"""
951+
Test that Python subclasses work correctly with nb::is_weak_referenceable()
952+
base classes. The nb::is_weak_referenceable() flag causes nanobind to
953+
install tp_traverse/tp_clear callbacks. When Python subclasses add their
954+
own instance dictionaries (e.g., via managed dicts on Python 3.12+),
955+
subtype_traverse calls our tp_traverse. We must only traverse dicts/weaklists
956+
created by nanobind, not those added by Python.
957+
958+
Regression test for issue #1201.
959+
"""
960+
import gc
961+
962+
# Create a Python subclass with __slots__
963+
class SubClass(t.StructWithWeakrefsOnly):
964+
__slots__ = 'hello',
965+
966+
# Create a sub-subclass without __slots__ (which should get a __dict__)
967+
class SubSubClass(SubClass):
968+
pass
969+
970+
# This should not crash
971+
x = SubSubClass(42)
972+
x.bye = 'blah'
973+
assert x.value() == 42
974+
assert x.bye == 'blah'
975+
976+
# Trigger GC to ensure inst_traverse doesn't crash
977+
gc.collect()
978+
gc.collect()
979+
980+
# Clean up
981+
del x
982+
gc.collect()

tests/test_classes_ext.pyi.ref

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,9 @@ class StructWithWeakrefs(Struct):
281281
class StructWithWeakrefsAndDynamicAttrs(Struct):
282282
def __init__(self, arg: int, /) -> None: ...
283283

284+
class StructWithWeakrefsOnly(Struct):
285+
def __init__(self, arg: int, /) -> None: ...
286+
284287
class Union:
285288
def __init__(self) -> None: ...
286289

0 commit comments

Comments
 (0)