Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b11469a
GH-91048: Add utils for printing the call stack for asyncio tasks
pablogsal May 1, 2025
7f800e8
Maybe
pablogsal May 2, 2025
c5e4efe
Maybe
pablogsal May 2, 2025
1c982b1
Maybe
pablogsal May 2, 2025
2d94cde
fix configure
pablogsal May 2, 2025
0a9a496
fix configure
pablogsal May 2, 2025
db47ff3
fix configure
mgmacias95 May 2, 2025
6f8bd4c
some tests + fixes
mgmacias95 May 2, 2025
152b3d7
improve tests
mgmacias95 May 2, 2025
955ef27
dsf
pablogsal May 2, 2025
65aee3c
dsf
pablogsal May 2, 2025
51e689e
test fixes
pablogsal May 3, 2025
1d27348
test fixes
pablogsal May 3, 2025
1d1b0e9
test fixes
pablogsal May 3, 2025
edad4d1
test fixes
pablogsal May 3, 2025
199589c
Fix free threading offsets
pablogsal May 3, 2025
9e87032
Fix free threading offsets AGAIN
pablogsal May 3, 2025
69e9221
Debugging
pablogsal May 3, 2025
b6cb609
More tests
pablogsal May 3, 2025
2dd3452
Add news entry
pablogsal May 3, 2025
a84a171
Doc fixes
pablogsal May 3, 2025
0f75edc
Fix doc build
ambv May 3, 2025
c3a6bcb
Add Yury
ambv May 3, 2025
5e1cb87
fix: Show independent tasks in the table
mgmacias95 May 3, 2025
d92b520
Merge pull request #101 from mgmacias95/GH-91048-tasks
pablogsal May 3, 2025
af6a8bf
Temporarily skip test_async_global_awaited_by on free-threading
ambv May 3, 2025
8db5dbe
Drop the `tools`. It's cleaner.
ambv May 3, 2025
6f8aa6b
Satisfy the linting gods
ambv May 3, 2025
8d566c6
chore: Refactor
mgmacias95 May 4, 2025
977c15a
Merge pull request #103 from mgmacias95/GH-91048-tasks
pablogsal May 4, 2025
9dbe00d
Doc fixes
pablogsal May 4, 2025
c56782b
Type fixes
pablogsal May 4, 2025
293337f
Type fixes
pablogsal May 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Drop the tools. It's cleaner.
  • Loading branch information
ambv committed May 3, 2025
commit 8db5dbe3f11030f8f8811fa0629eac710786bf15
16 changes: 10 additions & 6 deletions Doc/whatsnew/3.14.rst
Original file line number Diff line number Diff line change
Expand Up @@ -518,14 +518,18 @@ asynchronous tasks, available via:

.. code-block:: bash

python -m asyncio.tools [--tree] PID
python -m asyncio ps PID

This tool inspects the given process ID (PID) and displays information about
currently running asyncio tasks. By default, it outputs a task table: a flat
currently running asyncio tasks. It outputs a task table: a flat
listing of all tasks, their names, their coroutine stacks, and which tasks are
awaiting them.

With the ``--tree`` option, it instead renders a visual async call tree,
.. code-block:: bash

python -m asyncio pstree PID

This tool fetches the same information, but renders a visual async call tree,
showing coroutine relationships in a hierarchical format. This command is
particularly useful for debugging long-running or stuck asynchronous programs.
It can help developers quickly identify where a program is blocked, what tasks
Expand Down Expand Up @@ -565,7 +569,7 @@ Executing the new tool on the running process will yield a table like this:

.. code-block:: bash

python -m asyncio.tools 12345
python -m asyncio ps 12345

tid task id task name coroutine chain awaiter name awaiter id
---------------------------------------------------------------------------------------------------------------------------------------
Expand All @@ -576,11 +580,11 @@ Executing the new tool on the running process will yield a table like this:
6826911 0x200013c0e20 Task-7 task_group Task-3 0x200013c0420


and with the ``--tree`` option:
or:

.. code-block:: bash

python -m asyncio.tools --tree 12345
python -m asyncio pstree 12345

└── (T) Task-1
└── main
Expand Down
32 changes: 32 additions & 0 deletions Lib/asyncio/__main__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import argparse
import ast
import asyncio
import asyncio.tools
import concurrent.futures
import contextvars
import inspect
Expand Down Expand Up @@ -140,6 +142,36 @@ def interrupt(self) -> None:


if __name__ == '__main__':
parser = argparse.ArgumentParser(
prog="python3 -m asyncio",
description="Interactive asyncio shell and CLI tools",
)
subparsers = parser.add_subparsers(help="sub-commands", dest="command")
ps = subparsers.add_parser(
"ps", help="Display a table of all pending tasks in a process"
)
ps.add_argument("pid", type=int, help="Process ID to inspect")
pstree = subparsers.add_parser(
"pstree", help="Display a tree of all pending tasks in a process"
)
pstree.add_argument("pid", type=int, help="Process ID to inspect")
args = parser.parse_args()
match args.command:
case "ps":
asyncio.tools.display_awaited_by_tasks_table(args.pid)
sys.exit(0)
case "pstree":
asyncio.tools.display_awaited_by_tasks_tree(args.pid)
sys.exit(0)
case None:
pass # continue to the interactive shell
case _:
# shouldn't happen as an invalid command-line wouldn't parse
# but let's keep it for the next person adding a command
print(f"error: unhandled command {args.command}", file=sys.stderr)
parser.print_usage(file=sys.stderr)
sys.exit(1)

sys.audit("cpython.run_stdin")

if os.getenv('PYTHON_BASIC_REPL'):
Expand Down
68 changes: 34 additions & 34 deletions Lib/asyncio/tools.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import argparse
from dataclasses import dataclass
from collections import defaultdict
from itertools import count
Expand Down Expand Up @@ -107,10 +106,12 @@ def dfs(v):


# ─── PRINT TREE FUNCTION ───────────────────────────────────────
def print_async_tree(result, task_emoji="(T)", cor_emoji="", printer=print):
def build_async_tree(result, task_emoji="(T)", cor_emoji="", printer=print):
"""
Pretty-print the async call tree produced by `get_all_async_stacks()`,
prefixing tasks with *task_emoji* and coroutine frames with *cor_emoji*.
Build a list of strings for pretty-print a async call tree.

The call tree is produced by `get_all_async_stacks()`, prefixing tasks
with `task_emoji` and coroutine frames with `cor_emoji`.
"""
id2name, awaits = _index(result)
g = _task_graph(awaits)
Expand Down Expand Up @@ -179,40 +180,39 @@ def _print_cycle_exception(exception: CycleFoundException):
print(f"cycle: {inames}", file=sys.stderr)



if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Show Python async tasks in a process")
parser.add_argument("pid", type=int, help="Process ID(s) to inspect.")
parser.add_argument(
"--tree", "-t", action="store_true", help="Display tasks in a tree format"
)
args = parser.parse_args()

def _get_awaited_by_tasks(pid: int) -> list:
try:
tasks = get_all_awaited_by(args.pid)
return get_all_awaited_by(pid)
except RuntimeError as e:
while e.__context__ is not None:
e = e.__context__
print(f"Error retrieving tasks: {e}")
sys.exit(1)

if args.tree:
# Print the async call tree
try:
result = print_async_tree(tasks)
except CycleFoundException as e:
_print_cycle_exception(e)
sys.exit(1)

for tree in result:
print("\n".join(tree))
else:
# Build and print the task table
table = build_task_table(tasks)
# Print the table in a simple tabular format
print(
f"{'tid':<10} {'task id':<20} {'task name':<20} {'coroutine chain':<50} {'awaiter name':<20} {'awaiter id':<15}"
)
print("-" * 135)
for row in table:
print(f"{row[0]:<10} {row[1]:<20} {row[2]:<20} {row[3]:<50} {row[4]:<20} {row[5]:<15}")

def display_awaited_by_tasks_table(pid: int) -> None:
"""Build and print a table of all pending tasks under `pid`."""

tasks = _get_awaited_by_tasks(pid)
table = build_task_table(tasks)
# Print the table in a simple tabular format
print(
f"{'tid':<10} {'task id':<20} {'task name':<20} {'coroutine chain':<50} {'awaiter name':<20} {'awaiter id':<15}"
)
print("-" * 135)
for row in table:
print(f"{row[0]:<10} {row[1]:<20} {row[2]:<20} {row[3]:<50} {row[4]:<20} {row[5]:<15}")


def display_awaited_by_tasks_tree(pid: int) -> None:
"""Build and print a tree of all pending tasks under `pid`."""

tasks = _get_awaited_by_tasks(pid)
try:
result = print_async_tree(tasks)
except CycleFoundException as e:
_print_cycle_exception(e)
sys.exit(1)

for tree in result:
print("\n".join(tree))
36 changes: 17 additions & 19 deletions Lib/test/test_asyncio/test_tools.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"""Tests for the asyncio tools script."""

import unittest

from asyncio import tools
Expand Down Expand Up @@ -595,13 +593,13 @@ class TestAsyncioToolsTree(unittest.TestCase):
def test_asyncio_utils(self):
for input_, tree in TEST_INPUTS_TREE:
with self.subTest(input_):
self.assertEqual(tools.print_async_tree(input_), tree)
self.assertEqual(tools.build_async_tree(input_), tree)

def test_asyncio_utils_cycles(self):
for input_, cycles in TEST_INPUTS_CYCLES_TREE:
with self.subTest(input_):
try:
tools.print_async_tree(input_)
tools.build_async_tree(input_)
except tools.CycleFoundException as e:
self.assertEqual(e.cycles, cycles)

Expand All @@ -615,10 +613,10 @@ def test_asyncio_utils(self):

class TestAsyncioToolsBasic(unittest.TestCase):
def test_empty_input_tree(self):
"""Test print_async_tree with empty input."""
"""Test build_async_tree with empty input."""
result = []
expected_output = []
self.assertEqual(tools.print_async_tree(result), expected_output)
self.assertEqual(tools.build_async_tree(result), expected_output)

def test_empty_input_table(self):
"""Test build_task_table with empty input."""
Expand All @@ -629,7 +627,7 @@ def test_empty_input_table(self):
def test_only_independent_tasks_tree(self):
input_ = [(1, [(10, "taskA", []), (11, "taskB", [])])]
expected = [["└── (T) taskA"], ["└── (T) taskB"]]
result = tools.print_async_tree(input_)
result = tools.build_async_tree(input_)
self.assertEqual(sorted(result), sorted(expected))

def test_only_independent_tasks_table(self):
Expand All @@ -640,7 +638,7 @@ def test_only_independent_tasks_table(self):
)

def test_single_task_tree(self):
"""Test print_async_tree with a single task and no awaits."""
"""Test build_async_tree with a single task and no awaits."""
result = [
(
1,
Expand All @@ -654,7 +652,7 @@ def test_single_task_tree(self):
"└── (T) Task-1",
]
]
self.assertEqual(tools.print_async_tree(result), expected_output)
self.assertEqual(tools.build_async_tree(result), expected_output)

def test_single_task_table(self):
"""Test build_task_table with a single task and no awaits."""
Expand All @@ -670,7 +668,7 @@ def test_single_task_table(self):
self.assertEqual(tools.build_task_table(result), expected_output)

def test_cycle_detection(self):
"""Test print_async_tree raises CycleFoundException for cyclic input."""
"""Test build_async_tree raises CycleFoundException for cyclic input."""
result = [
(
1,
Expand All @@ -681,11 +679,11 @@ def test_cycle_detection(self):
)
]
with self.assertRaises(tools.CycleFoundException) as context:
tools.print_async_tree(result)
tools.build_async_tree(result)
self.assertEqual(context.exception.cycles, [[3, 2, 3]])

def test_complex_tree(self):
"""Test print_async_tree with a more complex tree structure."""
"""Test build_async_tree with a more complex tree structure."""
result = [
(
1,
Expand All @@ -705,7 +703,7 @@ def test_complex_tree(self):
" └── (T) Task-3",
]
]
self.assertEqual(tools.print_async_tree(result), expected_output)
self.assertEqual(tools.build_async_tree(result), expected_output)

def test_complex_table(self):
"""Test build_task_table with a more complex tree structure."""
Expand Down Expand Up @@ -747,7 +745,7 @@ def test_deep_coroutine_chain(self):
" └── (T) leaf",
]
]
result = tools.print_async_tree(input_)
result = tools.build_async_tree(input_)
self.assertEqual(result, expected)

def test_multiple_cycles_same_node(self):
Expand All @@ -762,7 +760,7 @@ def test_multiple_cycles_same_node(self):
)
]
with self.assertRaises(tools.CycleFoundException) as ctx:
tools.print_async_tree(input_)
tools.build_async_tree(input_)
cycles = ctx.exception.cycles
self.assertTrue(any(set(c) == {1, 2, 3} for c in cycles))

Expand All @@ -789,7 +787,7 @@ def test_task_awaits_self(self):
"""A task directly awaits itself – should raise a cycle."""
input_ = [(1, [(1, "Self-Awaiter", [[["loopback"], 1]])])]
with self.assertRaises(tools.CycleFoundException) as ctx:
tools.print_async_tree(input_)
tools.build_async_tree(input_)
self.assertIn([1, 1], ctx.exception.cycles)

def test_task_with_missing_awaiter_id(self):
Expand All @@ -811,7 +809,7 @@ def test_duplicate_coroutine_frames(self):
],
)
]
tree = tools.print_async_tree(input_)
tree = tools.build_async_tree(input_)
# Both children should be under the same coroutine node
flat = "\n".join(tree[0])
self.assertIn("frameA", flat)
Expand All @@ -827,13 +825,13 @@ def test_task_with_no_name(self):
"""Task with no name in id2name – should still render with fallback."""
input_ = [(1, [(1, "root", [[["f1"], 2]]), (2, None, [])])]
# If name is None, fallback to string should not crash
tree = tools.print_async_tree(input_)
tree = tools.build_async_tree(input_)
self.assertIn("(T) None", "\n".join(tree[0]))

def test_tree_rendering_with_custom_emojis(self):
"""Pass custom emojis to the tree renderer."""
input_ = [(1, [(1, "MainTask", [[["f1", "f2"], 2]]), (2, "SubTask", [])])]
tree = tools.print_async_tree(input_, task_emoji="🧵", cor_emoji="🔁")
tree = tools.build_async_tree(input_, task_emoji="🧵", cor_emoji="🔁")
flat = "\n".join(tree[0])
self.assertIn("🧵 MainTask", flat)
self.assertIn("🔁 f1", flat)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
Add a new ``python -m asyncio.tools`` command-line interface to inspect
Add a new ``python -m asyncio ps PID`` command-line interface to inspect
asyncio tasks in a running Python process. Displays a flat table of await
relationships or a tree view with ``--tree``, useful for debugging async
relationships. A variant showing a tree view is also available as
``python -m asyncio pstree PID``. Both are useful for debugging async
code. Patch by Pablo Galindo, Łukasz Langa, Yury Selivanov, and Marta
Gomez Macias.
Loading