diff --git a/Lib/asyncio/graph.py b/Lib/asyncio/graph.py index 54df7afa5553b97..d0a54f3625b37e5 100644 --- a/Lib/asyncio/graph.py +++ b/Lib/asyncio/graph.py @@ -155,7 +155,9 @@ def capture_call_graph( f = sys._getframe(depth) if limit != 0 else None try: while f is not None: - is_async = f.f_generator is not None + # gh-156988: sync gen should not clear the call chain + is_async = isinstance( + f.f_generator, (types.CoroutineType, types.AsyncGeneratorType)) call_stack.append(FrameCallGraphEntry(f)) if is_async: diff --git a/Lib/test/test_asyncio/test_graph.py b/Lib/test/test_asyncio/test_graph.py index 690f8f58b8f10da..50fa18e888fd22b 100644 --- a/Lib/test/test_asyncio/test_graph.py +++ b/Lib/test/test_asyncio/test_graph.py @@ -425,6 +425,26 @@ async def boom(): self.assertEqual(asyncio.capture_call_graph(task).call_stack, ()) self.assertIn(f"name={task.get_name()!r}", buf.getvalue()) + async def test_capture_call_graph_generator_keeps_caller_frames(self): + # gh-156988: sync gen should not clear the call chain + stack = None + + def gen(): + nonlocal stack + graph = asyncio.capture_call_graph() + stack = [entry.frame.f_code.co_name for entry in graph.call_stack] + yield + + def middle(): + for _ in gen(): + pass + + async def main(): + middle() + + await main() + self.assertEqual(stack[:3], ['gen', 'middle', 'main']) + @unittest.skipIf( not hasattr(asyncio.futures, "_c_future_add_to_awaited_by"), diff --git a/Misc/NEWS.d/next/Library/2026-09-05-18-23-30.gh-issue-156988.nEUQE6.rst b/Misc/NEWS.d/next/Library/2026-09-05-18-23-30.gh-issue-156988.nEUQE6.rst new file mode 100644 index 000000000000000..6cb2f1f4cf14ae0 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-05-18-23-30.gh-issue-156988.nEUQE6.rst @@ -0,0 +1,2 @@ +:func:`asyncio.print_call_graph` no longer truncates the call stack at a +synchronous generator.