Skip to content

Commit 7d20607

Browse files
ambvclaude
andcommitted
gh-102960: Make frames weak-referenceable
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 <noreply@anthropic.com>
1 parent 51b511d commit 7d20607

7 files changed

Lines changed: 207 additions & 4 deletions

File tree

Doc/library/weakref.rst

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,15 @@ exposed by the :mod:`!weakref` module for the benefit of advanced uses.
6363
Not all objects can be weakly referenced. Objects which support weak references
6464
include class instances, functions written in Python (but not in C), instance methods,
6565
sets, frozensets, some :term:`file objects <file object>`, :term:`generators <generator>`,
66-
type objects, sockets, arrays, deques, regular expression pattern objects, and code
67-
objects.
66+
type objects, sockets, arrays, deques, regular expression pattern objects, code
67+
objects, and frame objects.
6868

6969
.. versionchanged:: 3.2
7070
Added support for thread.lock, threading.Lock, and code objects.
7171

72+
.. versionchanged:: 3.16
73+
Added support for frame objects.
74+
7275
Several built-in types such as :class:`list` and :class:`dict` do not directly
7376
support weak references but can add support through subclassing::
7477

Doc/whatsnew/3.16.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ New features
7575
Other language changes
7676
======================
7777

78+
* :ref:`Frame objects <frame-objects>` now support :mod:`weak references
79+
<weakref>`. This allows associating extra data with active frames,
80+
for example in debuggers, without keeping the frames (and everything
81+
they reference) alive indefinitely.
82+
(Contributed by Łukasz Langa in :gh:`102960`.)
7883

7984

8085
New modules

Include/internal/pycore_frame.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ struct _frame {
3434
* "support" for the borrowed references, ensuring that they remain valid.
3535
*/
3636
PyObject *f_overwritten_fast_locals;
37+
PyObject *f_weakreflist; /* List of weak references */
3738
/* The frame data, if this frame object owns the frame */
3839
PyObject *_f_frame_data[1];
3940
};

Lib/test/test_frame.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,194 @@ async def t3():
273273
raise AssertionError('coroutine did not exit')
274274

275275

276+
class WeakRefTest(unittest.TestCase):
277+
"""
278+
Frames support weak references (gh-102960).
279+
"""
280+
281+
def make_frame(self):
282+
# Return the frame object of a finished function call. Unlike
283+
# frames extracted from a traceback, it isn't part of a reference
284+
# cycle, so it dies as soon as the last reference is dropped.
285+
def func():
286+
return sys._getframe()
287+
return func()
288+
289+
def make_traceback_frames(self):
290+
def outer():
291+
def inner():
292+
1/0
293+
return inner()
294+
try:
295+
outer()
296+
except ZeroDivisionError as e:
297+
tb = e.__traceback__
298+
frames = []
299+
while tb:
300+
frames.append(tb.tb_frame)
301+
tb = tb.tb_next
302+
return frames
303+
304+
def test_weakref_basic(self):
305+
f = self.make_frame()
306+
ref = weakref.ref(f)
307+
self.assertIs(ref(), f)
308+
del f
309+
support.gc_collect()
310+
self.assertIsNone(ref())
311+
312+
def test_weakref_live_frame(self):
313+
refs = []
314+
def func():
315+
frame = sys._getframe()
316+
refs.append(weakref.ref(frame))
317+
self.assertIs(refs[0](), frame)
318+
func()
319+
support.gc_collect()
320+
self.assertIsNone(refs[0]())
321+
322+
def test_weakref_callback(self):
323+
called = []
324+
f = self.make_frame()
325+
ref = weakref.ref(f, called.append)
326+
del f
327+
support.gc_collect()
328+
self.assertEqual(called, [ref])
329+
self.assertIsNone(ref())
330+
331+
def test_weakref_proxy(self):
332+
f = self.make_frame()
333+
proxy = weakref.proxy(f)
334+
self.assertEqual(proxy.f_lineno, f.f_lineno)
335+
self.assertIs(proxy.f_code, f.f_code)
336+
del f
337+
support.gc_collect()
338+
with self.assertRaises(ReferenceError):
339+
proxy.f_lineno
340+
341+
def test_multiple_weakrefs(self):
342+
f = self.make_frame()
343+
called = []
344+
refs = [weakref.ref(f) for _ in range(3)]
345+
refs += [weakref.ref(f, called.append) for _ in range(2)]
346+
# Callback-less weakrefs to the same object are shared.
347+
self.assertIs(refs[0], refs[1])
348+
self.assertIs(refs[0], refs[2])
349+
self.assertEqual(weakref.getweakrefcount(f), 3)
350+
# Weakrefs hash and compare through their referent while it is
351+
# alive, so compare identities instead.
352+
self.assertEqual({id(r) for r in weakref.getweakrefs(f)},
353+
{id(refs[0]), id(refs[3]), id(refs[4])})
354+
del f
355+
support.gc_collect()
356+
for ref in refs:
357+
self.assertIsNone(ref())
358+
self.assertEqual({id(r) for r in called}, {id(refs[3]), id(refs[4])})
359+
360+
def test_weak_key_dictionary(self):
361+
wkd = weakref.WeakKeyDictionary()
362+
def _fill():
363+
for i, frame in enumerate(self.make_traceback_frames()):
364+
wkd[frame] = i
365+
self.assertEqual(len(wkd), 3)
366+
_fill()
367+
support.gc_collect()
368+
self.assertEqual(len(wkd), 0)
369+
370+
def test_weak_value_dictionary(self):
371+
wvd = weakref.WeakValueDictionary()
372+
def _fill():
373+
for i, frame in enumerate(self.make_traceback_frames()):
374+
wvd[i] = frame
375+
self.assertEqual(len(wvd), 3)
376+
_fill()
377+
support.gc_collect()
378+
self.assertEqual(len(wvd), 0)
379+
380+
def test_weakref_traceback_frames(self):
381+
# Frames that participate in reference cycles are cleaned up
382+
# by the cyclic garbage collector.
383+
refs = []
384+
def _make():
385+
for frame in self.make_traceback_frames():
386+
refs.append(weakref.ref(frame))
387+
for ref in refs:
388+
self.assertIsNotNone(ref())
389+
_make()
390+
support.gc_collect()
391+
for ref in refs:
392+
self.assertIsNone(ref())
393+
394+
def test_weakref_generator_frame(self):
395+
def gen():
396+
yield sys._getframe()
397+
g = gen()
398+
frame = next(g)
399+
ref = weakref.ref(frame)
400+
del frame
401+
support.gc_collect()
402+
# The generator keeps its frame alive while suspended.
403+
self.assertIsNotNone(ref())
404+
g.close()
405+
del g
406+
support.gc_collect()
407+
self.assertIsNone(ref())
408+
409+
def test_weakref_coroutine_frame(self):
410+
async def coro():
411+
return sys._getframe()
412+
c = coro()
413+
ref = None
414+
try:
415+
c.send(None)
416+
except StopIteration as ex:
417+
ref = weakref.ref(ex.value)
418+
self.assertIsNotNone(ref, 'coroutine did not exit')
419+
del c
420+
support.gc_collect()
421+
self.assertIsNone(ref())
422+
423+
def test_weakref_after_frame_clear(self):
424+
f = self.make_frame()
425+
ref = weakref.ref(f)
426+
# Clearing the frame's contents must not affect weak references
427+
# to the frame object itself.
428+
f.clear()
429+
self.assertIs(ref(), f)
430+
del f
431+
support.gc_collect()
432+
self.assertIsNone(ref())
433+
434+
@threading_helper.requires_working_threading()
435+
def test_weakref_concurrent(self):
436+
# Exercise concurrent creation and destruction of weak references
437+
# to the same frame, mainly for the free-threaded build.
438+
def gen():
439+
yield sys._getframe()
440+
g = gen()
441+
frame = next(g)
442+
barrier = threading.Barrier(4)
443+
def work():
444+
barrier.wait()
445+
for _ in range(1000):
446+
ref = weakref.ref(frame)
447+
self.assertIs(ref(), frame)
448+
# Callback refs are not shared, so this concurrently adds
449+
# to and removes from the frame's weakref list.
450+
cb_ref = weakref.ref(frame, lambda r: None)
451+
self.assertIs(cb_ref(), frame)
452+
del ref, cb_ref
453+
threads = [threading.Thread(target=work) for _ in range(4)]
454+
with threading_helper.start_threads(threads):
455+
pass
456+
ref = weakref.ref(frame)
457+
del frame
458+
g.close()
459+
del g
460+
support.gc_collect()
461+
self.assertIsNone(ref())
462+
463+
276464
class ReprTest(unittest.TestCase):
277465
"""
278466
Tests for repr(frame).

Lib/test/test_sys.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1700,7 +1700,7 @@ def func():
17001700
INTERPRETER_FRAME = '9PihcP'
17011701
else:
17021702
INTERPRETER_FRAME = '9PhcP'
1703-
check(x, size('3PiccPPP' + INTERPRETER_FRAME + 'P'))
1703+
check(x, size('3PiccPPPP' + INTERPRETER_FRAME + 'P'))
17041704
# function
17051705
def func(): pass
17061706
check(func, size('16Pi'))
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
:ref:`Frame objects <frame-objects>` now support :mod:`weak references
2+
<weakref>`. Patch by Łukasz Langa.

Objects/frameobject.c

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
#include "pycore_optimizer.h" // _Py_Executors_InvalidateDependency()
1717
#include "pycore_tuple.h" // _PyTuple_FromPair
1818
#include "pycore_unicodeobject.h" // _PyUnicode_Equal()
19+
#include "pycore_weakref.h" // FT_CLEAR_WEAKREFS()
1920

2021
#include "frameobject.h" // PyFrameLocalsProxyObject
2122
#include "opcode.h" // EXTENDED_ARG
@@ -1931,6 +1932,8 @@ frame_dealloc(PyObject *op)
19311932
_PyObject_GC_UNTRACK(f);
19321933
}
19331934

1935+
FT_CLEAR_WEAKREFS(op, f->f_weakreflist);
1936+
19341937
/* GH-106092: If f->f_frame was on the stack and we reached the maximum
19351938
* nesting depth for deallocations, the trashcan may have delayed this
19361939
* deallocation until after f->f_frame is freed. Avoid dereferencing
@@ -2089,7 +2092,7 @@ PyTypeObject PyFrame_Type = {
20892092
frame_traverse, /* tp_traverse */
20902093
frame_tp_clear, /* tp_clear */
20912094
0, /* tp_richcompare */
2092-
0, /* tp_weaklistoffset */
2095+
OFF(f_weakreflist), /* tp_weaklistoffset */
20932096
0, /* tp_iter */
20942097
0, /* tp_iternext */
20952098
frame_methods, /* tp_methods */
@@ -2125,6 +2128,7 @@ _PyFrame_New_NoTrack(PyCodeObject *code)
21252128
f->f_extra_locals = NULL;
21262129
f->f_locals_cache = NULL;
21272130
f->f_overwritten_fast_locals = NULL;
2131+
f->f_weakreflist = NULL;
21282132
return f;
21292133
}
21302134

0 commit comments

Comments
 (0)