From: Łukasz Langa Date: Tue, 14 Jul 2026 23:58:14 +0000 (+0200) Subject: gh-102960: Make frames weak-referenceable (#152838) X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=6ae740c0a8cc67d71b6deebf459f57773cc06961;p=thirdparty%2FPython%2Fcpython.git gh-102960: Make frames weak-referenceable (#152838) Add an explicit f_weakreflist field to the frame object, following the same pattern as generators and coroutines, including free-threading-safe weakref clearing via FT_CLEAR_WEAKREFS() in frame_dealloc(). Py_TPFLAGS_MANAGED_WEAKREF is not used because static builtin types must not carry it (see init_static_type()) and the pre-header would cost two extra words per frame instead of one. Co-Authored-By: Claude Fable 5 --- diff --git a/Doc/library/weakref.rst b/Doc/library/weakref.rst index 952609c0e700..7cc0c33fda35 100644 --- a/Doc/library/weakref.rst +++ b/Doc/library/weakref.rst @@ -63,12 +63,15 @@ exposed by the :mod:`!weakref` module for the benefit of advanced uses. Not all objects can be weakly referenced. Objects which support weak references include class instances, functions written in Python (but not in C), instance methods, sets, frozensets, some :term:`file objects `, :term:`generators `, -type objects, sockets, arrays, deques, regular expression pattern objects, and code -objects. +type objects, sockets, arrays, deques, regular expression pattern objects, code +objects, and frame objects. .. versionchanged:: 3.2 Added support for thread.lock, threading.Lock, and code objects. +.. versionchanged:: 3.16 + Added support for frame objects. + Several built-in types such as :class:`list` and :class:`dict` do not directly support weak references but can add support through subclassing:: diff --git a/Doc/whatsnew/3.16.rst b/Doc/whatsnew/3.16.rst index fad723ba3f4c..b6a5e4c7fbf1 100644 --- a/Doc/whatsnew/3.16.rst +++ b/Doc/whatsnew/3.16.rst @@ -75,6 +75,11 @@ New features Other language changes ====================== +* :ref:`Frame objects ` now support :mod:`weak references + `. This allows associating extra data with active frames, + for example in debuggers, without keeping the frames (and everything + they reference) alive indefinitely. + (Contributed by Łukasz Langa in :gh:`102960`.) New modules diff --git a/Include/internal/pycore_frame.h b/Include/internal/pycore_frame.h index 3c9ab99c34eb..4eefbf89acb8 100644 --- a/Include/internal/pycore_frame.h +++ b/Include/internal/pycore_frame.h @@ -34,6 +34,7 @@ struct _frame { * "support" for the borrowed references, ensuring that they remain valid. */ PyObject *f_overwritten_fast_locals; + PyObject *f_weakreflist; /* List of weak references */ /* The frame data, if this frame object owns the frame */ PyObject *_f_frame_data[1]; }; diff --git a/Lib/test/test_frame.py b/Lib/test/test_frame.py index 18ade18d1a17..fc50e16575da 100644 --- a/Lib/test/test_frame.py +++ b/Lib/test/test_frame.py @@ -273,6 +273,146 @@ class FrameAttrsTest(unittest.TestCase): raise AssertionError('coroutine did not exit') +class WeakRefTest(unittest.TestCase): + """ + Frames support weak references (gh-102960). + """ + + def make_frame(self): + # Return the frame object of a finished function call. Unlike + # frames extracted from a traceback, it isn't part of a reference + # cycle, so it dies as soon as the last reference is dropped. + def func(): + return sys._getframe() + return func() + + def make_traceback_frames(self): + def outer(): + def inner(): + 1/0 + return inner() + try: + outer() + except ZeroDivisionError as e: + tb = e.__traceback__ + frames = [] + while tb: + frames.append(tb.tb_frame) + tb = tb.tb_next + return frames + + def test_weakref_basic(self): + called = [] + f = self.make_frame() + ref = weakref.ref(f) + cb_ref = weakref.ref(f, called.append) + self.assertIs(ref(), f) + self.assertIs(cb_ref(), f) + del f + support.gc_collect() + self.assertIsNone(ref()) + self.assertIsNone(cb_ref()) + self.assertEqual(called, [cb_ref]) + + @support.thread_unsafe("relies on gc.collect() reclaiming its cycles") + def test_weakref_live_frame(self): + refs = [] + def func(): + frame = sys._getframe() + refs.append(weakref.ref(frame)) + self.assertIs(refs[0](), frame) + func() + support.gc_collect() + self.assertIsNone(refs[0]()) + + @support.thread_unsafe("relies on gc.collect() reclaiming its cycles") + def test_weak_key_dictionary(self): + wkd = weakref.WeakKeyDictionary() + def _fill(): + for i, frame in enumerate(self.make_traceback_frames()): + wkd[frame] = i + self.assertEqual(len(wkd), 3) + _fill() + support.gc_collect() + self.assertEqual(len(wkd), 0) + + @support.thread_unsafe("relies on gc.collect() reclaiming its cycles") + def test_weakref_traceback_frames(self): + # Frames that participate in reference cycles are cleaned up + # by the cyclic garbage collector. + refs = [] + def _make(): + for frame in self.make_traceback_frames(): + refs.append(weakref.ref(frame)) + for ref in refs: + self.assertIsNotNone(ref()) + _make() + support.gc_collect() + for ref in refs: + self.assertIsNone(ref()) + + def test_weakref_generator_frame(self): + def gen(): + yield sys._getframe() + g = gen() + frame = next(g) + ref = weakref.ref(frame) + del frame + support.gc_collect() + # The generator keeps its frame alive while suspended. + self.assertIsNotNone(ref()) + g.close() + del g + support.gc_collect() + self.assertIsNone(ref()) + + def test_weakref_after_frame_clear(self): + f = self.make_frame() + ref = weakref.ref(f) + # Clearing the frame's contents must not affect weak references + # to the frame object itself. + f.clear() + self.assertIs(ref(), f) + del f + support.gc_collect() + self.assertIsNone(ref()) + + @threading_helper.requires_working_threading() + def test_weakref_concurrent(self): + # Exercise concurrent creation and destruction of weak references + # to the same frame, mainly for the free-threaded build. + def gen(): + yield sys._getframe() + g = gen() + frame = next(g) + barrier = threading.Barrier(4) + # Collect failures instead of asserting in the workers: exceptions + # raised in threads don't propagate to the unittest result. + failures = [] + def work(): + barrier.wait() + for _ in range(1000): + ref = weakref.ref(frame) + if ref() is not frame: + failures.append('shared ref dead while frame alive') + # Callback refs are not shared, so this concurrently adds + # to and removes from the frame's weakref list. + cb_ref = weakref.ref(frame, lambda r: None) + if cb_ref() is not frame: + failures.append('callback ref dead while frame alive') + del ref, cb_ref + threads = [threading.Thread(target=work) for _ in range(4)] + with threading_helper.start_threads(threads): + pass + self.assertEqual(failures, []) + ref = weakref.ref(frame) + del frame + g.close() + del g + support.gc_collect() + self.assertIsNone(ref()) + + class ReprTest(unittest.TestCase): """ Tests for repr(frame). diff --git a/Lib/test/test_sys.py b/Lib/test/test_sys.py index 1773633730ea..56c5c2aa2025 100644 --- a/Lib/test/test_sys.py +++ b/Lib/test/test_sys.py @@ -1700,7 +1700,7 @@ class SizeofTest(unittest.TestCase): INTERPRETER_FRAME = '9PihcP' else: INTERPRETER_FRAME = '9PhcP' - check(x, size('3PiccPPP' + INTERPRETER_FRAME + 'P')) + check(x, size('3PiccPPPP' + INTERPRETER_FRAME + 'P')) # function def func(): pass check(func, size('16Pi')) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-07-01-12-00-00.gh-issue-102960.fR8kJa.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-01-12-00-00.gh-issue-102960.fR8kJa.rst new file mode 100644 index 000000000000..fec1eeb242c1 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-01-12-00-00.gh-issue-102960.fR8kJa.rst @@ -0,0 +1,2 @@ +:ref:`Frame objects ` now support :mod:`weak references +`. Patch by Łukasz Langa. diff --git a/Objects/frameobject.c b/Objects/frameobject.c index e7ac59379dcf..c50cbeaada3c 100644 --- a/Objects/frameobject.c +++ b/Objects/frameobject.c @@ -16,6 +16,7 @@ #include "pycore_optimizer.h" // _Py_Executors_InvalidateDependency() #include "pycore_tuple.h" // _PyTuple_FromPair #include "pycore_unicodeobject.h" // _PyUnicode_Equal() +#include "pycore_weakref.h" // FT_CLEAR_WEAKREFS() #include "frameobject.h" // PyFrameLocalsProxyObject #include "opcode.h" // EXTENDED_ARG @@ -1931,6 +1932,8 @@ frame_dealloc(PyObject *op) _PyObject_GC_UNTRACK(f); } + FT_CLEAR_WEAKREFS(op, f->f_weakreflist); + /* GH-106092: If f->f_frame was on the stack and we reached the maximum * nesting depth for deallocations, the trashcan may have delayed this * deallocation until after f->f_frame is freed. Avoid dereferencing @@ -2089,7 +2092,7 @@ PyTypeObject PyFrame_Type = { frame_traverse, /* tp_traverse */ frame_tp_clear, /* tp_clear */ 0, /* tp_richcompare */ - 0, /* tp_weaklistoffset */ + OFF(f_weakreflist), /* tp_weaklistoffset */ 0, /* tp_iter */ 0, /* tp_iternext */ frame_methods, /* tp_methods */ @@ -2125,6 +2128,7 @@ _PyFrame_New_NoTrack(PyCodeObject *code) f->f_extra_locals = NULL; f->f_locals_cache = NULL; f->f_overwritten_fast_locals = NULL; + f->f_weakreflist = NULL; return f; }