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..47d408769c29a2 100644 --- a/Objects/typeobject.c +++ b/Objects/typeobject.c @@ -6879,6 +6879,33 @@ 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. 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);