https://github.com/python/cpython/commit/f6d16a0d708c611dd96bb739f0896a0b6d7e04e8
commit: f6d16a0d708c611dd96bb739f0896a0b6d7e04e8
branch: main
author: Anuj Nitin Bharambe <[email protected]>
committer: markshannon <[email protected]>
date: 2026-05-05T11:24:07+01:00
summary:

gh-149216: Notify type watchers on heap type deallocation (GH-149236)

Authored-by: Anuj Bharambe <[email protected]>

files:
A Misc/NEWS.d/next/C_API/2026-05-01-00-00-00.gh-issue-149216.TpWatch.rst
M Doc/c-api/type.rst
M Lib/test/test_capi/test_watchers.py
M Modules/_testcapi/watchers.c
M Objects/typeobject.c

diff --git a/Doc/c-api/type.rst b/Doc/c-api/type.rst
index 1794427a19ee6b..f943de0510fd28 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 041dfecccd3230..4f43747ba83fd9 100644
--- a/Objects/typeobject.c
+++ b/Objects/typeobject.c
@@ -6940,6 +6940,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);
 

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to