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/Lib/test/test_threading_local.py b/Lib/test/test_threading_local.py index 3a58afd8194a32..e49d757a3c4e02 100644 --- a/Lib/test/test_threading_local.py +++ b/Lib/test/test_threading_local.py @@ -223,6 +223,25 @@ def __eq__(self, other): with self.assertRaisesRegex(AttributeError, 'Loop.*read-only'): loop.__setattr__(NameCompareTrue(), 2) + def test_finalizer_leak(self): + # See https://github.com/python/cpython/issues/140798 + + local = self._local() + + class ClassWithDel: + def __del__(_): + local.b = 42 + self.assertEqual(local.__dict__, {'b': 42}) + + def thread_func(): + local.a = ClassWithDel() + + t = threading.Thread(target=thread_func) + t.start() + t.join() + + self.assertEqual(local.__dict__, {}) + class ThreadLocalTest(unittest.TestCase, BaseLocalTest): _local = _thread._local 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..fc98364ecf0308 --- /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 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..0f39bb6461d68e 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; } @@ -1862,6 +1871,25 @@ clear_locals(PyObject *locals_and_key, PyObject *dummyweakref) "thread local %R", (PyObject *)self); } } + + 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. + // 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 "