Skip to content

Commit f5a728e

Browse files
authored
Merge pull request #511 from kumaraditya303/ft-fix
Fix free-threading crash from untraversed deferred references in suspended greenlets
2 parents 1ddfce0 + 1eb14a1 commit f5a728e

6 files changed

Lines changed: 114 additions & 65 deletions

File tree

.github/workflows/tests.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,8 @@ jobs:
130130
# standalone in a unit test doesn't produce the error.
131131
#
132132
# So this is a temporary workaround.
133-
PYTHON_TLBC: "0"
133+
# This has been fixed for 3.15+
134+
PYTHON_TLBC: ${{ matrix.python-version == '3.14t' && '0' || '1' }}
134135
run: |
135136
sphinx-build -b doctest -d docs/_build/doctrees2 docs docs/_build/doctest2
136137
- name: Lint

docs/greenlet_gc.rst

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -164,53 +164,3 @@ frames and cycles can be freed:
164164
Collecting garbage
165165
(Running finalizer)
166166
(Running finalizer)
167-
168-
A Cycle Of Greenlets Is A Leak
169-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
170-
171-
What if we introduce a cycle among the greenlets themselves while also
172-
leaving a greenlet suspended? Here, the frames of the ``inner``
173-
greenlet refer to the ``outer`` (as the ``inner`` greenlet itself
174-
does), and both the frames of the ``outer``, as well as the ``outer``
175-
greenlet itself, refer to the ``inner``:
176-
177-
.. doctest::
178-
:pyversion: >= 3.5
179-
180-
>>> def inner():
181-
... cycle1 = Cycle()
182-
... cycle2 = Cycle()
183-
... cycle1.cycle = cycle2
184-
... cycle2.cycle = cycle1
185-
... parent = getcurrent().parent
186-
... parent.switch()
187-
>>> def outer():
188-
... glet = greenlet(inner)
189-
... getcurrent().child_greenlet = glet
190-
... glet.switch()
191-
... collect_it()
192-
193-
This time, even letting the outer and inner greenlets die doesn't find
194-
the cycle hidden in the inner greenlet's frame:
195-
196-
.. doctest::
197-
:pyversion: >= 3.5
198-
199-
>>> outer_glet = greenlet(outer)
200-
>>> outer_glet.switch()
201-
Collecting garbage
202-
>>> outer_glet.dead
203-
True
204-
>>> collect_it()
205-
Collecting garbage
206-
207-
Even explicitly deleting the outer greenlet doesn't find and clear the
208-
cycle; we have created a legitimate memory leak, not just of the
209-
greenlet objects, but also the objects in any suspended frames:
210-
211-
.. doctest::
212-
:pyversion: >= 3.5
213-
214-
>>> del outer_glet
215-
>>> collect_it()
216-
Collecting garbage

src/greenlet/TGreenlet.hpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ using greenlet::refs::BorrowedGreenlet;
4242
#endif
4343
#endif
4444

45+
46+
#if GREENLET_PY315
47+
#include "internal/pycore_gc.h"
48+
#if !defined(_MSC_VER) && !defined(__MINGW64__)
49+
#include "internal/pycore_stackref.h"
50+
#endif
51+
#endif
52+
4553
// XXX: TODO: Work to remove all virtual functions
4654
// for speed of calling and size of objects (no vtable).
4755
// One pattern is the Curiously Recurring Template

src/greenlet/TPythonState.cpp

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -348,21 +348,38 @@ void PythonState::set_initial_state(const PyThreadState* const tstate) noexcept
348348
#endif
349349
}
350350
// TODO: Better state management about when we own the top frame.
351-
int PythonState::tp_traverse(visitproc visit, void* arg, bool own_top_frame) noexcept
351+
int PythonState::tp_traverse(visitproc visit, void* arg, bool visit_top_frame) noexcept
352352
{
353353
Py_VISIT(this->_context.borrow());
354-
if (own_top_frame) {
354+
if (visit_top_frame) {
355355
Py_VISIT(this->_top_frame.borrow());
356356
}
357-
#if GREENLET_PY314
358-
// TODO: Should we be visiting the c_stack_refs objects?
359-
// CPython uses a specific macro to do that which takes into
360-
// account boxing and null values and then calls
361-
// ``_PyGC_VisitStackRef``, but we don't have access to that, and
362-
// we can't duplicate it ourself (because it compares
363-
// ``visitproc`` to another function we can't access).
364-
// The naive way of looping over c_stack_refs->ref and visiting
365-
// those crashes the process (at least with GIL disabled).
357+
#if GREENLET_PY315
358+
// Visit the references held by our suspended frames.
359+
// This is important specially on free-threading where the
360+
// the suspended frames may contain deferred references to
361+
// objects, and if they are not traversed then the interpreter
362+
// can free objects early causing a use-after-free crash
363+
// at runtime exit.
364+
if (this->_top_frame) {
365+
for (_PyInterpreterFrame* iframe = this->_top_frame->f_frame;
366+
iframe != nullptr; iframe = iframe->previous) {
367+
// Skip generator/coroutine frames; their object's traverse
368+
// already visits them (gen_traverse), so we'd double-count.
369+
// expose_frames leaves them in the ->previous chain.
370+
if (iframe->owner != FRAME_OWNED_BY_THREAD) {
371+
continue;
372+
}
373+
Py_VISIT(iframe->frame_obj);
374+
Py_VISIT(iframe->f_locals);
375+
_Py_VISIT_STACKREF(iframe->f_funcobj);
376+
_Py_VISIT_STACKREF(iframe->f_executable);
377+
int frame_result = _PyGC_VisitFrameStack(iframe, visit, arg);
378+
if (frame_result) {
379+
return frame_result;
380+
}
381+
}
382+
}
366383
#endif
367384
// Note that we DO NOT visit ``delete_later``. Even if it's
368385
// non-null and we technically own a reference to it, its

src/greenlet/greenlet_msvc_compat.hpp

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,34 @@ _PyCode_GetTLBCArray(PyCodeObject *co)
5050
#else
5151
#define Py_TAG_BITS ((uintptr_t)1)
5252
#define Py_TAG_DEFERRED (1)
53+
#define Py_INT_TAG 3
5354
#endif
5455

5556

5657
static const _PyStackRef PyStackRef_NULL = { .bits = Py_TAG_DEFERRED};
5758
#define PyStackRef_IsNull(stackref) ((stackref).bits == PyStackRef_NULL.bits)
5859

60+
static inline bool
61+
PyStackRef_IsTaggedInt(_PyStackRef i)
62+
{
63+
return (i.bits & Py_INT_TAG) == Py_INT_TAG;
64+
}
65+
66+
static inline bool
67+
PyStackRef_IsNullOrInt(_PyStackRef stackref)
68+
{
69+
return PyStackRef_IsNull(stackref) || PyStackRef_IsTaggedInt(stackref);
70+
}
71+
72+
#define _Py_VISIT_STACKREF(ref) \
73+
do { \
74+
if (!PyStackRef_IsNullOrInt(ref)) { \
75+
int vret = _PyGC_VisitStackRef(&(ref), visit, arg); \
76+
if (vret) \
77+
return vret; \
78+
} \
79+
} while (0)
80+
5981
static inline PyObject *
6082
PyStackRef_AsPyObjectBorrow(_PyStackRef stackref)
6183
{
@@ -64,7 +86,7 @@ PyStackRef_AsPyObjectBorrow(_PyStackRef stackref)
6486
}
6587

6688
static inline PyCodeObject *_PyFrame_GetCode(_PyInterpreterFrame *f) {
67-
assert(!PyStackRef_IsNull(f->f_executable));
89+
assert(!PyStackRef_IsNullOrInt(f->f_executable));
6890
PyObject *executable = PyStackRef_AsPyObjectBorrow(f->f_executable);
6991
assert(PyCode_Check(executable));
7092
return (PyCodeObject *)executable;

src/greenlet/tests/test_gc.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import gc
22

33
import weakref
4-
4+
import sys
55
import greenlet
66

77

@@ -16,7 +16,6 @@ def test_dead_circular_ref(self):
1616
o = weakref.ref(greenlet.greenlet(greenlet.getcurrent).switch())
1717
gc.collect()
1818
if o() is not None:
19-
import sys
2019
print("O IS NOT NONE.", sys.getrefcount(o()))
2120
self.assertIsNone(o())
2221
self.assertFalse(gc.garbage, gc.garbage)
@@ -84,3 +83,55 @@ def greenlet_body():
8483
del g
8584
greenlet.getcurrent()
8685
gc.collect()
86+
87+
def test_crashing_deferred_object(self):
88+
if sys.version_info < (3, 15):
89+
self.skipTest("Test is 3.15+ only")
90+
import doctest
91+
def with_doctest():
92+
"""
93+
>>> import gc
94+
>>> from greenlet import getcurrent, greenlet, GreenletExit
95+
>>> def outer():
96+
... gc.collect()
97+
>>> outer_glet = greenlet(outer)
98+
>>> outer_glet.switch()
99+
"""
100+
doctest.run_docstring_examples(with_doctest, dict())
101+
102+
def test_cycle_in_suspended_frame(self):
103+
if sys.version_info < (3, 15):
104+
self.skipTest("Test is 3.15+ only")
105+
import doctest
106+
def with_doctest():
107+
"""
108+
>>> import gc
109+
>>> from greenlet import getcurrent, greenlet
110+
>>> class Cycle:
111+
... def __del__(self):
112+
... print("(Running finalizer)")
113+
>>> def collect_it():
114+
... print("Collecting garbage")
115+
... gc.collect()
116+
>>> def inner():
117+
... cycle1 = Cycle()
118+
... cycle2 = Cycle()
119+
... cycle1.cycle = cycle2
120+
... cycle2.cycle = cycle1
121+
... getcurrent().parent.switch()
122+
>>> def outer():
123+
... glet = greenlet(inner)
124+
... glet.switch()
125+
... collect_it()
126+
127+
>>> outer_glet = greenlet(outer)
128+
>>> outer_glet.switch()
129+
Collecting garbage
130+
>>> outer_glet.dead
131+
True
132+
>>> collect_it()
133+
Collecting garbage
134+
(Running finalizer)
135+
(Running finalizer)
136+
"""
137+
doctest.run_docstring_examples(with_doctest, dict())

0 commit comments

Comments
 (0)