From 4fd359c329304058735fbf047d8d847e852ea855 Mon Sep 17 00:00:00 2001 From: Timofey Ivankov Date: Thu, 3 Sep 2026 01:34:52 +0300 Subject: [PATCH] gh-156860: Fix exponential asyncio.print_call_graph() output --- Lib/asyncio/graph.py | 15 ++++++-- Lib/test/test_asyncio/test_graph.py | 36 +++++++++++++++++++ ...-09-03-01-32-46.gh-issue-156860.mPeXty.rst | 2 ++ 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-03-01-32-46.gh-issue-156860.mPeXty.rst diff --git a/Lib/asyncio/graph.py b/Lib/asyncio/graph.py index 94fcd33d7088a68..ef4781a873bbdc5 100644 --- a/Lib/asyncio/graph.py +++ b/Lib/asyncio/graph.py @@ -41,6 +41,7 @@ def _build_graph_for_future( future: futures.Future, *, limit: int | None = None, + seen: set[int] | None = None, ) -> FutureCallGraph: if not isinstance(future, futures.Future): raise TypeError( @@ -68,9 +69,15 @@ def _build_graph_for_future( else: break - if future._asyncio_awaited_by: + if seen is None: + seen = set() + + # gh-156860: "awaited by" is a DAG, not a tree. Expand each future once + if future._asyncio_awaited_by and id(future) not in seen: + seen.add(id(future)) for parent in future._asyncio_awaited_by: - awaited_by.append(_build_graph_for_future(parent, limit=limit)) + awaited_by.append( + _build_graph_for_future(parent, limit=limit, seen=seen)) if limit is not None: if limit > 0: @@ -170,8 +177,10 @@ def capture_call_graph( awaited_by = [] if future._asyncio_awaited_by: + seen = {id(future)} for parent in future._asyncio_awaited_by: - awaited_by.append(_build_graph_for_future(parent, limit=limit)) + awaited_by.append( + _build_graph_for_future(parent, limit=limit, seen=seen)) if limit is not None: limit *= -1 diff --git a/Lib/test/test_asyncio/test_graph.py b/Lib/test/test_asyncio/test_graph.py index 50d528fb9fb2c51..ca9017d2d155078 100644 --- a/Lib/test/test_asyncio/test_graph.py +++ b/Lib/test/test_asyncio/test_graph.py @@ -434,6 +434,42 @@ async def main(): self.assertTrue(stack_for_fut[1].startswith('* Future(id=')) + async def test_build_graph_for_future_expands_dag_once(self): + # gh-156860: a future reachable by several paths is expanded once. + async def waits_for(*deps): + await asyncio.gather(*deps) + + fut = asyncio.Future() + layer = [fut] + for _ in range(3): + layer = [asyncio.ensure_future(waits_for(*layer)) for _ in range(2)] + await asyncio.sleep(0) + captured = asyncio.format_call_graph(fut) + + fut.set_result(None) + await asyncio.gather(*layer) + + self.assertEqual(captured.count('* Task'), 10) + + async def test_capture_call_graph_expands_dag_once(self): + # gh-156860 + captured = None + + async def waits_for(*deps): + await asyncio.gather(*deps) + + async def root(): + nonlocal captured + await asyncio.sleep(0) + captured = asyncio.format_call_graph() + + layer = [asyncio.ensure_future(root())] + for _ in range(3): + layer = [asyncio.ensure_future(waits_for(*layer)) for _ in range(2)] + await asyncio.gather(*layer) + + self.assertEqual(captured.count('* Task'), 13) + async def test_capture_call_graph_positive_limit(self): captured = None diff --git a/Misc/NEWS.d/next/Library/2026-09-03-01-32-46.gh-issue-156860.mPeXty.rst b/Misc/NEWS.d/next/Library/2026-09-03-01-32-46.gh-issue-156860.mPeXty.rst new file mode 100644 index 000000000000000..9f890bd25cadf49 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-03-01-32-46.gh-issue-156860.mPeXty.rst @@ -0,0 +1,2 @@ +Fix exponential growth of :func:`asyncio.print_call_graph` output when +several tasks await the same future.