diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index 9fff8e8bff91f7a..09a8c7100c857b7 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -508,6 +508,34 @@ async def rec(n): self.assertEqual(asyncio.run(rec(3)), ["rec"] * 4) + @skip_if_not_supported + def test_deep_awaiter_chain_is_walked_fully(self): + # gh-158688 + depth = 20000 + + async def root(): + await asyncio.sleep(0) + return [ + task.task_name + for info in RemoteUnwinder(os.getpid()).get_async_stack_trace() + for task in info.awaited_by + ] + + async def link(awaited): + return await awaited + + async def main(): + first = asyncio.create_task(root(), name="root") + top = first + for i in range(depth): + top = asyncio.create_task(link(top), name=f"a{i}") + second = asyncio.create_task(link(first), name="b") + return (await top), asyncio.current_task().get_name() + + names, main_name = asyncio.run(main()) + self.assertEqual(len(names), depth + 3) + self.assertIn(main_name, names) + @skip_if_not_supported @unittest.skipIf( sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, @@ -4550,9 +4578,9 @@ def recurse(n): sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, "Test only runs on Linux with process_vm_readv support", ) - def test_get_async_stack_trace_deep_task_waiter_chain_aborts(self): - """Test that a task waiter chain deeper than the limit aborts - the walk instead of overflowing the C stack.""" + def test_get_async_stack_trace_deep_task_waiter_chain_is_walked(self): + """Test that a task waiter chain longer than the walk used to allow + is walked to the end instead of overflowing the C stack.""" script_body = f"""\ import asyncio @@ -4569,10 +4597,12 @@ async def chain(n): """ with self._target_process(script_body) as (p, client_socket, _): _wait_for_signal(client_socket, b"ready") - self._assert_unwinder_limit_error( - lambda: RemoteUnwinder(p.pid).get_async_stack_trace(), - "Too many task waiters", - ) + tasks = [ + task + for info in get_async_stack_trace(p.pid) + for task in info.awaited_by + ] + self.assertEqual(len(tasks), self.TASK_WAITER_WALK_TASKS + 1) client_socket.sendall(b"done") @skip_if_not_supported diff --git a/Misc/NEWS.d/next/Library/2026-10-07-14-35-29.gh-issue-158688.X8kcFc.rst b/Misc/NEWS.d/next/Library/2026-10-07-14-35-29.gh-issue-158688.X8kcFc.rst new file mode 100644 index 000000000000000..10357b889a39861 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-07-14-35-29.gh-issue-158688.X8kcFc.rst @@ -0,0 +1,2 @@ +Fix a crash in the sampling profiler when dumping a program with a deep +chain of awaiting tasks. Patch by Timofei Ivankov. diff --git a/Modules/_remote_debugging/_remote_debugging.h b/Modules/_remote_debugging/_remote_debugging.h index 79fa4a92e745bd0..160658b5fdddc73 100644 --- a/Modules/_remote_debugging/_remote_debugging.h +++ b/Modules/_remote_debugging/_remote_debugging.h @@ -149,7 +149,6 @@ typedef enum _WIN32_THREADSTATE { #define MAX_LONG_DIGITS 64 /* Allows values up to ~2^1920 */ #define MAX_SET_TABLE_SIZE (1 << 20) /* 1 million entries max for set iteration */ #define MAX_FRAME_CHAIN_DEPTH (1024 + 512) /* Iteration bound for frame chain walks */ -#define MAX_TASK_WAITER_WALK_TASKS (1 << 14) /* Total-task bound for waiter walks */ #ifndef MAX #define MAX(a, b) ((a) > (b) ? (a) : (b)) diff --git a/Modules/_remote_debugging/asyncio.c b/Modules/_remote_debugging/asyncio.c index c8d79774a47a738..d35bf3db483e345 100644 --- a/Modules/_remote_debugging/asyncio.c +++ b/Modules/_remote_debugging/asyncio.c @@ -645,35 +645,54 @@ process_task_waiters( RemoteUnwinderObject *unwinder, PyObject *result ) { + // gh-158688: walk each task once, not once per path to it + PyObject *seen = PySet_New(NULL); + if (seen == NULL) { + set_exception_cause(unwinder, PyExc_MemoryError, "Failed to create visited task set"); + return -1; + } + for (Py_ssize_t i = 0; i < PyList_GET_SIZE(result); i++) { PyObject *task_info = PyList_GET_ITEM(result, i); + if (PySet_Add(seen, PyStructSequence_GET_ITEM(task_info, 0)) < 0) { + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to mark task as visited"); + goto error; + } PyObject *waiters = PyStructSequence_GET_ITEM(task_info, 3); for (Py_ssize_t j = 0; j < PyList_GET_SIZE(waiters); j++) { - if (PyList_GET_SIZE(result) >= MAX_TASK_WAITER_WALK_TASKS) { - PyErr_SetString(PyExc_RuntimeError, - "Too many task waiters (possible infinite loop)"); - set_exception_cause(unwinder, PyExc_RuntimeError, - "Task waiter walk size limit exceeded"); - return -1; - } PyObject *waiter = PyList_GET_ITEM(waiters, j); // CoroInfo item 1 holds the waiter task address stored by parse_task(). PyObject *task_id = PyStructSequence_GET_ITEM(waiter, 1); + Py_ssize_t seen_count = PySet_GET_SIZE(seen); + int marked = PySet_Add(seen, task_id); + if (marked < 0) { + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to mark task as visited"); + goto error; + } + if (PySet_GET_SIZE(seen) == seen_count) { + // already visited + continue; + } void *task_ptr = PyLong_AsVoidPtr(task_id); if (task_ptr == NULL && PyErr_Occurred()) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse waiter task ID"); - return -1; + goto error; } if (process_single_task_node( unwinder, (uintptr_t)task_ptr, NULL, result) < 0) { - return -1; + goto error; } } } + Py_DECREF(seen); return 0; + +error: + Py_DECREF(seen); + return -1; } /* ============================================================================