From c4e708406956dd6309686ad23c849775d415e10c Mon Sep 17 00:00:00 2001 From: Anuj Bharambe Date: Fri, 1 May 2026 20:22:24 +0530 Subject: [PATCH 1/3] gh-149216: Notify type watchers on heap type deallocation When a watched heap type is deallocated, type watcher callbacks were never invoked. The JIT optimizer relies on type watchers plus pointer comparisons on watched types; if a type is freed and a new type is allocated at the same address, stale JIT code could crash. Call the registered watcher callbacks from type_dealloc(), using the same _PyObject_ResurrectStart/_PyObject_ResurrectEnd pattern that dict_dealloc() already uses. The notification happens before any teardown, so callbacks can safely inspect the type object. --- Doc/c-api/type.rst | 14 ++++++++++ Lib/test/test_capi/test_watchers.py | 22 +++++++++++++++ ...05-01-00-00-00.gh-issue-149216.TpWatch.rst | 5 ++++ Modules/_testcapi/watchers.c | 21 +++++++++++++- Objects/typeobject.c | 28 +++++++++++++++++++ 5 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/C_API/2026-05-01-00-00-00.gh-issue-149216.TpWatch.rst diff --git a/Doc/c-api/type.rst b/Doc/c-api/type.rst index c9bb5c3f09ac18..33c9c16a55f274 100644 --- a/Doc/c-api/type.rst +++ b/Doc/c-api/type.rst @@ -110,11 +110,16 @@ Type Objects :c:func:`!_PyType_Lookup` is not called on *type* between the modifications; this is an implementation detail and subject to change.) + The callback is also invoked when a watched heap type is deallocated. + An extension should never call ``PyType_Watch`` with a *watcher_id* that was not returned to it by a previous call to :c:func:`PyType_AddWatcher`. .. versionadded:: 3.12 + .. versionchanged:: 3.15 + The callback is now also invoked when a watched heap type is deallocated. + .. c:function:: int PyType_Unwatch(int watcher_id, PyObject *type) @@ -138,8 +143,17 @@ Type Objects called on *type* or any type in its MRO; violating this rule could cause infinite recursion. + The callback may be called during type deallocation. In this case, the type + object is temporarily resurrected (its reference count is at least 1) and all + its attributes are still valid. However, the callback should not store new + strong references to the type, as this would resurrect the object and prevent + its deallocation. + .. versionadded:: 3.12 + .. versionchanged:: 3.15 + The callback may now be called during deallocation of a watched heap type. + .. c:function:: int PyType_HasFeature(PyTypeObject *o, int feature) diff --git a/Lib/test/test_capi/test_watchers.py b/Lib/test/test_capi/test_watchers.py index 67595e3550b0ff..490ae7b23e6279 100644 --- a/Lib/test/test_capi/test_watchers.py +++ b/Lib/test/test_capi/test_watchers.py @@ -208,6 +208,7 @@ class TestTypeWatchers(unittest.TestCase): TYPES = 0 # appends modified types to global event list ERROR = 1 # unconditionally sets and signals a RuntimeException WRAP = 2 # appends modified type wrapped in list to global event list + NAME = 3 # appends type name (string) to global event list # duplicating the C constant TYPE_MAX_WATCHERS = 8 @@ -377,6 +378,27 @@ def test_clear_unassigned_watcher_id(self): with self.assertRaisesRegex(ValueError, r"No type watcher set for ID 1"): self.clear_watcher(1) + def test_watch_type_dealloc(self): + # Use the NAME watcher (kind=3) which records the type's name as a + # string, avoiding any reference to the type object itself during + # deallocation. + with self.watcher(kind=self.NAME) as wid: + class MyTestType: pass + self.watch(wid, MyTestType) + del MyTestType + gc_collect() + events = _testcapi.get_type_modified_events() + self.assertIn("MyTestType", events) + + def test_watch_type_dealloc_error(self): + with self.watcher(kind=self.ERROR) as wid: + class MyTestType2: pass + self.watch(wid, MyTestType2) + with catch_unraisable_exception() as cm: + del MyTestType2 + gc_collect() + self.assertEqual(str(cm.unraisable.exc_value), "boom!") + def test_no_more_ids_available(self): with self.assertRaisesRegex(RuntimeError, r"no more type watcher IDs"): with ExitStack() as stack: diff --git a/Misc/NEWS.d/next/C_API/2026-05-01-00-00-00.gh-issue-149216.TpWatch.rst b/Misc/NEWS.d/next/C_API/2026-05-01-00-00-00.gh-issue-149216.TpWatch.rst new file mode 100644 index 00000000000000..59850c3a48a76f --- /dev/null +++ b/Misc/NEWS.d/next/C_API/2026-05-01-00-00-00.gh-issue-149216.TpWatch.rst @@ -0,0 +1,5 @@ +:c:type:`PyType_WatchCallback` callbacks registered via +:c:func:`PyType_AddWatcher` are now also invoked when a watched heap type is +deallocated. Previously, type watchers were only notified of modifications, +which could cause stale references when a type was freed and its address was +reused. diff --git a/Modules/_testcapi/watchers.c b/Modules/_testcapi/watchers.c index 5a756a87c15fe9..e0abf6b1845d8e 100644 --- a/Modules/_testcapi/watchers.c +++ b/Modules/_testcapi/watchers.c @@ -212,13 +212,32 @@ type_modified_callback_error(PyTypeObject *type) return -1; } +static int +type_modified_callback_name(PyTypeObject *type) +{ + assert(PyList_Check(g_type_modified_events)); + PyObject *name = PyUnicode_FromString(type->tp_name); + if (name == NULL) { + return -1; + } + if (PyList_Append(g_type_modified_events, name) < 0) { + Py_DECREF(name); + return -1; + } + Py_DECREF(name); + return 0; +} + static PyObject * add_type_watcher(PyObject *self, PyObject *kind) { int watcher_id; assert(PyLong_Check(kind)); long kind_l = PyLong_AsLong(kind); - if (kind_l == 2) { + if (kind_l == 3) { + watcher_id = PyType_AddWatcher(type_modified_callback_name); + } + else if (kind_l == 2) { watcher_id = PyType_AddWatcher(type_modified_callback_wrap); } else if (kind_l == 1) { diff --git a/Objects/typeobject.c b/Objects/typeobject.c index fb3c7101410683..18144478a8bfba 100644 --- a/Objects/typeobject.c +++ b/Objects/typeobject.c @@ -6879,6 +6879,34 @@ type_dealloc(PyObject *self) // Assert this is a heap-allocated type object _PyObject_ASSERT((PyObject *)type, type->tp_flags & Py_TPFLAGS_HEAPTYPE); + // Notify type watchers before teardown, using the same resurrection + // pattern that dict_dealloc() uses. The type object is still fully + // intact at this point (dict, bases, mro, name are all valid), so + // callbacks can safely inspect it. + if (type->tp_watched) { + _PyObject_ResurrectStart(self); + PyInterpreterState *interp = _PyInterpreterState_GET(); + int bits = type->tp_watched; + int i = 0; + while (bits) { + assert(i < TYPE_MAX_WATCHERS); + if (bits & 1) { + PyType_WatchCallback cb = interp->type_watchers[i]; + if (cb && (cb(type) < 0)) { + PyErr_FormatUnraisable( + "Exception ignored in type watcher callback #%d " + "for %R", + i, type); + } + } + i++; + bits >>= 1; + } + if (_PyObject_ResurrectEnd(self)) { + return; // callback resurrected the object + } + } + _PyObject_GC_UNTRACK(type); type_dealloc_common(type); From 8f9a177ddd95fa6a386473e4949454bbdd71c468 Mon Sep 17 00:00:00 2001 From: Anuj Bharambe Date: Fri, 1 May 2026 22:10:07 +0530 Subject: [PATCH 2/3] Address review: replace resurrection pattern with assert --- Objects/typeobject.c | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Objects/typeobject.c b/Objects/typeobject.c index 18144478a8bfba..459a88e04dc514 100644 --- a/Objects/typeobject.c +++ b/Objects/typeobject.c @@ -6879,12 +6879,10 @@ type_dealloc(PyObject *self) // Assert this is a heap-allocated type object _PyObject_ASSERT((PyObject *)type, type->tp_flags & Py_TPFLAGS_HEAPTYPE); - // Notify type watchers before teardown, using the same resurrection - // pattern that dict_dealloc() uses. The type object is still fully + // Notify type watchers before teardown. The type object is still fully // intact at this point (dict, bases, mro, name are all valid), so // callbacks can safely inspect it. if (type->tp_watched) { - _PyObject_ResurrectStart(self); PyInterpreterState *interp = _PyInterpreterState_GET(); int bits = type->tp_watched; int i = 0; @@ -6902,9 +6900,7 @@ type_dealloc(PyObject *self) i++; bits >>= 1; } - if (_PyObject_ResurrectEnd(self)) { - return; // callback resurrected the object - } + assert(Py_REFCNT(self) == 0); } _PyObject_GC_UNTRACK(type); From e710025b695e6fc0a7dcf40697d223c3c18fffee Mon Sep 17 00:00:00 2001 From: Anuj Bharambe Date: Sun, 3 May 2026 17:00:33 +0530 Subject: [PATCH 3/3] Address review: restore resurrection pattern for type watcher dealloc --- Objects/typeobject.c | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Objects/typeobject.c b/Objects/typeobject.c index 459a88e04dc514..47d408769c29a2 100644 --- a/Objects/typeobject.c +++ b/Objects/typeobject.c @@ -6883,6 +6883,7 @@ type_dealloc(PyObject *self) // intact at this point (dict, bases, mro, name are all valid), so // callbacks can safely inspect it. if (type->tp_watched) { + _PyObject_ResurrectStart(self); PyInterpreterState *interp = _PyInterpreterState_GET(); int bits = type->tp_watched; int i = 0; @@ -6900,7 +6901,9 @@ type_dealloc(PyObject *self) i++; bits >>= 1; } - assert(Py_REFCNT(self) == 0); + if (_PyObject_ResurrectEnd(self)) { + return; // callback resurrected the object + } } _PyObject_GC_UNTRACK(type);