From f3cead40e3a2afc0f272d814d0b5b1039787d22e Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Fri, 26 Jun 2026 15:23:07 +0300 Subject: [PATCH] gh-116946: Implement the GC protocol for _tkinter tkapp and tktimertoken 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 --- Lib/test/test_tkinter/test_misc.py | 33 ++++++++ ...-06-26-14-05-00.gh-issue-116946.Kp7raZ.rst | 3 + Modules/_tkinter.c | 79 ++++++++++++++++--- 3 files changed, 102 insertions(+), 13 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-26-14-05-00.gh-issue-116946.Kp7raZ.rst diff --git a/Lib/test/test_tkinter/test_misc.py b/Lib/test/test_tkinter/test_misc.py index 98af4d822dadc4..7e98a605f7e221 100644 --- a/Lib/test/test_tkinter/test_misc.py +++ b/Lib/test/test_tkinter/test_misc.py @@ -1,13 +1,16 @@ import collections.abc import functools +import gc import platform import sys +import time import unittest import tkinter from tkinter import TclError import enum from test import support from test.support import os_helper +from test.support.script_helper import assert_python_ok from test.test_tkinter.support import setUpModule # noqa: F401 from test.test_tkinter.support import (AbstractTkTest, AbstractDefaultRootTest, requires_tk, get_tk_patchlevel, @@ -350,6 +353,36 @@ def callback(): self.root.deletecommand(name) self.assertRaises(TclError, self.root.tk.call, name) + 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 index 00000000000000..95957b7d9d9894 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-26-14-05-00.gh-issue-116946.Kp7raZ.rst @@ -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. diff --git a/Modules/_tkinter.c b/Modules/_tkinter.c index 466e275c5cecf0..e76d90c23e8ce6 100644 --- a/Modules/_tkinter.c +++ b/Modules/_tkinter.c @@ -626,7 +626,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; @@ -2810,7 +2811,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; @@ -2821,16 +2823,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; + } + Py_XDECREF(self->func); + tp->tp_free(op); Py_DECREF(tp); } @@ -3124,17 +3151,37 @@ _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); /*CHECK_TCL_APPARTMENT;*/ ENTER_TCL 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(); } @@ -3341,6 +3388,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} @@ -3353,6 +3402,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, }; @@ -3400,6 +3450,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} }; @@ -3412,6 +3464,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, };