diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index df0af14d9f75622..465f6a7d80c9bf6 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -1,4 +1,5 @@ import asyncio +import collections.abc import unittest import os import textwrap @@ -14,6 +15,7 @@ from unittest.mock import ANY from test.support import ( os_helper, + threading_helper, SHORT_TIMEOUT, busy_retry, requires_gil_enabled, @@ -663,6 +665,84 @@ async def main(): stacks = self._extract_coroutine_funcnames(asyncio.run(main())) self.assertEqual(stacks["consumer"], ["consumer"]) + @skip_if_not_supported + def test_running_task_stack_between_steps(self): + # gh-159645 + traces = [] + + class Loop(asyncio.SelectorEventLoop): + def call_soon(self, *args, **kwargs): + if asyncio.current_task(self) is not None: + unwinder = RemoteUnwinder(os.getpid()) + traces.append(unwinder.get_async_stack_trace()) + return super().call_soon(*args, **kwargs) + + async def child(): + await asyncio.sleep(0) + + async def main(): + await asyncio.create_task(child(), name="child") + + asyncio.run(main(), loop_factory=Loop) + stacks = [list(self._extract_coroutine_funcnames(trace).values()) + for trace in traces] + self.assertIn([["__sleep0", "sleep", "child"], ["main"]], stacks) + self.assertIn([[], ["main"]], stacks) + + @skip_if_not_supported + def test_running_task_frame_not_in_thread_stack(self): + # gh-159645 + ready = threading.Event() + release = threading.Event() + + async def child(): + ready.set() + release.wait() + await asyncio.sleep(0) + + async def main(): + loop = asyncio.get_running_loop() + coro = child() + task = asyncio.create_task(coro, name="child") + thread = threading.Thread(target=coro.send, args=(None,)) + with threading_helper.start_threads([thread], unlock=release.set): + self.assertTrue(ready.wait(SHORT_TIMEOUT)) + self.assertTrue(coro.cr_running) + previous = tasks._swap_current_task(loop, task) + try: + trace = RemoteUnwinder(os.getpid()).get_async_stack_trace() + finally: + tasks._swap_current_task(loop, previous) + await task + return trace + + stacks = self._extract_coroutine_funcnames(asyncio.run(main())) + self.assertEqual(stacks["child"], ["child"]) + + @skip_if_not_supported + def test_running_task_with_non_native_coroutine(self): + # gh-159645 + class Wrapper(collections.abc.Coroutine): + def __init__(self, coro): + self._coro = coro + def send(self, value): + return self._coro.send(value) + def throw(self, *args): + return self._coro.throw(*args) + def close(self): + return self._coro.close() + def __await__(self): + return self._coro.__await__() + + async def inner(): + return RemoteUnwinder(os.getpid()).get_async_stack_trace() + + async def main(): + return await asyncio.create_task(Wrapper(inner()), name="wrapper") + + stacks = self._extract_coroutine_funcnames(asyncio.run(main())) + self.assertEqual(stacks["wrapper"], []) + @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-18-19-18-20.gh-issue-159645.IOXcpT.rst b/Misc/NEWS.d/next/Library/2026-10-18-19-18-20.gh-issue-159645.IOXcpT.rst new file mode 100644 index 000000000000000..fde97e6f131cb54 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-18-19-18-20.gh-issue-159645.IOXcpT.rst @@ -0,0 +1,2 @@ +Fix :mod:`profiling.sampling` ``--async-aware`` profiles showing event loop +frames under tasks. Patch by Timofei Ivankov. diff --git a/Modules/_remote_debugging/asyncio.c b/Modules/_remote_debugging/asyncio.c index d31f783186a2c30..57a67224a4d9014 100644 --- a/Modules/_remote_debugging/asyncio.c +++ b/Modules/_remote_debugging/asyncio.c @@ -765,6 +765,10 @@ get_task_frame(RemoteUnwinderObject *unwinder, uintptr_t task_addr, uintptr_t *f if (get_coro_like_address(unwinder, running_coro_addr, &running_coro_addr) < 0) { return -1; } + if (running_coro_addr == 0) { + *frame_addr = 0; + return 0; + } *frame_addr = running_coro_addr + (uintptr_t)unwinder->debug_offsets.gen_object.gi_iframe; return 0; @@ -781,6 +785,7 @@ parse_async_frame_chain( uintptr_t address_of_thread, uintptr_t running_task_frame ) { + Py_ssize_t coro_chain_size = PyList_GET_SIZE(calls); uintptr_t address_of_current_frame; if (find_running_frame(unwinder, address_of_thread, &address_of_current_frame) < 0) { set_exception_cause(unwinder, PyExc_RuntimeError, "Running frame search failed in async chain"); @@ -826,11 +831,12 @@ parse_async_frame_chain( // Stop at the task's own frame. Code objects are shared by // recursive calls, so they cannot identify it. if (this_frame == running_task_frame) { - break; + return PyList_SetSlice(calls, 0, coro_chain_size, NULL); } } - return 0; + // gh-159645: keep the coroutine chain if the task's frame was not found. + return PyList_SetSlice(calls, coro_chain_size, PY_SSIZE_T_MAX, NULL); } /* ============================================================================ @@ -972,6 +978,11 @@ process_running_task_chain( return -1; } + // gh-159645: non-native coroutines have no frame to look for. + if (running_task_frame == 0) { + return process_task_waiters(unwinder, result); + } + // Get the chain from the current frame to this task PyObject *coro_chain = PyStructSequence_GET_ITEM(task_info, 2); assert(coro_chain != NULL); @@ -987,12 +998,6 @@ process_running_task_chain( PyObject *frame_chain = PyStructSequence_GET_ITEM(coro_info, 0); assert(frame_chain != NULL); - // Clear the coro_chain - if (PyList_Clear(frame_chain) < 0) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to clear coroutine chain"); - return -1; - } - // Add the chain from the current frame to this task if (parse_async_frame_chain(unwinder, frame_chain, thread_state_addr, running_task_frame) < 0) { return -1;