diff --git a/tests/test_base.py b/tests/test_base.py index 5506748d..00da8238 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -720,6 +720,60 @@ async def foo(): self.loop.run_until_complete(foo()) self.loop.run_until_complete(asyncio.sleep(0.01)) + def test_asyncgen_finalizer_hook_no_stack_capture(self): + # The finalizer hook can be invoked from a dealloc while the + # interpreter is mid-way through clearing a frame, where debug-mode + # source-traceback capture would walk a half-cleared frame and + # crash. Handles created while the suppression flag is set must not + # capture a traceback; normal handles must keep capturing. + if self.implementation != 'uvloop': + raise unittest.SkipTest('uvloop only') + + from uvloop.loop import _extract_stack_disabled + + self.loop.set_debug(True) + try: + handle = self.loop.call_soon(lambda: None) + self.assertIsNotNone(handle._source_traceback) + handle.cancel() + + _extract_stack_disabled.flag = True + try: + handle = self.loop.call_soon(lambda: None) + self.assertIsNone(handle._source_traceback) + handle.cancel() + finally: + _extract_stack_disabled.flag = False + finally: + self.loop.set_debug(False) + + def test_asyncgen_finalizer_hook_flag_reentrant(self): + # The hook must save/restore the suppression flag, not set/clear it: + # allocations inside the hook can trigger a GC that re-enters it on + # the same thread, and a clobbering reset would reopen the unsafe + # window for the outer invocation. + if self.implementation != 'uvloop': + raise unittest.SkipTest('uvloop only') + + from uvloop.loop import _extract_stack_disabled + + async def waiter(): + yield 1 + + async def make_agen(): + agen = waiter() + await agen.asend(None) + return agen + + agen = self.loop.run_until_complete(make_agen()) + _extract_stack_disabled.flag = True # simulate an outer invocation + try: + self.loop._asyncgen_finalizer_hook(agen) + self.assertTrue(_extract_stack_disabled.flag) + finally: + _extract_stack_disabled.flag = False + self.loop.run_until_complete(asyncio.sleep(0.01)) + def test_inf_wait_for(self): async def foo(): await asyncio.sleep(0.1) diff --git a/uvloop/cbhandles.pyx b/uvloop/cbhandles.pyx index 00070816..1f9e25ac 100644 --- a/uvloop/cbhandles.pyx +++ b/uvloop/cbhandles.pyx @@ -416,10 +416,23 @@ cdef new_MethodHandle3(Loop loop, str name, method3_t callback, object context, return handle +class _ExtractStackDisabled(threading_local): + # Per-thread: True while handles are created from the asyncgen finalizer + # hook, which a dealloc can invoke while CPython is mid-way through + # clearing the current frame; sys._getframe() would then dereference the + # half-cleared frame and crash the interpreter. + flag = False + + +_extract_stack_disabled = _ExtractStackDisabled() + + cdef extract_stack(): """Replacement for traceback.extract_stack() that only does the necessary work for asyncio debug mode. """ + if _extract_stack_disabled.flag: + return None try: f = sys_getframe() # sys._getframe() might raise ValueError if being called without a frame, e.g. @@ -433,6 +446,10 @@ cdef extract_stack(): stack = tb_StackSummary.extract(tb_walk_stack(f), limit=DEBUG_STACK_DEPTH, lookup_lines=False) + except (AttributeError, ValueError, TypeError): + # walk_stack can encounter non-frame objects, e.g. py3.14 surfaces + # _asyncio.TaskStepMethWrapper, which has no f_back (#715). + return None finally: f = None diff --git a/uvloop/includes/stdlib.pxi b/uvloop/includes/stdlib.pxi index 5fff4ad8..2f4f2fd1 100644 --- a/uvloop/includes/stdlib.pxi +++ b/uvloop/includes/stdlib.pxi @@ -139,6 +139,7 @@ cdef int ssl_SSL_ERROR_WANT_WRITE = ssl.SSL_ERROR_WANT_WRITE cdef int ssl_SSL_ERROR_SYSCALL = ssl.SSL_ERROR_SYSCALL cdef threading_Thread = threading.Thread +cdef threading_local = threading.local cdef threading_main_thread = threading.main_thread cdef int subprocess_PIPE = subprocess.PIPE diff --git a/uvloop/loop.pyx b/uvloop/loop.pyx index e20d956d..3a526c94 100644 --- a/uvloop/loop.pyx +++ b/uvloop/loop.pyx @@ -3195,7 +3195,16 @@ cdef class Loop: def _asyncgen_finalizer_hook(self, agen): self._asyncgens.discard(agen) if not self.is_closed(): - self.call_soon_threadsafe(self.create_task, agen.aclose()) + # Frame walking is unsafe in this hook; see + # _ExtractStackDisabled in cbhandles.pyx. Save/restore (not + # set/clear): an allocation below can trigger a GC that + # re-enters this hook on the same thread. + prev = _extract_stack_disabled.flag + _extract_stack_disabled.flag = True + try: + self.call_soon_threadsafe(self.create_task, agen.aclose()) + finally: + _extract_stack_disabled.flag = prev def _asyncgen_firstiter_hook(self, agen): if self._asyncgens_shutdown_called: