From b29c5bdcb2f2128ce2bf4e94d1e66c75fd20ef78 Mon Sep 17 00:00:00 2001 From: Mikhail Efimov Date: Fri, 31 Oct 2025 17:26:24 +0300 Subject: [PATCH 01/12] Memory leak fix --- Python/pystate.c | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Python/pystate.c b/Python/pystate.c index 24681536797f94..32411d0c57b20f 100644 --- a/Python/pystate.c +++ b/Python/pystate.c @@ -1669,9 +1669,6 @@ PyThreadState_Clear(PyThreadState *tstate) /* Don't clear tstate->pyframe: it is a borrowed reference */ - Py_CLEAR(tstate->threading_local_key); - Py_CLEAR(tstate->threading_local_sentinel); - Py_CLEAR(((_PyThreadStateImpl *)tstate)->asyncio_running_loop); Py_CLEAR(((_PyThreadStateImpl *)tstate)->asyncio_running_task); @@ -1708,6 +1705,12 @@ PyThreadState_Clear(PyThreadState *tstate) Py_CLEAR(tstate->c_profileobj); Py_CLEAR(tstate->c_traceobj); + // gh-140798: It's important to clear thread local values + // after profiling and tracing primitives. + + Py_CLEAR(tstate->threading_local_key); + Py_CLEAR(tstate->threading_local_sentinel); + Py_CLEAR(tstate->async_gen_firstiter); Py_CLEAR(tstate->async_gen_finalizer); From 0843821b90cf1893b456711f0faadccd721f0b57 Mon Sep 17 00:00:00 2001 From: Mikhail Efimov Date: Fri, 31 Oct 2025 17:40:02 +0300 Subject: [PATCH 02/12] Test added --- Lib/test/test_threading_local.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Lib/test/test_threading_local.py b/Lib/test/test_threading_local.py index 3a58afd8194a32..e09a6cec8de278 100644 --- a/Lib/test/test_threading_local.py +++ b/Lib/test/test_threading_local.py @@ -223,6 +223,31 @@ def __eq__(self, other): with self.assertRaisesRegex(AttributeError, 'Loop.*read-only'): loop.__setattr__(NameCompareTrue(), 2) + def test_settrace_leak(self): + # See https://github.com/python/cpython/issues/100892 + + local = self._local() + + class ClassWithDel: + def __del__(self): + pass + + counter = 0 + + def tracer(frame, event, arg): + self.assertNotEqual(frame.f_code.co_name, '__del__') + nonlocal counter + counter += 1 + local.d = ClassWithDel() + + threading.settrace(tracer) + t = threading.Thread() + t.start() + t.join() + del t + + self.assertEqual(counter, 2) + class ThreadLocalTest(unittest.TestCase, BaseLocalTest): _local = _thread._local From 438245979943e723677615671f5db59ffa5b5f5b Mon Sep 17 00:00:00 2001 From: Mikhail Efimov Date: Fri, 31 Oct 2025 17:42:11 +0300 Subject: [PATCH 03/12] Typo --- Lib/test/test_threading_local.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/test/test_threading_local.py b/Lib/test/test_threading_local.py index e09a6cec8de278..882986eef4b18a 100644 --- a/Lib/test/test_threading_local.py +++ b/Lib/test/test_threading_local.py @@ -224,7 +224,7 @@ def __eq__(self, other): loop.__setattr__(NameCompareTrue(), 2) def test_settrace_leak(self): - # See https://github.com/python/cpython/issues/100892 + # See https://github.com/python/cpython/issues/140798 local = self._local() From 491898d394e67f7a357a491c901525dccb4264f0 Mon Sep 17 00:00:00 2001 From: Mikhail Efimov Date: Fri, 31 Oct 2025 17:50:33 +0300 Subject: [PATCH 04/12] Add NEWS --- .../2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst new file mode 100644 index 00000000000000..25195196871460 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst @@ -0,0 +1,3 @@ +Fix possible memory leak with :class:`threading.local` when tracing is +active. It was produced by incorrect clearing order in +:c:func:`PyThreadState_Clear`. Patch by Mikhail Efimov. From 685c05027c4b0ea7aa7dbb83413bc4d25ea0b684 Mon Sep 17 00:00:00 2001 From: Mikhail Efimov Date: Fri, 31 Oct 2025 18:21:48 +0300 Subject: [PATCH 05/12] Correct cleaning threading trace function in test --- Lib/test/test_threading_local.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_threading_local.py b/Lib/test/test_threading_local.py index 882986eef4b18a..a601bf3bf062b9 100644 --- a/Lib/test/test_threading_local.py +++ b/Lib/test/test_threading_local.py @@ -240,11 +240,15 @@ def tracer(frame, event, arg): counter += 1 local.d = ClassWithDel() - threading.settrace(tracer) - t = threading.Thread() - t.start() - t.join() - del t + old_trace = threading.gettrace() + try: + threading.settrace(tracer) + t = threading.Thread() + t.start() + t.join() + del t + finally: + threading.settrace(old_trace) self.assertEqual(counter, 2) From f7e61628871a7613e4a64ffad1df857396abdb6c Mon Sep 17 00:00:00 2001 From: Mikhail Efimov Date: Fri, 31 Oct 2025 18:40:18 +0300 Subject: [PATCH 06/12] Test readability improved --- Lib/test/test_threading_local.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Lib/test/test_threading_local.py b/Lib/test/test_threading_local.py index a601bf3bf062b9..ac02b6368dfa26 100644 --- a/Lib/test/test_threading_local.py +++ b/Lib/test/test_threading_local.py @@ -232,12 +232,11 @@ class ClassWithDel: def __del__(self): pass - counter = 0 + func_names = [] def tracer(frame, event, arg): - self.assertNotEqual(frame.f_code.co_name, '__del__') - nonlocal counter - counter += 1 + nonlocal func_names + func_names.append(frame.f_code.co_name) local.d = ClassWithDel() old_trace = threading.gettrace() @@ -250,7 +249,7 @@ def tracer(frame, event, arg): finally: threading.settrace(old_trace) - self.assertEqual(counter, 2) + self.assertEqual(func_names, ['run', '_delete']) class ThreadLocalTest(unittest.TestCase, BaseLocalTest): From cdae2c79accb82e981e625a10f6a65309217d953 Mon Sep 17 00:00:00 2001 From: Mikhail Efimov Date: Sun, 2 Nov 2025 20:41:42 +0300 Subject: [PATCH 07/12] Revert pystate.c changes, comment fix --- Include/cpython/pystate.h | 2 +- Python/pystate.c | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Include/cpython/pystate.h b/Include/cpython/pystate.h index ac8798ff6129a0..3f777de0ec4af5 100644 --- a/Include/cpython/pystate.h +++ b/Include/cpython/pystate.h @@ -213,7 +213,7 @@ struct _ts { PyObject *threading_local_key; /* Used by `threading.local`s to be remove keys/values for dying threads. - The PyThreadObject must hold the only reference to this value. + The PyThreadState must hold the only reference to this value. */ PyObject *threading_local_sentinel; _PyRemoteDebuggerSupport remote_debugger_support; diff --git a/Python/pystate.c b/Python/pystate.c index 32411d0c57b20f..24681536797f94 100644 --- a/Python/pystate.c +++ b/Python/pystate.c @@ -1669,6 +1669,9 @@ PyThreadState_Clear(PyThreadState *tstate) /* Don't clear tstate->pyframe: it is a borrowed reference */ + Py_CLEAR(tstate->threading_local_key); + Py_CLEAR(tstate->threading_local_sentinel); + Py_CLEAR(((_PyThreadStateImpl *)tstate)->asyncio_running_loop); Py_CLEAR(((_PyThreadStateImpl *)tstate)->asyncio_running_task); @@ -1705,12 +1708,6 @@ PyThreadState_Clear(PyThreadState *tstate) Py_CLEAR(tstate->c_profileobj); Py_CLEAR(tstate->c_traceobj); - // gh-140798: It's important to clear thread local values - // after profiling and tracing primitives. - - Py_CLEAR(tstate->threading_local_key); - Py_CLEAR(tstate->threading_local_sentinel); - Py_CLEAR(tstate->async_gen_firstiter); Py_CLEAR(tstate->async_gen_finalizer); From 6955309d2fcbca2a7f8faff6d291a842224ce4be Mon Sep 17 00:00:00 2001 From: Mikhail Efimov Date: Sun, 2 Nov 2025 21:13:32 +0300 Subject: [PATCH 08/12] Another fix and test, NEWS updated --- Lib/test/test_threading_local.py | 28 +++++---------- ...-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst | 6 ++-- Modules/_threadmodule.c | 34 ++++++++++++++++++- 3 files changed, 45 insertions(+), 23 deletions(-) diff --git a/Lib/test/test_threading_local.py b/Lib/test/test_threading_local.py index ac02b6368dfa26..152b7cbb00d03d 100644 --- a/Lib/test/test_threading_local.py +++ b/Lib/test/test_threading_local.py @@ -223,33 +223,23 @@ def __eq__(self, other): with self.assertRaisesRegex(AttributeError, 'Loop.*read-only'): loop.__setattr__(NameCompareTrue(), 2) - def test_settrace_leak(self): + def test_finalizer_leak(self): # See https://github.com/python/cpython/issues/140798 local = self._local() class ClassWithDel: - def __del__(self): - pass - - func_names = [] + def __del__(_): + local.b = object() - def tracer(frame, event, arg): - nonlocal func_names - func_names.append(frame.f_code.co_name) - local.d = ClassWithDel() + def thread_func(): + local.a = ClassWithDel() - old_trace = threading.gettrace() - try: - threading.settrace(tracer) - t = threading.Thread() - t.start() - t.join() - del t - finally: - threading.settrace(old_trace) + t = threading.Thread(target=thread_func) + t.start() + t.join() - self.assertEqual(func_names, ['run', '_delete']) + self.assertEqual(True, False) class ThreadLocalTest(unittest.TestCase, BaseLocalTest): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst index 25195196871460..199848ee90edc6 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst @@ -1,3 +1,3 @@ -Fix possible memory leak with :class:`threading.local` when tracing is -active. It was produced by incorrect clearing order in -:c:func:`PyThreadState_Clear`. Patch by Mikhail Efimov. +Fix possible memory leak with instance of :class:`_thread._local` when +some finalizer uses this object after ``thread_local_key`` was cleared. +Patch by Mikhail Efimov. diff --git a/Modules/_threadmodule.c b/Modules/_threadmodule.c index cc8277c5783858..b3fea0cb115ebe 100644 --- a/Modules/_threadmodule.c +++ b/Modules/_threadmodule.c @@ -1452,8 +1452,10 @@ typedef struct { PyObject *weakreflist; /* List of weak references to self */ /* A {localdummy -> localdict} dict */ PyObject *localdicts; - /* A set of weakrefs to thread sentinels localdummies*/ + /* A set of weakrefs to thread sentinels localdummies */ PyObject *thread_watchdogs; + /* A set of thread sentinels that were created during finalization */ + PyObject *abandoned_sentinels; } localobject; #define localobject_CAST(op) ((localobject *)(op)) @@ -1544,6 +1546,11 @@ local_new(PyTypeObject *type, PyObject *args, PyObject *kw) goto err; } + self->abandoned_sentinels = PySet_New(NULL); + if (self->abandoned_sentinels == NULL) { + goto err; + } + PyObject *localsdict = NULL; PyObject *sentinel_wr = NULL; if (create_localsdict(self, state, &localsdict, &sentinel_wr) < 0) { @@ -1568,6 +1575,7 @@ local_traverse(PyObject *op, visitproc visit, void *arg) Py_VISIT(self->kw); Py_VISIT(self->localdicts); Py_VISIT(self->thread_watchdogs); + Py_VISIT(self->abandoned_sentinels); return 0; } @@ -1579,6 +1587,7 @@ local_clear(PyObject *op) Py_CLEAR(self->kw); Py_CLEAR(self->localdicts); Py_CLEAR(self->thread_watchdogs); + Py_CLEAR(self->abandoned_sentinels); return 0; } @@ -1855,6 +1864,11 @@ clear_locals(PyObject *locals_and_key, PyObject *dummyweakref) /* If the thread-local object is still alive and not being cleared, remove the corresponding local dict */ + + PyThreadState *tstate = PyThreadState_Get(); + assert(tstate->threading_local_key == NULL); + assert(tstate->threading_local_sentinel == NULL); + if (self->localdicts != NULL) { PyObject *key = PyTuple_GetItem(locals_and_key, 1); if (PyDict_Pop(self->localdicts, key, NULL) < 0) { @@ -1862,6 +1876,24 @@ clear_locals(PyObject *locals_and_key, PyObject *dummyweakref) "thread local %R", (PyObject *)self); } } + + if (tstate->threading_local_key != NULL) { + // gh-140798: It's possible that some finalizer recreates + // thread key and sentinel localdummies during clearing localsdict. + // In this case we save thread sentinel to local set and + // clear key and sentinel in thread state one more time. + if (PySet_Add(self->abandoned_sentinels, tstate->threading_local_sentinel) < 0) { + PyErr_FormatUnraisable("Exception ignored while adding " + "thread abandoned sentinel of %R", self); + } + + Py_CLEAR(tstate->threading_local_key); + Py_CLEAR(tstate->threading_local_sentinel); + } + + assert(tstate->threading_local_key == NULL); + assert(tstate->threading_local_sentinel == NULL); + if (self->thread_watchdogs != NULL) { if (PySet_Discard(self->thread_watchdogs, dummyweakref) < 0) { PyErr_FormatUnraisable("Exception ignored while clearing " From c4bc09927eca004831f4d60bd42dc1ccd5d2b7df Mon Sep 17 00:00:00 2001 From: Mikhail Efimov Date: Sun, 2 Nov 2025 21:59:36 +0300 Subject: [PATCH 09/12] Test improved --- Lib/test/test_threading_local.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_threading_local.py b/Lib/test/test_threading_local.py index 152b7cbb00d03d..e49d757a3c4e02 100644 --- a/Lib/test/test_threading_local.py +++ b/Lib/test/test_threading_local.py @@ -230,7 +230,8 @@ def test_finalizer_leak(self): class ClassWithDel: def __del__(_): - local.b = object() + local.b = 42 + self.assertEqual(local.__dict__, {'b': 42}) def thread_func(): local.a = ClassWithDel() @@ -239,7 +240,7 @@ def thread_func(): t.start() t.join() - self.assertEqual(True, False) + self.assertEqual(local.__dict__, {}) class ThreadLocalTest(unittest.TestCase, BaseLocalTest): From 50bc0f6bcae3e82688c65f7ea21ef517048069cf Mon Sep 17 00:00:00 2001 From: Mikhail Efimov Date: Sun, 2 Nov 2025 23:29:13 +0300 Subject: [PATCH 10/12] Remove two asserts --- Modules/_threadmodule.c | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Modules/_threadmodule.c b/Modules/_threadmodule.c index b3fea0cb115ebe..d47d3e9e34136a 100644 --- a/Modules/_threadmodule.c +++ b/Modules/_threadmodule.c @@ -1865,10 +1865,6 @@ clear_locals(PyObject *locals_and_key, PyObject *dummyweakref) /* If the thread-local object is still alive and not being cleared, remove the corresponding local dict */ - PyThreadState *tstate = PyThreadState_Get(); - assert(tstate->threading_local_key == NULL); - assert(tstate->threading_local_sentinel == NULL); - if (self->localdicts != NULL) { PyObject *key = PyTuple_GetItem(locals_and_key, 1); if (PyDict_Pop(self->localdicts, key, NULL) < 0) { @@ -1877,6 +1873,7 @@ clear_locals(PyObject *locals_and_key, PyObject *dummyweakref) } } + PyThreadState *tstate = PyThreadState_Get(); if (tstate->threading_local_key != NULL) { // gh-140798: It's possible that some finalizer recreates // thread key and sentinel localdummies during clearing localsdict. From dfa5f30b8297392e581b8b924ab6032fedaea6bb Mon Sep 17 00:00:00 2001 From: Mikhail Efimov Date: Mon, 3 Nov 2025 00:23:41 +0300 Subject: [PATCH 11/12] NEWS fix --- .../2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst b/Misc/NEWS.d/next/Core_and_Builtins/2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst index 199848ee90edc6..fc98364ecf0308 100644 --- a/Misc/NEWS.d/next/Core_and_Builtins/2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst +++ b/Misc/NEWS.d/next/Core_and_Builtins/2025-10-31-17-50-28.gh-issue-140798.ZyXp_r.rst @@ -1,3 +1,3 @@ -Fix possible memory leak with instance of :class:`_thread._local` when +Fix possible memory leak with instance of :class:`!_thread._local` when some finalizer uses this object after ``thread_local_key`` was cleared. Patch by Mikhail Efimov. From a7d31479d6909e16b9cf5bf663b0af3d448e9947 Mon Sep 17 00:00:00 2001 From: Mikhail Efimov Date: Mon, 3 Nov 2025 00:28:14 +0300 Subject: [PATCH 12/12] Remove one blank line --- Modules/_threadmodule.c | 1 - 1 file changed, 1 deletion(-) diff --git a/Modules/_threadmodule.c b/Modules/_threadmodule.c index d47d3e9e34136a..0f39bb6461d68e 100644 --- a/Modules/_threadmodule.c +++ b/Modules/_threadmodule.c @@ -1864,7 +1864,6 @@ clear_locals(PyObject *locals_and_key, PyObject *dummyweakref) /* If the thread-local object is still alive and not being cleared, remove the corresponding local dict */ - if (self->localdicts != NULL) { PyObject *key = PyTuple_GetItem(locals_and_key, 1); if (PyDict_Pop(self->localdicts, key, NULL) < 0) {