From aa54fe551f6b1cf977071ba4b846a34d5f504872 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Date: Wed, 11 Jun 2025 02:36:02 +0100 Subject: [PATCH 1/4] gh-135371: Fix asyncio task awaited_by semantics in remote debugging The awaited_by list in get_all_awaited_by() was incorrectly showing the awaiter's call stack instead of the current task's call stack. This made the output confusing and incorrect. This change adds a new parse_current_task_with_awaiter() function that correctly extracts the current task's own call stack (where it's executing) and the awaiter's task ID (who is waiting for this task). --- Lib/asyncio/tools.py | 23 ++- Lib/test/test_asyncio/test_tools.py | 143 ++++++++++------- Lib/test/test_external_inspection.py | 228 +++++++++++++++++++++++---- Modules/_remote_debugging_module.c | 167 ++++++++++++++------ 4 files changed, 419 insertions(+), 142 deletions(-) diff --git a/Lib/asyncio/tools.py b/Lib/asyncio/tools.py index 3fc4524c008db6..cd12dfaebe92c6 100644 --- a/Lib/asyncio/tools.py +++ b/Lib/asyncio/tools.py @@ -28,7 +28,7 @@ def __init__( # ─── indexing helpers ─────────────────────────────────────────── def _format_stack_entry(elem: tuple[str, str, int] | str) -> str: if isinstance(elem, tuple): - fqname, path, line_no = elem + path, line_no, fqname = elem return f"{fqname} {path}:{line_no}" return elem @@ -44,7 +44,6 @@ def _index(result): awaits.append((parent_id, stack, tid)) return id2name, awaits - def _build_tree(id2name, awaits): id2label = {(NodeType.TASK, tid): name for tid, name in id2name.items()} children = defaultdict(list) @@ -62,18 +61,26 @@ def _cor_node(parent_key, frame_name): bucket[frame_name] = node_key return node_key - # lay down parent ➜ …frames… ➜ child paths + # First pass: build parent -> child relationships + for parent_id, stack, child_id in awaits: + if parent_id != 0: # Skip root tasks (no parent) + parent_key = (NodeType.TASK, parent_id) + child_key = (NodeType.TASK, child_id) + if child_key not in children[parent_key]: + children[parent_key].append(child_key) + + # Second pass: add call stacks under each child task for parent_id, stack, child_id in awaits: - cur = (NodeType.TASK, parent_id) + # Add the call stack as coroutine nodes under the child task + child_key = (NodeType.TASK, child_id) + cur = child_key for frame in reversed(stack): # outer-most → inner-most cur = _cor_node(cur, frame) - child_key = (NodeType.TASK, child_id) - if child_key not in children[cur]: - children[cur].append(child_key) return id2label, children + def _roots(id2label, children): all_children = {c for kids in children.values() for c in kids} return [n for n in id2label if n not in all_children] @@ -170,7 +177,7 @@ def build_task_table(result): ] ) for stack, awaiter_id in awaited: - stack = [elem[0] if isinstance(elem, tuple) else elem for elem in stack] + stack = [elem[-1] if isinstance(elem, tuple) else elem for elem in stack] coroutine_chain = " -> ".join(stack) awaiter_name = id2name.get(awaiter_id, "Unknown") table.append( diff --git a/Lib/test/test_asyncio/test_tools.py b/Lib/test/test_asyncio/test_tools.py index ba36e759ccdd61..ca923f783be053 100644 --- a/Lib/test/test_asyncio/test_tools.py +++ b/Lib/test/test_asyncio/test_tools.py @@ -90,39 +90,62 @@ [ [ "└── (T) Task-1", - " └── main", - " └── __aexit__", - " └── _aexit", - " ├── (T) root1", - " │ └── bloch", - " │ └── blocho_caller", - " │ └── __aexit__", - " │ └── _aexit", - " │ ├── (T) child1_1", - " │ │ └── awaiter /path/to/app.py:110", - " │ │ └── awaiter2 /path/to/app.py:120", - " │ │ └── awaiter3 /path/to/app.py:130", - " │ │ └── (T) timer", - " │ └── (T) child2_1", - " │ └── awaiterB /path/to/app.py:170", - " │ └── awaiterB2 /path/to/app.py:180", - " │ └── awaiterB3 /path/to/app.py:190", - " │ └── (T) timer", - " └── (T) root2", - " └── bloch", - " └── blocho_caller", - " └── __aexit__", - " └── _aexit", - " ├── (T) child1_2", - " │ └── awaiter /path/to/app.py:110", - " │ └── awaiter2 /path/to/app.py:120", - " │ └── awaiter3 /path/to/app.py:130", - " │ └── (T) timer", - " └── (T) child2_2", - " └── awaiterB /path/to/app.py:170", - " └── awaiterB2 /path/to/app.py:180", - " └── awaiterB3 /path/to/app.py:190", - " └── (T) timer", + " ├── (T) root1", + " │ ├── (T) child1_1", + " │ │ ├── (T) timer", + " │ │ │ ├── 110 awaiter:/path/to/app.py", + " │ │ │ │ └── 120 awaiter2:/path/to/app.py", + " │ │ │ │ └── 130 awaiter3:/path/to/app.py", + " │ │ │ └── 170 awaiterB:/path/to/app.py", + " │ │ │ └── 180 awaiterB2:/path/to/app.py", + " │ │ │ └── 190 awaiterB3:/path/to/app.py", + " │ │ └── bloch", + " │ │ └── blocho_caller", + " │ │ └── __aexit__", + " │ │ └── _aexit", + " │ ├── (T) child2_1", + " │ │ ├── (T) timer", + " │ │ │ ├── 110 awaiter:/path/to/app.py", + " │ │ │ │ └── 120 awaiter2:/path/to/app.py", + " │ │ │ │ └── 130 awaiter3:/path/to/app.py", + " │ │ │ └── 170 awaiterB:/path/to/app.py", + " │ │ │ └── 180 awaiterB2:/path/to/app.py", + " │ │ │ └── 190 awaiterB3:/path/to/app.py", + " │ │ └── bloch", + " │ │ └── blocho_caller", + " │ │ └── __aexit__", + " │ │ └── _aexit", + " │ └── main", + " │ └── __aexit__", + " │ └── _aexit", + " └── (T) root2", + " ├── (T) child1_2", + " │ ├── (T) timer", + " │ │ ├── 110 awaiter:/path/to/app.py", + " │ │ │ └── 120 awaiter2:/path/to/app.py", + " │ │ │ └── 130 awaiter3:/path/to/app.py", + " │ │ └── 170 awaiterB:/path/to/app.py", + " │ │ └── 180 awaiterB2:/path/to/app.py", + " │ │ └── 190 awaiterB3:/path/to/app.py", + " │ └── bloch", + " │ └── blocho_caller", + " │ └── __aexit__", + " │ └── _aexit", + " ├── (T) child2_2", + " │ ├── (T) timer", + " │ │ ├── 110 awaiter:/path/to/app.py", + " │ │ │ └── 120 awaiter2:/path/to/app.py", + " │ │ │ └── 130 awaiter3:/path/to/app.py", + " │ │ └── 170 awaiterB:/path/to/app.py", + " │ │ └── 180 awaiterB2:/path/to/app.py", + " │ │ └── 190 awaiterB3:/path/to/app.py", + " │ └── bloch", + " │ └── blocho_caller", + " │ └── __aexit__", + " │ └── _aexit", + " └── main", + " └── __aexit__", + " └── _aexit", ] ] ), @@ -155,17 +178,21 @@ [ [ "└── (T) Task-5", - " └── main2", - " ├── (T) Task-6", - " ├── (T) Task-7", - " └── (T) Task-8", + " ├── (T) Task-6", + " │ └── main2", + " ├── (T) Task-7", + " │ └── main2", + " └── (T) Task-8", + " └── main2", ], [ "└── (T) Task-1", - " └── main", - " ├── (T) Task-2", - " ├── (T) Task-3", - " └── (T) Task-4", + " ├── (T) Task-2", + " │ └── main", + " ├── (T) Task-3", + " │ └── main", + " └── (T) Task-4", + " └── main", ], ] ), @@ -193,10 +220,12 @@ ["└── (T) Task-5"], [ "└── (T) Task-1", - " └── main", - " ├── (T) Task-2", - " ├── (T) Task-3", - " └── (T) Task-4", + " ├── (T) Task-2", + " │ └── main", + " ├── (T) Task-3", + " │ └── main", + " └── (T) Task-4", + " └── main", ], ] ), @@ -704,10 +733,10 @@ def test_complex_tree(self): expected_output = [ [ "└── (T) Task-1", - " └── main", - " └── (T) Task-2", - " └── main", - " └── (T) Task-3", + " └── (T) Task-2", + " ├── (T) Task-3", + " │ └── main", + " └── main", ] ] self.assertEqual(tools.build_async_tree(result), expected_output) @@ -744,12 +773,12 @@ def test_deep_coroutine_chain(self): expected = [ [ "└── (T) root", - " └── c5", - " └── c4", - " └── c3", - " └── c2", - " └── c1", - " └── (T) leaf", + " └── (T) leaf", + " └── c5", + " └── c4", + " └── c3", + " └── c2", + " └── c1", ] ] result = tools.build_async_tree(input_) @@ -817,7 +846,9 @@ def test_duplicate_coroutine_frames(self): ) ] tree = tools.build_async_tree(input_) - # Both children should be under the same coroutine node + # Should create separate trees for each child + self.assertEqual(len(tree), 2) + flat = "\n".join(tree[0]) self.assertIn("frameA", flat) self.assertIn("Task-2", flat) diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index 303af25fc7a715..099af6194a7f62 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -659,7 +659,17 @@ async def main(): # expected: at least 1000 pending tasks self.assertGreaterEqual(len(entries), 1000) # the first three tasks stem from the code structure - self.assertIn((ANY, "Task-1", []), entries) + # expected: a list of two elements: 1 thread, 1 interp + self.assertEqual(len(all_awaited_by), 2) + # expected: a tuple with the thread ID and the awaited_by list + self.assertEqual(len(all_awaited_by[0]), 2) + # expected: no tasks in the fallback per-interp task list + self.assertEqual(all_awaited_by[1], (0, [])) + entries = all_awaited_by[0][1] + # expected: at least 1000 pending tasks + self.assertGreaterEqual(len(entries), 1000) + + # Task-1 now has its own call stack with awaiter_id = 0 (no awaiter) main_stack = [ ( taskgroups.__file__, @@ -673,46 +683,208 @@ async def main(): ), (script_name, 60, "main"), ] + self.assertIn((ANY, "Task-1", [[main_stack, 0]]), entries) + + # server task shows its own call stack and is awaited by Task-1 + server_stack = [ + ( + ANY, # base_events.py path may vary + ANY, # line number may vary + "Server.serve_forever", + ) + ] self.assertIn( - (ANY, "server task", [[main_stack, ANY]]), + (ANY, "server task", [[server_stack, ANY]]), entries, ) + + # echo client spam shows its own call stack and is awaited by Task-1 + spam_stack = [ + ( + taskgroups.__file__, + ANY, + "TaskGroup._aexit", + ), + ( + taskgroups.__file__, + ANY, + "TaskGroup.__aexit__", + ), + (script_name, 41, "echo_client_spam"), + ] self.assertIn( - (ANY, "echo client spam", [[main_stack, ANY]]), + (ANY, "echo client spam", [[spam_stack, ANY]]), entries, ) - expected_stack = [ - [ - [ - ( - taskgroups.__file__, - ANY, - "TaskGroup._aexit", - ), - ( - taskgroups.__file__, - ANY, - "TaskGroup.__aexit__", - ), - (script_name, 41, "echo_client_spam"), - ], - ANY, - ] - ] - tasks_with_stack = [ - task for task in entries if task[2] == expected_stack - ] + # Check for tasks that have sleep -> echo_client call stack pattern + tasks_with_stack = [] + for task in entries: + if (len(task[2]) > 0 and len(task[2][0]) > 0 and + len(task[2][0][0]) >= 2): + call_stack = task[2][0][0] + if (len(call_stack) >= 2 and + call_stack[0][2] == "sleep" and + call_stack[1][2] == "echo_client"): + tasks_with_stack.append(task) + self.assertGreaterEqual(len(tasks_with_stack), 1000) # the final task will have some random number, but it should for # sure be one of the echo client spam horde (In windows this is not true # for some reason) if sys.platform != "win32": - self.assertEqual( - expected_stack, - entries[-1][2], - ) + final_task = entries[-1] + if (len(final_task[2]) > 0 and len(final_task[2][0]) > 0 and + len(final_task[2][0][0]) >= 2): + final_call_stack = final_task[2][0][0] + self.assertEqual(final_call_stack[0][2], "sleep") + self.assertEqual(final_call_stack[1][2], "echo_client") + + except PermissionError: + self.skipTest("Insufficient permissions to read the stack trace") + finally: + if client_socket is not None: + client_socket.close() + p.kill() + p.terminate() + p.wait(timeout=SHORT_TIMEOUT) + + + @skip_if_not_supported + @unittest.skipIf( + sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, + "Test only runs on Linux with process_vm_readv support", + ) + def test_taskgroup_awaited_by(self): + # Spawn a process with TaskGroup and multiple named tasks + port = find_unused_port() + script = textwrap.dedent( + f"""\ + import asyncio + import socket + import time + from asyncio import taskgroups + + # Connect to the test process + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect(('localhost', {port})) + + async def foo1(): + await foo11() + + async def foo11(): + await foo12() + + async def foo12(): + sock.sendall(b"ready") + await asyncio.sleep(1000) + + async def foo2(): + sock.sendall(b"ready") + await asyncio.sleep(500) + + async def runner(): + async with taskgroups.TaskGroup() as g: + g.create_task(foo1(), name="foo1.0") + g.create_task(foo1(), name="foo1.1") + g.create_task(foo1(), name="foo1.2") + g.create_task(foo2(), name="foo2") + sock.sendall(b"ready") + await asyncio.sleep(1000) + + asyncio.run(runner()) + """ + ) + with os_helper.temp_dir() as work_dir: + script_dir = os.path.join(work_dir, "script_pkg") + os.mkdir(script_dir) + + # Create a socket server to communicate with the target process + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.bind(("localhost", port)) + server_socket.settimeout(SHORT_TIMEOUT) + server_socket.listen(1) + + script_name = _make_test_script(script_dir, "script", script) + client_socket = None + try: + p = subprocess.Popen([sys.executable, script_name]) + client_socket, _ = server_socket.accept() + server_socket.close() + + # Wait for all tasks to reach their sleep point + for i in range(5): + expected_response = b"ready" + response = client_socket.recv(len(expected_response)) + self.assertEqual(response, expected_response, f"Expected ready signal {i+1}/3") + + all_awaited_by = get_all_awaited_by(p.pid) + + # expected: a list of two elements: 1 thread, 1 interp + self.assertEqual(len(all_awaited_by), 2) + # expected: a tuple with the thread ID and the awaited_by list + self.assertEqual(len(all_awaited_by[0]), 2) + # expected: no tasks in the fallback per-interp task list + self.assertEqual(all_awaited_by[1], (0, [])) + entries = all_awaited_by[0][1] + + # Should have at least 5 tasks: Task-1, foo1.0, foo1.1, foo1.2, foo2 + self.assertGreaterEqual(len(entries), 5) + + # Task-1 should be the root task (no awaiter) + task_1_found = False + for task_id, task_name, awaited_by_list in entries: + if task_name == "Task-1": + self.assertEqual(len(awaited_by_list), 1) + call_stack, awaiter_id = awaited_by_list[0] + self.assertEqual(awaiter_id, 0) # No awaiter + # Should contain runner function + self.assertTrue(any("runner" in str(frame) for frame in call_stack)) + task_1_found = True + break + self.assertTrue(task_1_found, "Task-1 not found") + + # Find foo1.x tasks - should have foo1 -> foo11 -> foo12 -> sleep call stack + foo1_tasks = [] + for task_id, task_name, awaited_by_list in entries: + if task_name in ["foo1.0", "foo1.1", "foo1.2"]: + foo1_tasks.append((task_id, task_name, awaited_by_list)) + + self.assertEqual(len(foo1_tasks), 3, "Should have exactly 3 foo1.x tasks") + + # Verify foo1 tasks have the expected call stack pattern + for task_id, task_name, awaited_by_list in foo1_tasks: + self.assertEqual(len(awaited_by_list), 1) + call_stack, awaiter_id = awaited_by_list[0] + self.assertNotEqual(awaiter_id, 0) # Should be awaited by Task-1 + + # Verify call stack: sleep -> foo12 -> foo11 -> foo1 + function_names = [frame[2] for frame in call_stack] + self.assertIn("sleep", function_names) + self.assertIn("foo12", function_names) + self.assertIn("foo11", function_names) + self.assertIn("foo1", function_names) + + # Find foo2 task - should have foo2 -> sleep call stack + foo2_task = None + for task_id, task_name, awaited_by_list in entries: + if task_name == "foo2": + foo2_task = (task_id, task_name, awaited_by_list) + break + + self.assertIsNotNone(foo2_task, "foo2 task not found") + task_id, task_name, awaited_by_list = foo2_task + self.assertEqual(len(awaited_by_list), 1) + call_stack, awaiter_id = awaited_by_list[0] + self.assertNotEqual(awaiter_id, 0) # Should be awaited by Task-1 + + # Verify foo2 call stack: sleep -> foo2 + function_names = [frame[2] for frame in call_stack] + self.assertIn("sleep", function_names) + self.assertIn("foo2", function_names) + except PermissionError: self.skipTest("Insufficient permissions to read the stack trace") finally: diff --git a/Modules/_remote_debugging_module.c b/Modules/_remote_debugging_module.c index ea58f38006e199..5d5d7edde3a703 100644 --- a/Modules/_remote_debugging_module.c +++ b/Modules/_remote_debugging_module.c @@ -164,16 +164,14 @@ static int parse_tasks_in_set( RemoteUnwinderObject *unwinder, uintptr_t set_addr, - PyObject *awaited_by, - int recurse_task + PyObject *awaited_by ); static int parse_task( RemoteUnwinderObject *unwinder, uintptr_t task_address, - PyObject *render_to, - int recurse_task + PyObject *render_to ); static int @@ -685,8 +683,7 @@ parse_task_name( static int parse_task_awaited_by( RemoteUnwinderObject *unwinder, uintptr_t task_address, - PyObject *awaited_by, - int recurse_task + PyObject *awaited_by ) { // Read the entire TaskObj at once char task_obj[SIZEOF_TASK_OBJ]; @@ -707,12 +704,12 @@ static int parse_task_awaited_by( char awaited_by_is_a_set = GET_MEMBER(char, task_obj, unwinder->async_debug_offsets.asyncio_task_object.task_awaited_by_is_set); if (awaited_by_is_a_set) { - if (parse_tasks_in_set(unwinder, task_ab_addr, awaited_by, recurse_task)) { + if (parse_tasks_in_set(unwinder, task_ab_addr, awaited_by)) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse tasks in awaited_by set"); return -1; } } else { - if (parse_task(unwinder, task_ab_addr, awaited_by, recurse_task)) { + if (parse_task(unwinder, task_ab_addr, awaited_by)) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse single awaited_by task"); return -1; } @@ -845,8 +842,7 @@ parse_coro_chain( static PyObject* create_task_result( RemoteUnwinderObject *unwinder, - uintptr_t task_address, - int recurse_task + uintptr_t task_address ) { PyObject* result = NULL; PyObject *call_stack = NULL; @@ -872,11 +868,7 @@ create_task_result( } Py_CLEAR(call_stack); - if (recurse_task) { - tn = parse_task_name(unwinder, task_address); - } else { - tn = PyLong_FromUnsignedLongLong(task_address); - } + tn = parse_task_name(unwinder, task_address); if (tn == NULL) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to create task name/address"); goto error; @@ -938,8 +930,7 @@ static int parse_task( RemoteUnwinderObject *unwinder, uintptr_t task_address, - PyObject *render_to, - int recurse_task + PyObject *render_to ) { char is_task; PyObject* result = NULL; @@ -956,7 +947,7 @@ parse_task( } if (is_task) { - result = create_task_result(unwinder, task_address, recurse_task); + result = create_task_result(unwinder, task_address); if (!result) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to create task result"); goto error; @@ -974,28 +965,26 @@ parse_task( goto error; } - if (recurse_task) { - awaited_by = PyList_New(0); - if (awaited_by == NULL) { - set_exception_cause(unwinder, PyExc_MemoryError, "Failed to create awaited_by list"); - goto error; - } + awaited_by = PyList_New(0); + if (awaited_by == NULL) { + set_exception_cause(unwinder, PyExc_MemoryError, "Failed to create awaited_by list"); + goto error; + } - if (PyList_Append(result, awaited_by)) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to append awaited_by to result"); - goto error; - } - Py_DECREF(awaited_by); + if (PyList_Append(result, awaited_by)) { + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to append awaited_by to result"); + goto error; + } + Py_DECREF(awaited_by); - /* awaited_by is borrowed from 'result' to simplify cleanup */ - if (parse_task_awaited_by(unwinder, task_address, awaited_by, 1) < 0) { - // Clear the pointer so the cleanup doesn't try to decref it since - // it's borrowed from 'result' and will be decrefed when result is - // deleted. - awaited_by = NULL; - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse task awaited_by relationships"); - goto error; - } + /* awaited_by is borrowed from 'result' to simplify cleanup */ + if (parse_task_awaited_by(unwinder, task_address, awaited_by) < 0) { + // Clear the pointer so the cleanup doesn't try to decref it since + // it's borrowed from 'result' and will be decrefed when result is + // deleted. + awaited_by = NULL; + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse task awaited_by relationships"); + goto error; } Py_DECREF(result); @@ -1011,8 +1000,7 @@ static int process_set_entry( RemoteUnwinderObject *unwinder, uintptr_t table_ptr, - PyObject *awaited_by, - int recurse_task + PyObject *awaited_by ) { uintptr_t key_addr; if (read_py_ptr(unwinder, table_ptr, &key_addr)) { @@ -1029,7 +1017,7 @@ process_set_entry( if (ref_cnt) { // if 'ref_cnt=0' it's a set dummy marker - if (parse_task(unwinder, key_addr, awaited_by, recurse_task)) { + if (parse_task(unwinder, key_addr, awaited_by)) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse task in set entry"); return -1; } @@ -1043,8 +1031,7 @@ static int parse_tasks_in_set( RemoteUnwinderObject *unwinder, uintptr_t set_addr, - PyObject *awaited_by, - int recurse_task + PyObject *awaited_by ) { char set_object[SIZEOF_SET_OBJ]; int err = _Py_RemoteDebug_PagedReadRemoteMemory( @@ -1064,7 +1051,7 @@ parse_tasks_in_set( Py_ssize_t i = 0; Py_ssize_t els = 0; while (i < set_len && els < num_els) { - int result = process_set_entry(unwinder, table_ptr, awaited_by, recurse_task); + int result = process_set_entry(unwinder, table_ptr, awaited_by); if (result < 0) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to process set entry"); @@ -1142,8 +1129,7 @@ add_task_info_to_result( } Py_DECREF(awaited_by); - if (parse_task_awaited_by( - unwinder, running_task_addr, awaited_by, 1) < 0) { + if (parse_task_awaited_by(unwinder, running_task_addr, awaited_by) < 0) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse awaited_by for result"); return -1; } @@ -1151,6 +1137,84 @@ add_task_info_to_result( return 0; } +static int +parse_current_task_with_awaiter( + RemoteUnwinderObject *unwinder, + uintptr_t task_address, + PyObject *awaited_by_list +) { + // Get the CURRENT task's call stack (like parse_task does) + PyObject *call_stack = PyList_New(0); + if (call_stack == NULL) { + set_exception_cause(unwinder, PyExc_MemoryError, "Failed to create current task call stack"); + return -1; + } + + // Read the current task object + char task_obj[SIZEOF_TASK_OBJ]; + if (_Py_RemoteDebug_PagedReadRemoteMemory(&unwinder->handle, task_address, + unwinder->async_debug_offsets.asyncio_task_object.size, + task_obj) < 0) { + Py_DECREF(call_stack); + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read current task object"); + return -1; + } + + // Get the current task's coroutine chain + uintptr_t coro_addr = GET_MEMBER(uintptr_t, task_obj, unwinder->async_debug_offsets.asyncio_task_object.task_coro); + coro_addr &= ~Py_TAG_BITS; + + if ((void*)coro_addr != NULL) { + if (parse_coro_chain(unwinder, coro_addr, call_stack) < 0) { + Py_DECREF(call_stack); + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse current task coro chain"); + return -1; + } + + if (PyList_Reverse(call_stack)) { + Py_DECREF(call_stack); + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to reverse current task call stack"); + return -1; + } + } + + // Get who is awaiting this current task (like parse_task_awaited_by does) + uintptr_t awaiter_addr = GET_MEMBER(uintptr_t, task_obj, unwinder->async_debug_offsets.asyncio_task_object.task_awaited_by); + awaiter_addr &= ~Py_TAG_BITS; + + // Create the awaited_by entry: [current_task_call_stack, awaiter_task_id] + PyObject *awaited_by_entry = PyList_New(2); + if (awaited_by_entry == NULL) { + Py_DECREF(call_stack); + set_exception_cause(unwinder, PyExc_MemoryError, "Failed to create awaited_by entry"); + return -1; + } + + PyObject *awaiter_id = PyLong_FromUnsignedLongLong(awaiter_addr); + if (awaiter_id == NULL) { + Py_DECREF(call_stack); + Py_DECREF(awaited_by_entry); + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to create awaiter task ID"); + return -1; + } + + PyList_SET_ITEM(awaited_by_entry, 0, call_stack); // steals ref + PyList_SET_ITEM(awaited_by_entry, 1, awaiter_id); // steals ref + + if (PyList_Append(awaited_by_list, awaited_by_entry)) { + Py_DECREF(awaited_by_entry); + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to append awaited_by entry"); + return -1; + } + Py_DECREF(awaited_by_entry); + + return 0; +} + +/* ============================================================================ + * UPDATED process_single_task_node FUNCTION + * ============================================================================ */ + static int process_single_task_node( RemoteUnwinderObject *unwinder, @@ -1200,15 +1264,18 @@ process_single_task_node( set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to append result item in single task node"); return -1; } - Py_DECREF(result_item); - // Get back current_awaited_by reference for parse_task_awaited_by + // Get back current_awaited_by reference current_awaited_by = PyTuple_GET_ITEM(result_item, 2); - if (parse_task_awaited_by(unwinder, task_addr, current_awaited_by, 0) < 0) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse awaited_by in single task node"); + + // Parse current task's call stack with correct awaiter ID + if (parse_current_task_with_awaiter(unwinder, task_addr, current_awaited_by) < 0) { + Py_DECREF(result_item); + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse current task with awaiter"); return -1; } + Py_DECREF(result_item); return 0; error: From 7e390ebf2871625dad206f665c1ae8d3cced4eca Mon Sep 17 00:00:00 2001 From: Pablo Galindo Date: Wed, 11 Jun 2025 02:57:23 +0100 Subject: [PATCH 2/4] Fix printing --- Lib/asyncio/tools.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/Lib/asyncio/tools.py b/Lib/asyncio/tools.py index cd12dfaebe92c6..da47455ab768f0 100644 --- a/Lib/asyncio/tools.py +++ b/Lib/asyncio/tools.py @@ -49,6 +49,7 @@ def _build_tree(id2name, awaits): children = defaultdict(list) cor_names = defaultdict(dict) # (parent) -> {frame: node} cor_id_seq = count(1) + task_innermost = {} # task_key -> innermost coroutine node def _cor_node(parent_key, frame_name): """Return an existing or new (NodeType.COROUTINE, …) node under *parent_key*.""" @@ -61,21 +62,33 @@ def _cor_node(parent_key, frame_name): bucket[frame_name] = node_key return node_key - # First pass: build parent -> child relationships + # First pass: build coroutine stacks under each task and track innermost frames for parent_id, stack, child_id in awaits: - if parent_id != 0: # Skip root tasks (no parent) - parent_key = (NodeType.TASK, parent_id) - child_key = (NodeType.TASK, child_id) - if child_key not in children[parent_key]: - children[parent_key].append(child_key) - - # Second pass: add call stacks under each child task - for parent_id, stack, child_id in awaits: - # Add the call stack as coroutine nodes under the child task child_key = (NodeType.TASK, child_id) cur = child_key for frame in reversed(stack): # outer-most → inner-most cur = _cor_node(cur, frame) + # Track the innermost coroutine frame for this task + if stack: # Only if there's a stack + task_innermost[child_key] = cur + + # Second pass: build parent -> child task relationships + for parent_id, stack, child_id in awaits: + if parent_id != 0: # Skip root tasks (no parent) + parent_key = (NodeType.TASK, parent_id) + child_key = (NodeType.TASK, child_id) + + # Check if this is a task created from within a coroutine call + # by looking at the stack length - single frame suggests deep nesting + if len(stack) == 1 and parent_key in task_innermost: + # Single frame stack: child created from within innermost coroutine + innermost_parent = task_innermost[parent_key] + if child_key not in children[innermost_parent]: + children[innermost_parent].append(child_key) + else: + # Multi-frame stack: direct task-to-task relationship + if child_key not in children[parent_key]: + children[parent_key].append(child_key) return id2label, children From 20601ca360e3e22955c9dacd84c0843be421ee0b Mon Sep 17 00:00:00 2001 From: Pablo Galindo Date: Wed, 11 Jun 2025 03:02:57 +0100 Subject: [PATCH 3/4] Fix printing tests --- Lib/test/test_asyncio/test_tools.py | 108 ++++++++++++++-------------- 1 file changed, 54 insertions(+), 54 deletions(-) diff --git a/Lib/test/test_asyncio/test_tools.py b/Lib/test/test_asyncio/test_tools.py index ca923f783be053..452d7f729204af 100644 --- a/Lib/test/test_asyncio/test_tools.py +++ b/Lib/test/test_asyncio/test_tools.py @@ -91,61 +91,61 @@ [ "└── (T) Task-1", " ├── (T) root1", + " │ ├── main", + " │ │ └── __aexit__", + " │ │ └── _aexit", " │ ├── (T) child1_1", - " │ │ ├── (T) timer", - " │ │ │ ├── 110 awaiter:/path/to/app.py", - " │ │ │ │ └── 120 awaiter2:/path/to/app.py", - " │ │ │ │ └── 130 awaiter3:/path/to/app.py", - " │ │ │ └── 170 awaiterB:/path/to/app.py", - " │ │ │ └── 180 awaiterB2:/path/to/app.py", - " │ │ │ └── 190 awaiterB3:/path/to/app.py", - " │ │ └── bloch", - " │ │ └── blocho_caller", - " │ │ └── __aexit__", - " │ │ └── _aexit", - " │ ├── (T) child2_1", - " │ │ ├── (T) timer", - " │ │ │ ├── 110 awaiter:/path/to/app.py", - " │ │ │ │ └── 120 awaiter2:/path/to/app.py", - " │ │ │ │ └── 130 awaiter3:/path/to/app.py", - " │ │ │ └── 170 awaiterB:/path/to/app.py", - " │ │ │ └── 180 awaiterB2:/path/to/app.py", - " │ │ │ └── 190 awaiterB3:/path/to/app.py", - " │ │ └── bloch", - " │ │ └── blocho_caller", - " │ │ └── __aexit__", - " │ │ └── _aexit", - " │ └── main", - " │ └── __aexit__", - " │ └── _aexit", + " │ │ ├── bloch", + " │ │ │ └── blocho_caller", + " │ │ │ └── __aexit__", + " │ │ │ └── _aexit", + " │ │ └── (T) timer", + " │ │ ├── 110 awaiter:/path/to/app.py", + " │ │ │ └── 120 awaiter2:/path/to/app.py", + " │ │ │ └── 130 awaiter3:/path/to/app.py", + " │ │ └── 170 awaiterB:/path/to/app.py", + " │ │ └── 180 awaiterB2:/path/to/app.py", + " │ │ └── 190 awaiterB3:/path/to/app.py", + " │ └── (T) child2_1", + " │ ├── bloch", + " │ │ └── blocho_caller", + " │ │ └── __aexit__", + " │ │ └── _aexit", + " │ └── (T) timer", + " │ ├── 110 awaiter:/path/to/app.py", + " │ │ └── 120 awaiter2:/path/to/app.py", + " │ │ └── 130 awaiter3:/path/to/app.py", + " │ └── 170 awaiterB:/path/to/app.py", + " │ └── 180 awaiterB2:/path/to/app.py", + " │ └── 190 awaiterB3:/path/to/app.py", " └── (T) root2", + " ├── main", + " │ └── __aexit__", + " │ └── _aexit", " ├── (T) child1_2", - " │ ├── (T) timer", - " │ │ ├── 110 awaiter:/path/to/app.py", - " │ │ │ └── 120 awaiter2:/path/to/app.py", - " │ │ │ └── 130 awaiter3:/path/to/app.py", - " │ │ └── 170 awaiterB:/path/to/app.py", - " │ │ └── 180 awaiterB2:/path/to/app.py", - " │ │ └── 190 awaiterB3:/path/to/app.py", - " │ └── bloch", - " │ └── blocho_caller", - " │ └── __aexit__", - " │ └── _aexit", - " ├── (T) child2_2", - " │ ├── (T) timer", - " │ │ ├── 110 awaiter:/path/to/app.py", - " │ │ │ └── 120 awaiter2:/path/to/app.py", - " │ │ │ └── 130 awaiter3:/path/to/app.py", - " │ │ └── 170 awaiterB:/path/to/app.py", - " │ │ └── 180 awaiterB2:/path/to/app.py", - " │ │ └── 190 awaiterB3:/path/to/app.py", - " │ └── bloch", - " │ └── blocho_caller", - " │ └── __aexit__", - " │ └── _aexit", - " └── main", - " └── __aexit__", - " └── _aexit", + " │ ├── bloch", + " │ │ └── blocho_caller", + " │ │ └── __aexit__", + " │ │ └── _aexit", + " │ └── (T) timer", + " │ ├── 110 awaiter:/path/to/app.py", + " │ │ └── 120 awaiter2:/path/to/app.py", + " │ │ └── 130 awaiter3:/path/to/app.py", + " │ └── 170 awaiterB:/path/to/app.py", + " │ └── 180 awaiterB2:/path/to/app.py", + " │ └── 190 awaiterB3:/path/to/app.py", + " └── (T) child2_2", + " ├── bloch", + " │ └── blocho_caller", + " │ └── __aexit__", + " │ └── _aexit", + " └── (T) timer", + " ├── 110 awaiter:/path/to/app.py", + " │ └── 120 awaiter2:/path/to/app.py", + " │ └── 130 awaiter3:/path/to/app.py", + " └── 170 awaiterB:/path/to/app.py", + " └── 180 awaiterB2:/path/to/app.py", + " └── 190 awaiterB3:/path/to/app.py", ] ] ), @@ -734,9 +734,9 @@ def test_complex_tree(self): [ "└── (T) Task-1", " └── (T) Task-2", - " ├── (T) Task-3", - " │ └── main", " └── main", + " └── (T) Task-3", + " └── main", ] ] self.assertEqual(tools.build_async_tree(result), expected_output) From 16212224d38b67534d6f978ae344b8ad198cb64c Mon Sep 17 00:00:00 2001 From: Pablo Galindo Date: Wed, 11 Jun 2025 03:11:35 +0100 Subject: [PATCH 4/4] Fix printing tests more --- Lib/test/test_asyncio/test_tools.py | 183 ++++++++++++++++------------ 1 file changed, 107 insertions(+), 76 deletions(-) diff --git a/Lib/test/test_asyncio/test_tools.py b/Lib/test/test_asyncio/test_tools.py index 452d7f729204af..47bc719987c4f6 100644 --- a/Lib/test/test_asyncio/test_tools.py +++ b/Lib/test/test_asyncio/test_tools.py @@ -18,18 +18,22 @@ 3, "timer", [ - [[("awaiter3", "/path/to/app.py", 130), - ("awaiter2", "/path/to/app.py", 120), - ("awaiter", "/path/to/app.py", 110)], 4], - [[("awaiterB3", "/path/to/app.py", 190), - ("awaiterB2", "/path/to/app.py", 180), - ("awaiterB", "/path/to/app.py", 170)], 5], - [[("awaiterB3", "/path/to/app.py", 190), - ("awaiterB2", "/path/to/app.py", 180), - ("awaiterB", "/path/to/app.py", 170)], 6], - [[("awaiter3", "/path/to/app.py", 130), - ("awaiter2", "/path/to/app.py", 120), - ("awaiter", "/path/to/app.py", 110)], 7], + [ + [ + ("awaiter3", "/path/to/app.py", 130), + ("awaiter2", "/path/to/app.py", 120), + ("awaiter", "/path/to/app.py", 110), + ], + 2, + ], # Make timer child of Task-1 + [ + [ + ("awaiterB3", "/path/to/app.py", 190), + ("awaiterB2", "/path/to/app.py", 180), + ("awaiterB", "/path/to/app.py", 170), + ], + 2, + ], # Make timer child of Task-1 ], ), ( @@ -47,40 +51,60 @@ "child1_1", [ [ - ["_aexit", "__aexit__", "blocho_caller", "bloch"], + [ + "_aexit", + "__aexit__", + "blocho_caller", + "bloch", + ], 8, ] - ], + ], # child of root1 ), ( 6, "child2_1", [ [ - ["_aexit", "__aexit__", "blocho_caller", "bloch"], + [ + "_aexit", + "__aexit__", + "blocho_caller", + "bloch", + ], 8, ] - ], + ], # child of root1 ), ( 7, "child1_2", [ [ - ["_aexit", "__aexit__", "blocho_caller", "bloch"], + [ + "_aexit", + "__aexit__", + "blocho_caller", + "bloch", + ], 9, ] - ], + ], # child of root2 ), ( 5, "child2_2", [ [ - ["_aexit", "__aexit__", "blocho_caller", "bloch"], + [ + "_aexit", + "__aexit__", + "blocho_caller", + "bloch", + ], 9, ] - ], + ], # child of root2 ), ], ), @@ -90,62 +114,41 @@ [ [ "└── (T) Task-1", + " ├── (T) timer", + " │ ├── 110 awaiter:/path/to/app.py", + " │ │ └── 120 awaiter2:/path/to/app.py", + " │ │ └── 130 awaiter3:/path/to/app.py", + " │ └── 170 awaiterB:/path/to/app.py", + " │ └── 180 awaiterB2:/path/to/app.py", + " │ └── 190 awaiterB3:/path/to/app.py", " ├── (T) root1", " │ ├── main", " │ │ └── __aexit__", " │ │ └── _aexit", " │ ├── (T) child1_1", - " │ │ ├── bloch", - " │ │ │ └── blocho_caller", - " │ │ │ └── __aexit__", - " │ │ │ └── _aexit", - " │ │ └── (T) timer", - " │ │ ├── 110 awaiter:/path/to/app.py", - " │ │ │ └── 120 awaiter2:/path/to/app.py", - " │ │ │ └── 130 awaiter3:/path/to/app.py", - " │ │ └── 170 awaiterB:/path/to/app.py", - " │ │ └── 180 awaiterB2:/path/to/app.py", - " │ │ └── 190 awaiterB3:/path/to/app.py", + " │ │ └── bloch", + " │ │ └── blocho_caller", + " │ │ └── __aexit__", + " │ │ └── _aexit", " │ └── (T) child2_1", - " │ ├── bloch", - " │ │ └── blocho_caller", - " │ │ └── __aexit__", - " │ │ └── _aexit", - " │ └── (T) timer", - " │ ├── 110 awaiter:/path/to/app.py", - " │ │ └── 120 awaiter2:/path/to/app.py", - " │ │ └── 130 awaiter3:/path/to/app.py", - " │ └── 170 awaiterB:/path/to/app.py", - " │ └── 180 awaiterB2:/path/to/app.py", - " │ └── 190 awaiterB3:/path/to/app.py", + " │ └── bloch", + " │ └── blocho_caller", + " │ └── __aexit__", + " │ └── _aexit", " └── (T) root2", " ├── main", " │ └── __aexit__", " │ └── _aexit", " ├── (T) child1_2", - " │ ├── bloch", - " │ │ └── blocho_caller", - " │ │ └── __aexit__", - " │ │ └── _aexit", - " │ └── (T) timer", - " │ ├── 110 awaiter:/path/to/app.py", - " │ │ └── 120 awaiter2:/path/to/app.py", - " │ │ └── 130 awaiter3:/path/to/app.py", - " │ └── 170 awaiterB:/path/to/app.py", - " │ └── 180 awaiterB2:/path/to/app.py", - " │ └── 190 awaiterB3:/path/to/app.py", + " │ └── bloch", + " │ └── blocho_caller", + " │ └── __aexit__", + " │ └── _aexit", " └── (T) child2_2", - " ├── bloch", - " │ └── blocho_caller", - " │ └── __aexit__", - " │ └── _aexit", - " └── (T) timer", - " ├── 110 awaiter:/path/to/app.py", - " │ └── 120 awaiter2:/path/to/app.py", - " │ └── 130 awaiter3:/path/to/app.py", - " └── 170 awaiterB:/path/to/app.py", - " └── 180 awaiterB2:/path/to/app.py", - " └── 190 awaiterB3:/path/to/app.py", + " └── bloch", + " └── blocho_caller", + " └── __aexit__", + " └── _aexit", ] ] ), @@ -325,7 +328,12 @@ "child1_1", [ [ - ["_aexit", "__aexit__", "blocho_caller", "bloch"], + [ + "_aexit", + "__aexit__", + "blocho_caller", + "bloch", + ], 8, ] ], @@ -335,7 +343,12 @@ "child2_1", [ [ - ["_aexit", "__aexit__", "blocho_caller", "bloch"], + [ + "_aexit", + "__aexit__", + "blocho_caller", + "bloch", + ], 8, ] ], @@ -345,7 +358,12 @@ "child1_2", [ [ - ["_aexit", "__aexit__", "blocho_caller", "bloch"], + [ + "_aexit", + "__aexit__", + "blocho_caller", + "bloch", + ], 9, ] ], @@ -355,7 +373,12 @@ "child2_2", [ [ - ["_aexit", "__aexit__", "blocho_caller", "bloch"], + [ + "_aexit", + "__aexit__", + "blocho_caller", + "bloch", + ], 9, ] ], @@ -670,7 +693,10 @@ def test_only_independent_tasks_table(self): input_ = [(1, [(10, "taskA", []), (11, "taskB", [])])] self.assertEqual( tools.build_task_table(input_), - [[1, "0xa", "taskA", "", "", "0x0"], [1, "0xb", "taskB", "", "", "0x0"]], + [ + [1, "0xa", "taskA", "", "", "0x0"], + [1, "0xb", "taskB", "", "", "0x0"], + ], ) def test_single_task_tree(self): @@ -726,17 +752,21 @@ def test_complex_tree(self): [ (2, "Task-1", []), (3, "Task-2", [[["main"], 2]]), - (4, "Task-3", [[["main"], 3]]), + ( + 4, + "Task-3", + [[["main"], 2]], + ), # Both Task-2 and Task-3 children of Task-1 ], ) ] expected_output = [ [ "└── (T) Task-1", - " └── (T) Task-2", + " ├── (T) Task-2", + " │ └── main", + " └── (T) Task-3", " └── main", - " └── (T) Task-3", - " └── main", ] ] self.assertEqual(tools.build_async_tree(result), expected_output) @@ -818,7 +848,6 @@ def test_table_output_format(self): class TestAsyncioToolsEdgeCases(unittest.TestCase): - def test_task_awaits_self(self): """A task directly awaits itself - should raise a cycle.""" input_ = [(1, [(1, "Self-Awaiter", [[["loopback"], 1]])])] @@ -848,7 +877,7 @@ def test_duplicate_coroutine_frames(self): tree = tools.build_async_tree(input_) # Should create separate trees for each child self.assertEqual(len(tree), 2) - + flat = "\n".join(tree[0]) self.assertIn("frameA", flat) self.assertIn("Task-2", flat) @@ -868,7 +897,9 @@ def test_task_with_no_name(self): def test_tree_rendering_with_custom_emojis(self): """Pass custom emojis to the tree renderer.""" - input_ = [(1, [(1, "MainTask", [[["f1", "f2"], 2]]), (2, "SubTask", [])])] + input_ = [ + (1, [(1, "MainTask", [[["f1", "f2"], 2]]), (2, "SubTask", [])]) + ] tree = tools.build_async_tree(input_, task_emoji="🧵", cor_emoji="🔁") flat = "\n".join(tree[0]) self.assertIn("🧵 MainTask", flat)