]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-149216: Notify type watchers on heap type deallocation (GH-149236)
authorAnuj Nitin Bharambe <119653366+anujbharambe@users.noreply.github.com>
Tue, 5 May 2026 10:24:07 +0000 (15:54 +0530)
committerGitHub <noreply@github.com>
Tue, 5 May 2026 10:24:07 +0000 (11:24 +0100)
Authored-by: Anuj Bharambe <anujnitinb@gmail.com>
Doc/c-api/type.rst
Lib/test/test_capi/test_watchers.py
Misc/NEWS.d/next/C_API/2026-05-01-00-00-00.gh-issue-149216.TpWatch.rst [new file with mode: 0644]
Modules/_testcapi/watchers.c
Objects/typeobject.c

index 1794427a19ee6b431eb87c4cce681d83fc65f9e4..f943de0510fd28d6473badca997d3cffb3cb87ba 100644 (file)
@@ -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)
 
index 67595e3550b0ff23971360dff29225865ba02245..490ae7b23e6279585dfc8bee8c473bfb60a6408c 100644 (file)
@@ -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 @@ class TestTypeWatchers(unittest.TestCase):
         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 (file)
index 0000000..59850c3
--- /dev/null
@@ -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.
index 5a756a87c15fe926c95215d8ce129249e9e6f89a..e0abf6b1845d8efeb246fdd65b6cf997fa7cdba6 100644 (file)
@@ -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) {
index 041dfecccd323034baa45b809d7baa1477737390..4f43747ba83fd9d36e3c39cd0e4017a18714105e 100644 (file)
@@ -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);