From c68d26bdf5f11a6c9a11aa860132dc425599517b Mon Sep 17 00:00:00 2001 From: Timofey Ivankov Date: Sat, 17 Oct 2026 15:24:44 +0300 Subject: [PATCH 1/5] gh-159620: Fix remote asyncio stacks stopping at async generators --- Lib/test/test_external_inspection.py | 134 ++++++++++++++++++ ...-10-17-15-22-16.gh-issue-159620.Ynsu5L.rst | 2 + Modules/_remote_debugging/asyncio.c | 96 +++++++++---- Objects/genobject.c | 6 + 4 files changed, 208 insertions(+), 30 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-17-15-22-16.gh-issue-159620.Ynsu5L.rst diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index 9fff8e8bff91f7..3a6a5069d8b211 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -508,6 +508,140 @@ async def rec(n): self.assertEqual(asyncio.run(rec(3)), ["rec"] * 4) + @skip_if_not_supported + def test_async_generator_stack_is_not_truncated(self): + # gh-159620 + async def agen(): + await asyncio.sleep(10_000) + yield + + async def agen_with_cleanup(): + try: + yield + finally: + await asyncio.sleep(10_000) + + async def outer_agen(): + async for _ in agen(): + yield + + async def consumer(): + async for _ in agen(): + pass + + async def nested_consumer(): + async for _ in outer_agen(): + pass + + async def main(): + g = agen_with_cleanup() + await anext(g) + async_for_task = asyncio.create_task(consumer(), name="async for") + nested_task = asyncio.create_task( + nested_consumer(), name="nested async for") + anext_task = asyncio.create_task(anext(agen()), name="anext") + aclose_task = asyncio.create_task(g.aclose(), name="aclose") + await asyncio.sleep(0) + return { + task.task_name: [ + frame.funcname.rpartition(".")[2] + for coro in task.coroutine_stack + for frame in coro.call_stack + ] + for info in RemoteUnwinder(os.getpid()).get_all_awaited_by() + for task in info.awaited_by + } + + stacks = asyncio.run(main()) + self.assertEqual(stacks["async for"], ["sleep", "agen", "consumer"]) + self.assertEqual(stacks["nested async for"], + ["sleep", "agen", "outer_agen", "nested_consumer"]) + self.assertEqual(stacks["anext"], ["sleep", "agen"]) + self.assertEqual(stacks["aclose"], ["sleep", "agen_with_cleanup"]) + + @skip_if_not_supported + def test_async_generator_waiter_in_async_stack_trace(self): + # gh-159620 + async def agen(task): + await task + yield + + async def main(): + waiter_task = asyncio.create_task( + anext(agen(asyncio.current_task())), name="anext") + await asyncio.sleep(0) + return { + task.task_name: [ + frame.funcname.rpartition(".")[2] + for coro in task.coroutine_stack + for frame in coro.call_stack + ] + for info in RemoteUnwinder(os.getpid()).get_async_stack_trace() + for task in info.awaited_by + } + + stacks = asyncio.run(main()) + self.assertEqual(stacks["anext"], ["agen"]) + + @skip_if_not_supported + def test_async_generator_running_task_stack(self): + # gh-159620 + async def agen(): + yield { + task.task_name: [ + frame.funcname.rpartition(".")[2] + for coro in task.coroutine_stack + for frame in coro.call_stack + ] + for info in RemoteUnwinder(os.getpid()).get_async_stack_trace() + for task in info.awaited_by + } + + async def main(): + return await asyncio.create_task(anext(agen()), name="anext") + + stacks = asyncio.run(main()) + self.assertEqual(stacks["anext"], ["agen"]) + + @skip_if_not_supported + def test_class_named_like_async_generator_wrapper_is_not_walked(self): + # gh-159620 + async def decoy(): + await asyncio.sleep(10_000) + + class async_generator_asend: + __slots__ = ("gen",) + def __init__(self, gen): + self.gen = gen + def __await__(self): + return self + def __next__(self): + return None + + async def consumer(aw): + await aw + + async def main(): + gen = decoy() + consumer_task = asyncio.create_task( + consumer(async_generator_asend(gen)), name="consumer") + await asyncio.sleep(0) + try: + return { + task.task_name: [ + frame.funcname.rpartition(".")[2] + for coro in task.coroutine_stack + for frame in coro.call_stack + ] + for info in RemoteUnwinder(os.getpid()).get_all_awaited_by() + for task in info.awaited_by + } + finally: + gen.close() + + stacks = asyncio.run(main()) + self.assertEqual(stacks["consumer"], ["consumer"]) + @skip_if_not_supported @unittest.skipIf( sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, diff --git a/Misc/NEWS.d/next/Library/2026-10-17-15-22-16.gh-issue-159620.Ynsu5L.rst b/Misc/NEWS.d/next/Library/2026-10-17-15-22-16.gh-issue-159620.Ynsu5L.rst new file mode 100644 index 00000000000000..71d7ad55f258f5 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-17-15-22-16.gh-issue-159620.Ynsu5L.rst @@ -0,0 +1,2 @@ +Fix python -m asyncio ps, pstree and async-aware :mod:profiling.sampling +stacks stopping at asynchronous generators. Patch by Timofei Ivankov diff --git a/Modules/_remote_debugging/asyncio.c b/Modules/_remote_debugging/asyncio.c index c8d79774a47a73..5f7a7fd5163565 100644 --- a/Modules/_remote_debugging/asyncio.c +++ b/Modules/_remote_debugging/asyncio.c @@ -240,11 +240,63 @@ parse_task_name( * COROUTINE CHAIN PARSING * ============================================================================ */ +// gh-159620 +/* Return in *coro_addr the coroutine or async generator to walk for the + awaitable at addr, or 0 if it is neither (for example, a future). */ +static int +get_coro_like_address( + RemoteUnwinderObject *unwinder, + uintptr_t addr, + uintptr_t *coro_addr +) { + *coro_addr = 0; + + // Skip user classes that share a name with the generator types below + uintptr_t type_addr; + unsigned long tp_flags; + if (read_ptr(unwinder, addr + (uintptr_t)unwinder->debug_offsets.pyobject.ob_type, + &type_addr) < 0 + || _Py_RemoteDebug_PagedReadRemoteMemory(&unwinder->handle, + type_addr + (uintptr_t)unwinder->debug_offsets.type_object.tp_flags, + sizeof(tp_flags), &tp_flags) < 0) + { + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read awaitable type"); + return -1; + } + + if (tp_flags & Py_TPFLAGS_HEAPTYPE) { + return 0; + } + + uintptr_t name_addr; + char name[32] = {0}; + if (read_ptr(unwinder, type_addr + (uintptr_t)unwinder->debug_offsets.type_object.tp_name, + &name_addr) < 0 + || _Py_RemoteDebug_PagedReadRemoteMemory(&unwinder->handle, name_addr, + sizeof(name) - 1, name) < 0) + { + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read awaitable type name"); + return -1; + } + + if (strcmp(name, "coroutine") == 0) { + *coro_addr = addr; + } + else if (strcmp(name, "async_generator_asend") == 0 + || strcmp(name, "async_generator_athrow") == 0) + { + // asend and athrow are separate wrapper objects: read the pointer + // to their parent async generator, stored right after PyObject_HEAD + return read_ptr(unwinder, addr + (uintptr_t)unwinder->debug_offsets.pyobject.size, + coro_addr); + } + return 0; +} + static int get_awaited_coro_address( RemoteUnwinderObject *unwinder, uintptr_t gi_iframe_addr, - uintptr_t gen_type_addr, uintptr_t *next_coro ) { *next_coro = 0; @@ -282,31 +334,7 @@ get_awaited_coro_address( return -1; } - if ((void*)gi_await_addr != NULL) { - uintptr_t gi_await_addr_type_addr; - err = read_ptr( - unwinder, - gi_await_addr + (uintptr_t)unwinder->debug_offsets.pyobject.ob_type, - &gi_await_addr_type_addr); - if (err) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read gi_await type address"); - return -1; - } - - if (gen_type_addr == gi_await_addr_type_addr) { - /* This needs an explanation. We always start with parsing - native coroutine / generator frames. Ultimately they - are awaiting on something. That something can be - a native coroutine frame or... an iterator. - If it's the latter -- we can't continue building - our chain. So the condition to bail out of this is - to do that when the type of the current coroutine - doesn't match the type of whatever it points to - in its cr_await. - */ - *next_coro = gi_await_addr; - } - } + *next_coro = gi_await_addr; } return 0; @@ -329,6 +357,13 @@ parse_coro_chain( return -1; } + if (get_coro_like_address(unwinder, coro_address, &coro_address) < 0) { + return -1; + } + if ((void*)coro_address == NULL) { + return 0; + } + // Read the entire generator object at once char gen_object[SIZEOF_GEN_OBJ]; int err = _Py_RemoteDebug_PagedReadRemoteMemory( @@ -346,8 +381,6 @@ parse_coro_chain( return 0; } - uintptr_t gen_type_addr = GET_MEMBER(uintptr_t, gen_object, unwinder->debug_offsets.pyobject.ob_type); - PyObject* name = NULL; // Parse the previous frame using the gi_iframe from local copy @@ -374,8 +407,7 @@ parse_coro_chain( return 0; } - if (get_awaited_coro_address(unwinder, gi_iframe_addr, gen_type_addr, - &coro_address) < 0) { + if (get_awaited_coro_address(unwinder, gi_iframe_addr, &coro_address) < 0) { return -1; } } @@ -733,6 +765,10 @@ get_task_frame(RemoteUnwinderObject *unwinder, uintptr_t task_addr, uintptr_t *f return -1; } + if (get_coro_like_address(unwinder, running_coro_addr, &running_coro_addr) < 0) { + return -1; + } + *frame_addr = running_coro_addr + (uintptr_t)unwinder->debug_offsets.gen_object.gi_iframe; return 0; } diff --git a/Objects/genobject.c b/Objects/genobject.c index c313002c723e31..d53fcff6a6724c 100644 --- a/Objects/genobject.c +++ b/Objects/genobject.c @@ -1682,6 +1682,12 @@ typedef struct PyAsyncGenAThrow { int8_t agt_state; } PyAsyncGenAThrow; +// _remote_debugging reads the generator right after PyObject_HEAD. +static_assert(offsetof(PyAsyncGenASend, ags_gen) == sizeof(PyObject), + "ags_gen must immediately follow PyObject_HEAD"); +static_assert(offsetof(PyAsyncGenAThrow, agt_gen) == sizeof(PyObject), + "agt_gen must immediately follow PyObject_HEAD"); + typedef struct _PyAsyncGenWrappedValue { PyObject_HEAD From 6ac14a0012064c97fe4ddb7885bcb828ad5f359e Mon Sep 17 00:00:00 2001 From: Timofey Ivankov Date: Sat, 17 Oct 2026 15:36:25 +0300 Subject: [PATCH 2/5] fix lint --- .../Library/2026-10-17-15-22-16.gh-issue-159620.Ynsu5L.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Misc/NEWS.d/next/Library/2026-10-17-15-22-16.gh-issue-159620.Ynsu5L.rst b/Misc/NEWS.d/next/Library/2026-10-17-15-22-16.gh-issue-159620.Ynsu5L.rst index 71d7ad55f258f5..2cbc3461cfaa7d 100644 --- a/Misc/NEWS.d/next/Library/2026-10-17-15-22-16.gh-issue-159620.Ynsu5L.rst +++ b/Misc/NEWS.d/next/Library/2026-10-17-15-22-16.gh-issue-159620.Ynsu5L.rst @@ -1,2 +1,3 @@ -Fix python -m asyncio ps, pstree and async-aware :mod:profiling.sampling -stacks stopping at asynchronous generators. Patch by Timofei Ivankov +Fix ``python -m asyncio ps``, ``pstree`` and async-aware +:mod:`profiling.sampling` stacks stopping at asynchronous generators. +Patch by Timofei Ivankov. From b257667f57b6db8e15f8d721faa3837df060b08f Mon Sep 17 00:00:00 2001 From: Timofey Ivankov Date: Sat, 17 Oct 2026 16:24:58 +0300 Subject: [PATCH 3/5] move static asserts --- Objects/genobject.c | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Objects/genobject.c b/Objects/genobject.c index d53fcff6a6724c..a37cd4bee0c146 100644 --- a/Objects/genobject.c +++ b/Objects/genobject.c @@ -1682,12 +1682,6 @@ typedef struct PyAsyncGenAThrow { int8_t agt_state; } PyAsyncGenAThrow; -// _remote_debugging reads the generator right after PyObject_HEAD. -static_assert(offsetof(PyAsyncGenASend, ags_gen) == sizeof(PyObject), - "ags_gen must immediately follow PyObject_HEAD"); -static_assert(offsetof(PyAsyncGenAThrow, agt_gen) == sizeof(PyObject), - "agt_gen must immediately follow PyObject_HEAD"); - typedef struct _PyAsyncGenWrappedValue { PyObject_HEAD @@ -2246,6 +2240,9 @@ async_gen_asend_new(PyAsyncGenObject *gen, PyObject *sendval) } } + // _remote_debugging reads the generator right after PyObject_HEAD. + static_assert(offsetof(PyAsyncGenASend, ags_gen) == sizeof(PyObject), + "ags_gen must immediately follow PyObject_HEAD"); ags->ags_gen = (PyAsyncGenObject*)Py_NewRef(gen); ags->ags_sendval = Py_XNewRef(sendval); ags->ags_state = AWAITABLE_STATE_INIT; @@ -2716,6 +2713,9 @@ async_gen_athrow_new(PyAsyncGenObject *gen, PyObject *args) if (o == NULL) { return NULL; } + // _remote_debugging reads the generator right after PyObject_HEAD. + static_assert(offsetof(PyAsyncGenAThrow, agt_gen) == sizeof(PyObject), + "agt_gen must immediately follow PyObject_HEAD"); o->agt_gen = (PyAsyncGenObject*)Py_NewRef(gen); o->agt_typ = Py_XNewRef(typ); o->agt_tb = Py_XNewRef(tb); From dba24f8adb4cc1395b7213b69c42ea44b21755cb Mon Sep 17 00:00:00 2001 From: Timofey Ivankov Date: Sat, 17 Oct 2026 16:36:04 +0300 Subject: [PATCH 4/5] remove static asserts --- Objects/genobject.c | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Objects/genobject.c b/Objects/genobject.c index a37cd4bee0c146..c313002c723e31 100644 --- a/Objects/genobject.c +++ b/Objects/genobject.c @@ -2240,9 +2240,6 @@ async_gen_asend_new(PyAsyncGenObject *gen, PyObject *sendval) } } - // _remote_debugging reads the generator right after PyObject_HEAD. - static_assert(offsetof(PyAsyncGenASend, ags_gen) == sizeof(PyObject), - "ags_gen must immediately follow PyObject_HEAD"); ags->ags_gen = (PyAsyncGenObject*)Py_NewRef(gen); ags->ags_sendval = Py_XNewRef(sendval); ags->ags_state = AWAITABLE_STATE_INIT; @@ -2713,9 +2710,6 @@ async_gen_athrow_new(PyAsyncGenObject *gen, PyObject *args) if (o == NULL) { return NULL; } - // _remote_debugging reads the generator right after PyObject_HEAD. - static_assert(offsetof(PyAsyncGenAThrow, agt_gen) == sizeof(PyObject), - "agt_gen must immediately follow PyObject_HEAD"); o->agt_gen = (PyAsyncGenObject*)Py_NewRef(gen); o->agt_typ = Py_XNewRef(typ); o->agt_tb = Py_XNewRef(tb); From 168848d2ba3d68c15c96197d62d4839b4e7dd819 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Sat, 17 Oct 2026 23:16:06 +0100 Subject: [PATCH 5/5] fixup! gh-159620: Fix remote asyncio stacks stopping at async generators --- Lib/test/test_external_inspection.py | 84 ++++++++++------------------ Modules/_remote_debugging/asyncio.c | 5 +- 2 files changed, 29 insertions(+), 60 deletions(-) diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index 3a6a5069d8b211..d6243c3fc818ea 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -475,6 +475,17 @@ def _extract_coroutine_stacks_lineno_only(self, stack_trace): @requires_remote_subprocess_debugging() class TestSelfStackTrace(RemoteInspectionTestBase): + def _extract_coroutine_funcnames(self, trace): + return { + task.task_name: [ + frame.funcname.rpartition(".")[2] + for coro in task.coroutine_stack + for frame in coro.call_stack + ] + for info in trace + for task in info.awaited_by + } + @skip_if_not_supported def test_long_task_name_is_truncated(self): # gh-157788 @@ -525,37 +536,25 @@ async def outer_agen(): async for _ in agen(): yield - async def consumer(): - async for _ in agen(): - pass - - async def nested_consumer(): - async for _ in outer_agen(): + async def consumer(gen): + async for _ in gen: pass async def main(): g = agen_with_cleanup() await anext(g) - async_for_task = asyncio.create_task(consumer(), name="async for") + async_for_task = asyncio.create_task(consumer(agen()), name="async for") nested_task = asyncio.create_task( - nested_consumer(), name="nested async for") + consumer(outer_agen()), name="nested async for") anext_task = asyncio.create_task(anext(agen()), name="anext") aclose_task = asyncio.create_task(g.aclose(), name="aclose") await asyncio.sleep(0) - return { - task.task_name: [ - frame.funcname.rpartition(".")[2] - for coro in task.coroutine_stack - for frame in coro.call_stack - ] - for info in RemoteUnwinder(os.getpid()).get_all_awaited_by() - for task in info.awaited_by - } + return RemoteUnwinder(os.getpid()).get_all_awaited_by() - stacks = asyncio.run(main()) + stacks = self._extract_coroutine_funcnames(asyncio.run(main())) self.assertEqual(stacks["async for"], ["sleep", "agen", "consumer"]) self.assertEqual(stacks["nested async for"], - ["sleep", "agen", "outer_agen", "nested_consumer"]) + ["sleep", "agen", "outer_agen", "consumer"]) self.assertEqual(stacks["anext"], ["sleep", "agen"]) self.assertEqual(stacks["aclose"], ["sleep", "agen_with_cleanup"]) @@ -570,37 +569,21 @@ async def main(): waiter_task = asyncio.create_task( anext(agen(asyncio.current_task())), name="anext") await asyncio.sleep(0) - return { - task.task_name: [ - frame.funcname.rpartition(".")[2] - for coro in task.coroutine_stack - for frame in coro.call_stack - ] - for info in RemoteUnwinder(os.getpid()).get_async_stack_trace() - for task in info.awaited_by - } + return RemoteUnwinder(os.getpid()).get_async_stack_trace() - stacks = asyncio.run(main()) + stacks = self._extract_coroutine_funcnames(asyncio.run(main())) self.assertEqual(stacks["anext"], ["agen"]) @skip_if_not_supported def test_async_generator_running_task_stack(self): # gh-159620 async def agen(): - yield { - task.task_name: [ - frame.funcname.rpartition(".")[2] - for coro in task.coroutine_stack - for frame in coro.call_stack - ] - for info in RemoteUnwinder(os.getpid()).get_async_stack_trace() - for task in info.awaited_by - } + yield RemoteUnwinder(os.getpid()).get_async_stack_trace() async def main(): return await asyncio.create_task(anext(agen()), name="anext") - stacks = asyncio.run(main()) + stacks = self._extract_coroutine_funcnames(asyncio.run(main())) self.assertEqual(stacks["anext"], ["agen"]) @skip_if_not_supported @@ -622,24 +605,13 @@ async def consumer(aw): await aw async def main(): - gen = decoy() - consumer_task = asyncio.create_task( - consumer(async_generator_asend(gen)), name="consumer") - await asyncio.sleep(0) - try: - return { - task.task_name: [ - frame.funcname.rpartition(".")[2] - for coro in task.coroutine_stack - for frame in coro.call_stack - ] - for info in RemoteUnwinder(os.getpid()).get_all_awaited_by() - for task in info.awaited_by - } - finally: - gen.close() + with contextlib.closing(decoy()) as gen: + consumer_task = asyncio.create_task( + consumer(async_generator_asend(gen)), name="consumer") + await asyncio.sleep(0) + return RemoteUnwinder(os.getpid()).get_all_awaited_by() - stacks = asyncio.run(main()) + stacks = self._extract_coroutine_funcnames(asyncio.run(main())) self.assertEqual(stacks["consumer"], ["consumer"]) @skip_if_not_supported diff --git a/Modules/_remote_debugging/asyncio.c b/Modules/_remote_debugging/asyncio.c index 5f7a7fd5163565..884065b6efdee7 100644 --- a/Modules/_remote_debugging/asyncio.c +++ b/Modules/_remote_debugging/asyncio.c @@ -324,17 +324,14 @@ get_awaited_coro_address( uintptr_t stackpointer_addr = GET_MEMBER_NO_TAG(uintptr_t, iframe, unwinder->debug_offsets.interpreter_frame.stackpointer); if ((void*)stackpointer_addr != NULL) { - uintptr_t gi_await_addr; err = read_py_ptr( unwinder, stackpointer_addr - sizeof(void*) * 2, - &gi_await_addr); + next_coro); if (err) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read gi_await address"); return -1; } - - *next_coro = gi_await_addr; } return 0;