Skip to content

Commit 3f175ad

Browse files
David Sacerdotedavid-runlayer
authored andcommitted
Fix debug-mode segfault when the asyncgen finalizer hook runs during frame teardown
The asyncgen finalizer hook can be invoked by a dealloc while CPython is mid-way through clearing the current interpreter frame (_PyEval_FrameClearAndPop -> PyObject_CallFinalizerFromDealloc -> _asyncgen_finalizer_hook). In debug mode the hook's call_soon_threadsafe creates a Handle whose source-traceback capture calls sys._getframe(), which dereferences the half-cleared frame (garbage frame_obj) and segfaults the interpreter. Observed in production on CPython 3.13.14 under streaming HTTP load (starlette request.stream() async generators being GC'd under cancellation churn): worker SIGSEGV within seconds of enabling loop.set_debug(True). Reproduces on 3.13 and 3.14, x86_64 and aarch64. Fix: skip source-traceback capture (per-thread flag) for handles created from the finalizer hook -- the traceback of a GC point is meaningless for debugging anyway -- and make extract_stack() tolerate AttributeError/ ValueError/TypeError from walk_stack, which py3.14 can raise when non-frame objects (e.g. _asyncio.TaskStepMethWrapper) appear in the stack (#715).
1 parent e8efea4 commit 3f175ad

4 files changed

Lines changed: 82 additions & 1 deletion

File tree

tests/test_base.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,60 @@ async def foo():
720720
self.loop.run_until_complete(foo())
721721
self.loop.run_until_complete(asyncio.sleep(0.01))
722722

723+
def test_asyncgen_finalizer_hook_no_stack_capture(self):
724+
# The finalizer hook can be invoked from a dealloc while the
725+
# interpreter is mid-way through clearing a frame, where debug-mode
726+
# source-traceback capture would walk a half-cleared frame and
727+
# crash. Handles created while the suppression flag is set must not
728+
# capture a traceback; normal handles must keep capturing.
729+
if self.implementation != 'uvloop':
730+
raise unittest.SkipTest('uvloop only')
731+
732+
from uvloop.loop import _extract_stack_disabled
733+
734+
self.loop.set_debug(True)
735+
try:
736+
handle = self.loop.call_soon(lambda: None)
737+
self.assertIsNotNone(handle._source_traceback)
738+
handle.cancel()
739+
740+
_extract_stack_disabled.flag = True
741+
try:
742+
handle = self.loop.call_soon(lambda: None)
743+
self.assertIsNone(handle._source_traceback)
744+
handle.cancel()
745+
finally:
746+
_extract_stack_disabled.flag = False
747+
finally:
748+
self.loop.set_debug(False)
749+
750+
def test_asyncgen_finalizer_hook_flag_reentrant(self):
751+
# The hook must save/restore the suppression flag, not set/clear it:
752+
# allocations inside the hook can trigger a GC that re-enters it on
753+
# the same thread, and a clobbering reset would reopen the unsafe
754+
# window for the outer invocation.
755+
if self.implementation != 'uvloop':
756+
raise unittest.SkipTest('uvloop only')
757+
758+
from uvloop.loop import _extract_stack_disabled
759+
760+
async def waiter():
761+
yield 1
762+
763+
async def make_agen():
764+
agen = waiter()
765+
await agen.asend(None)
766+
return agen
767+
768+
agen = self.loop.run_until_complete(make_agen())
769+
_extract_stack_disabled.flag = True # simulate an outer invocation
770+
try:
771+
self.loop._asyncgen_finalizer_hook(agen)
772+
self.assertTrue(_extract_stack_disabled.flag)
773+
finally:
774+
_extract_stack_disabled.flag = False
775+
self.loop.run_until_complete(asyncio.sleep(0.01))
776+
723777
def test_inf_wait_for(self):
724778
async def foo():
725779
await asyncio.sleep(0.1)

uvloop/cbhandles.pyx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,10 +416,23 @@ cdef new_MethodHandle3(Loop loop, str name, method3_t callback, object context,
416416
return handle
417417

418418

419+
class _ExtractStackDisabled(threading_local):
420+
# Per-thread: True while handles are created from the asyncgen finalizer
421+
# hook, which a dealloc can invoke while CPython is mid-way through
422+
# clearing the current frame; sys._getframe() would then dereference the
423+
# half-cleared frame and crash the interpreter.
424+
flag = False
425+
426+
427+
_extract_stack_disabled = _ExtractStackDisabled()
428+
429+
419430
cdef extract_stack():
420431
"""Replacement for traceback.extract_stack() that only does the
421432
necessary work for asyncio debug mode.
422433
"""
434+
if _extract_stack_disabled.flag:
435+
return None
423436
try:
424437
f = sys_getframe()
425438
# sys._getframe() might raise ValueError if being called without a frame, e.g.
@@ -433,6 +446,10 @@ cdef extract_stack():
433446
stack = tb_StackSummary.extract(tb_walk_stack(f),
434447
limit=DEBUG_STACK_DEPTH,
435448
lookup_lines=False)
449+
except (AttributeError, ValueError, TypeError):
450+
# walk_stack can encounter non-frame objects, e.g. py3.14 surfaces
451+
# _asyncio.TaskStepMethWrapper, which has no f_back (#715).
452+
return None
436453
finally:
437454
f = None
438455

uvloop/includes/stdlib.pxi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ cdef int ssl_SSL_ERROR_WANT_WRITE = ssl.SSL_ERROR_WANT_WRITE
139139
cdef int ssl_SSL_ERROR_SYSCALL = ssl.SSL_ERROR_SYSCALL
140140

141141
cdef threading_Thread = threading.Thread
142+
cdef threading_local = threading.local
142143
cdef threading_main_thread = threading.main_thread
143144

144145
cdef int subprocess_PIPE = subprocess.PIPE

uvloop/loop.pyx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3195,7 +3195,16 @@ cdef class Loop:
31953195
def _asyncgen_finalizer_hook(self, agen):
31963196
self._asyncgens.discard(agen)
31973197
if not self.is_closed():
3198-
self.call_soon_threadsafe(self.create_task, agen.aclose())
3198+
# Frame walking is unsafe in this hook; see
3199+
# _ExtractStackDisabled in cbhandles.pyx. Save/restore (not
3200+
# set/clear): an allocation below can trigger a GC that
3201+
# re-enters this hook on the same thread.
3202+
prev = _extract_stack_disabled.flag
3203+
_extract_stack_disabled.flag = True
3204+
try:
3205+
self.call_soon_threadsafe(self.create_task, agen.aclose())
3206+
finally:
3207+
_extract_stack_disabled.flag = prev
31993208

32003209
def _asyncgen_firstiter_hook(self, agen):
32013210
if self._asyncgens_shutdown_called:

0 commit comments

Comments
 (0)