diff --git a/Doc/library/weakref.rst b/Doc/library/weakref.rst index 952609c0e700e5e..7cc0c33fda353c4 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 1a73a79a58b78b1..ad20c3b2db4dbf9 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 3c9ab99c34ebc64..4eefbf89acb85f7 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 18ade18d1a1708c..a6b07ea4af63474 100644 --- a/Lib/test/test_frame.py +++ b/Lib/test/test_frame.py @@ -273,6 +273,204 @@ async def t3(): 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): + f = self.make_frame() + ref = weakref.ref(f) + self.assertIs(ref(), f) + del f + support.gc_collect() + self.assertIsNone(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]()) + + def test_weakref_callback(self): + called = [] + f = self.make_frame() + ref = weakref.ref(f, called.append) + del f + support.gc_collect() + self.assertEqual(called, [ref]) + self.assertIsNone(ref()) + + def test_weakref_proxy(self): + f = self.make_frame() + proxy = weakref.proxy(f) + self.assertEqual(proxy.f_lineno, f.f_lineno) + self.assertIs(proxy.f_code, f.f_code) + del f + support.gc_collect() + with self.assertRaises(ReferenceError): + proxy.f_lineno + + def test_multiple_weakrefs(self): + f = self.make_frame() + called = [] + refs = [weakref.ref(f) for _ in range(3)] + refs += [weakref.ref(f, called.append) for _ in range(2)] + # Callback-less weakrefs to the same object are shared. + self.assertIs(refs[0], refs[1]) + self.assertIs(refs[0], refs[2]) + self.assertEqual(weakref.getweakrefcount(f), 3) + # Weakrefs hash and compare through their referent while it is + # alive, so compare identities instead. + self.assertEqual({id(r) for r in weakref.getweakrefs(f)}, + {id(refs[0]), id(refs[3]), id(refs[4])}) + del f + support.gc_collect() + for ref in refs: + self.assertIsNone(ref()) + self.assertEqual({id(r) for r in called}, {id(refs[3]), id(refs[4])}) + + @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_weak_value_dictionary(self): + wvd = weakref.WeakValueDictionary() + def _fill(): + for i, frame in enumerate(self.make_traceback_frames()): + wvd[i] = frame + self.assertEqual(len(wvd), 3) + _fill() + support.gc_collect() + self.assertEqual(len(wvd), 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_coroutine_frame(self): + async def coro(): + return sys._getframe() + c = coro() + ref = None + try: + c.send(None) + except StopIteration as ex: + ref = weakref.ref(ex.value) + self.assertIsNotNone(ref, 'coroutine did not exit') + del c + 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 1773633730ea001..56c5c2aa2025b9a 100644 --- a/Lib/test/test_sys.py +++ b/Lib/test/test_sys.py @@ -1700,7 +1700,7 @@ def func(): 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 000000000000000..fec1eeb242c1ed6 --- /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 e7ac59379dcfbcc..c50cbeaada3c406 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; }