]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-116946: Implement the GC protocol for _tkinter tkapp and tktimertoken (GH-152310)
authorSerhiy Storchaka <storchaka@gmail.com>
Fri, 10 Jul 2026 16:44:44 +0000 (19:44 +0300)
committerGitHub <noreply@github.com>
Fri, 10 Jul 2026 16:44:44 +0000 (19:44 +0300)
The _tkinter.tkapp and _tkinter.tktimertoken types never implemented the
garbage collector protocol, so reference cycles through an interpreter's
trace function or a timer handler's callback could not be collected.

A pending timer is kept alive by the Tcl event loop, which fires it even
after the Python token is dropped, so it is treated as a GC root (only its
callback is traversed) and collecting it never cancels a live timer.  The
GC slots use a plain cast rather than the type-checking macro, since the
collector may visit a surviving object at shutdown after module clearing
has reset the global type pointers.  Deallocation cancels any pending timer
so its callback cannot run on freed memory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Bénédikt Tran <10796600+picnixz@users.noreply.github.com>
Lib/test/test_tkinter/test_misc.py
Misc/NEWS.d/next/Library/2026-06-26-14-05-00.gh-issue-116946.Kp7raZ.rst [new file with mode: 0644]
Modules/_tkinter.c

index 9f883e6d79c20148a51374802ab933a6e81ef294..e764ce91b1161c4f17e2121c9492949fbc7bf24f 100644 (file)
@@ -1,8 +1,10 @@
 import collections.abc
 import functools
+import gc
 import platform
 import sys
 import textwrap
+import time
 import unittest
 import weakref
 import tkinter
@@ -414,6 +416,36 @@ class MiscTest(AbstractTkTest, unittest.TestCase):
         support.gc_collect()
         self.assertIsNone(ref())
 
+    def test_gc_protocol(self):
+        # gh-116946: _tkinter objects implement the GC protocol.
+        self.assertTrue(gc.is_tracked(self.root))
+        tok = self.root.tk.createtimerhandler(10_000_000, lambda: None)
+        try:
+            self.assertTrue(gc.is_tracked(tok))
+        finally:
+            tok.deletetimerhandler()
+
+    def test_timer_fires_after_gc(self):
+        # gh-116946: a pending timer is kept alive by the Tcl event loop, not by
+        # the garbage collector, so collecting it must not cancel it -- it must
+        # still fire even when the Python token has been dropped.
+        fired = []
+        self.root.tk.createtimerhandler(1, lambda: fired.append(1))
+        support.gc_collect()
+        deadline = time.monotonic() + support.SHORT_TIMEOUT
+        while not fired and time.monotonic() < deadline:
+            self.root.update()
+        self.assertEqual(fired, [1])
+
+    def test_pending_timer_at_shutdown(self):
+        # gh-116946: the final garbage collection at interpreter shutdown must
+        # not crash when it visits a timer that is still pending (its type has
+        # already been cleared by the module's tp_clear).
+        assert_python_ok('-c',
+            'import tkinter\n'
+            'interp = tkinter.Tcl()\n'
+            'interp.tk.createtimerhandler(10_000_000, lambda: None)\n')
+
     def test_option(self):
         self.addCleanup(self.root.option_clear)
         self.root.option_add('*Button.background', 'red')
diff --git a/Misc/NEWS.d/next/Library/2026-06-26-14-05-00.gh-issue-116946.Kp7raZ.rst b/Misc/NEWS.d/next/Library/2026-06-26-14-05-00.gh-issue-116946.Kp7raZ.rst
new file mode 100644 (file)
index 0000000..95957b7
--- /dev/null
@@ -0,0 +1,3 @@
+The internal :mod:`!_tkinter` ``tkapp`` and ``tktimertoken`` types now
+implement the garbage collector protocol, so reference cycles involving a
+Tcl interpreter or a timer handler can be collected.
index 30185c08eeabc9b8acc3df4ec491058363acab11..f5fb1841f0d59409b2bdfaf5939ea9497bfb5bf7 100644 (file)
@@ -635,7 +635,8 @@ Tkapp_New(const char *screenName, const char *className,
     TkappObject *v;
     char *argv0;
 
-    v = PyObject_New(TkappObject, (PyTypeObject *) Tkapp_Type);
+    PyTypeObject *tp = (PyTypeObject *)Tkapp_Type;
+    v = (TkappObject *)tp->tp_alloc(tp, 0);
     if (v == NULL)
         return NULL;
 
@@ -2943,7 +2944,8 @@ Tktt_New(PyObject *func)
 {
     TkttObject *v;
 
-    v = PyObject_New(TkttObject, (PyTypeObject *) Tktt_Type);
+    PyTypeObject *tp = (PyTypeObject *)Tktt_Type;
+    v = (TkttObject *)tp->tp_alloc(tp, 0);
     if (v == NULL)
         return NULL;
 
@@ -2954,16 +2956,41 @@ Tktt_New(PyObject *func)
     return (TkttObject*)Py_NewRef(v);
 }
 
-static void
-Tktt_Dealloc(PyObject *self)
+/* Plain cast, not TkttObject_CAST: the GC can run at shutdown after
+   module_clear() has cleared the global Tktt_Type the macro checks against. */
+
+static int
+Tktt_Clear(PyObject *op)
 {
-    TkttObject *v = TkttObject_CAST(self);
-    PyObject *func = v->func;
-    PyObject *tp = (PyObject *) Py_TYPE(self);
+    TkttObject *self = (TkttObject *)op;
+    Py_CLEAR(self->func);
+    return 0;
+}
 
-    Py_XDECREF(func);
+static int
+Tktt_Traverse(PyObject *op, visitproc visit, void *arg)
+{
+    TkttObject *self = (TkttObject *)op;
+    Py_VISIT(Py_TYPE(op));
+    /* Not the extra reference of a pending timer (see Tktt_New): it is owned
+       by the Tcl event loop, so the timer is a GC root, not part of a cycle. */
+    Py_VISIT(self->func);
+    return 0;
+}
 
-    PyObject_Free(self);
+static void
+Tktt_Dealloc(PyObject *op)
+{
+    TkttObject *self = (TkttObject *)op;  /* see GC slots above */
+    PyTypeObject *tp = Py_TYPE(op);
+    PyObject_GC_UnTrack(op);
+    /* Cancel any pending timer so its callback cannot fire on freed memory. */
+    if (self->token != NULL) {
+        Tcl_DeleteTimerHandler(self->token);
+        self->token = NULL;
+    }
+    (void)Tktt_Clear(op);
+    tp->tp_free(op);
     Py_DECREF(tp);
 }
 
@@ -3257,11 +3284,31 @@ _tkinter_tkapp_willdispatch_impl(TkappObject *self)
 
 /**** Tkapp Type Methods ****/
 
+/* Plain casts -- see the Tktt GC slots above. */
+
+static int
+Tkapp_Clear(PyObject *op)
+{
+    TkappObject *self = (TkappObject *)op;
+    Py_CLEAR(self->trace);
+    return 0;
+}
+
+static int
+Tkapp_Traverse(PyObject *op, visitproc visit, void *arg)
+{
+    TkappObject *self = (TkappObject *)op;
+    Py_VISIT(Py_TYPE(op));
+    Py_VISIT(self->trace);
+    return 0;
+}
+
 static void
 Tkapp_Dealloc(PyObject *op)
 {
-    TkappObject *self = TkappObject_CAST(op);
-    PyTypeObject *tp = Py_TYPE(self);
+    TkappObject *self = (TkappObject *)op;
+    PyTypeObject *tp = Py_TYPE(op);
+    PyObject_GC_UnTrack(op);
     if (self->threaded && self->thread_id != Tcl_GetCurrentThread()) {
         /* Deleting the interpreter from another thread aborts the process
            ("Tcl_AsyncDelete: async handler deleted by the wrong thread").
@@ -3280,8 +3327,8 @@ Tkapp_Dealloc(PyObject *op)
         Tcl_DeleteInterp(Tkapp_Interp(self));
         LEAVE_TCL
     }
-    Py_XDECREF(self->trace);
-    PyObject_Free(self);
+    (void)Tkapp_Clear(op);
+    tp->tp_free(op);
     Py_DECREF(tp);
     DisableEventHook();
 }
@@ -3488,6 +3535,8 @@ static PyMethodDef Tktt_methods[] =
 
 static PyType_Slot Tktt_Type_slots[] = {
     {Py_tp_dealloc, Tktt_Dealloc},
+    {Py_tp_traverse, Tktt_Traverse},
+    {Py_tp_clear, Tktt_Clear},
     {Py_tp_repr, Tktt_Repr},
     {Py_tp_methods, Tktt_methods},
     {0, 0}
@@ -3500,6 +3549,7 @@ static PyType_Spec Tktt_Type_spec = {
         Py_TPFLAGS_DEFAULT
         | Py_TPFLAGS_DISALLOW_INSTANTIATION
         | Py_TPFLAGS_IMMUTABLETYPE
+        | Py_TPFLAGS_HAVE_GC
     ),
     .slots = Tktt_Type_slots,
 };
@@ -3547,6 +3597,8 @@ static PyMethodDef Tkapp_methods[] =
 
 static PyType_Slot Tkapp_Type_slots[] = {
     {Py_tp_dealloc, Tkapp_Dealloc},
+    {Py_tp_traverse, Tkapp_Traverse},
+    {Py_tp_clear, Tkapp_Clear},
     {Py_tp_methods, Tkapp_methods},
     {0, 0}
 };
@@ -3559,6 +3611,7 @@ static PyType_Spec Tkapp_Type_spec = {
         Py_TPFLAGS_DEFAULT
         | Py_TPFLAGS_DISALLOW_INSTANTIATION
         | Py_TPFLAGS_IMMUTABLETYPE
+        | Py_TPFLAGS_HAVE_GC
     ),
     .slots = Tkapp_Type_slots,
 };