From 2e93255d0d29990c9ccb965c82b8cbf116214ffa Mon Sep 17 00:00:00 2001 From: "Miss Islington (bot)" <31488909+miss-islington@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:28:34 -0700 Subject: [PATCH 01/15] [3.15] gh-153364: Make frame, coroutine, and task-waiter chain walks iterative and bounded (GH-153365) (#158813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gh-153364: Make frame, coroutine, and task-waiter chain walks iterative and bounded (GH-153365) * let me declare single limit * use our new limit in process_frame_chain() * add it in parse_async_frame_chain() * parse_coro_chain() * NEWS * async in the message? * test * no race * process_task_awaited_by * process_task_awaited_by limit test * NEWS * MAX_TASK_WAITER_CHAIN_DEPTH * TASK_WAITER_CHAIN_DEPTH in test * TASK_WAITER_CHAIN_DEPTH 256 * prevent the drift with the comment * better naming, better style * MAX_TASK_WAITER_CHAIN_DEPTH comment * task-waiter iterative bfs walk * iterative coro-walk * nicer news * 1 << 14 * comment * unused read_Py_ssize_t * fix tombstones * simplify * correct msg * better test * news for tombstones * left-over from when testing buggy version * redundant new line (cherry picked from commit e0861c6ae70c7e6f16c28399bd2b28c521e11e84) Co-authored-by: Maurycy Pawłowski-Wieroński (cherry picked from commit 9e401cf7f5a53d8b46702e6c4bcb8849b9bf2829) --- Lib/test/test_external_inspection.py | 291 ++++++++++++++++-- ...-07-08-22-18-04.gh-issue-153364.JBFHEg.rst | 3 + Modules/_remote_debugging/_remote_debugging.h | 37 +-- Modules/_remote_debugging/asyncio.c | 214 +++++++------ Modules/_remote_debugging/frames.c | 6 +- Modules/_remote_debugging/object_reading.c | 1 - 6 files changed, 370 insertions(+), 182 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-08-22-18-04.gh-issue-153364.JBFHEg.rst diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index 797b13af59f0f1..d48a2b9eb6e02a 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -340,6 +340,40 @@ def _run_script_and_get_trace( finally: _cleanup_sockets(client_socket, server_socket) + @contextmanager + def _target_process(self, script_body): + """Context manager for running a target process with socket sync.""" + port = find_unused_port() + script = f"""\ +import socket +sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +sock.connect(('localhost', {port})) +{textwrap.dedent(script_body)} +""" + + with os_helper.temp_dir() as work_dir: + script_dir = os.path.join(work_dir, "script_pkg") + os.mkdir(script_dir) + + server_socket = _create_server_socket(port) + script_name = _make_test_script(script_dir, "script", script) + client_socket = None + + try: + with _managed_subprocess([sys.executable, script_name]) as p: + client_socket, _ = server_socket.accept() + server_socket.close() + server_socket = None + + def make_unwinder(cache_frames=True): + return RemoteUnwinder( + p.pid, all_threads=True, cache_frames=cache_frames + ) + + yield p, client_socket, make_unwinder + finally: + _cleanup_sockets(client_socket, server_socket) + def _find_frame_in_trace(self, stack_trace, predicate): """ Find a frame matching predicate in stack trace. @@ -1505,6 +1539,71 @@ def matches_awaited_by_pattern(task): finally: _cleanup_sockets(client_socket, server_socket) + @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_async_awaited_by_skips_set_tombstones(self): + script_body = """\ + import asyncio + + class RemovedTask(asyncio.Task): + def __hash__(self): + return 0 + + class RemainingTask(asyncio.Task): + def __hash__(self): + return 1 + + async def main(): + victim = asyncio.current_task() + victim.set_name("victim") + removed = RemovedTask( + asyncio.sleep(10_000), name="removed" + ) + remaining = RemainingTask( + asyncio.sleep(10_000), name="remaining" + ) + + asyncio.future_add_to_awaited_by(victim, removed) + asyncio.future_add_to_awaited_by(victim, remaining) + + # Removing hash 0 leaves a dummy in slot 0 before the only + # active entry in slot 1. It must not count toward the set's + # used entries. + asyncio.future_discard_from_awaited_by(victim, removed) + + sock.sendall(b"ready") + sock.recv(16) + + asyncio.run(main()) + """ + + with self._target_process(script_body) as ( + _, + client_socket, + make_unwinder, + ): + _wait_for_signal(client_socket, b"ready") + + for method_name in ( + "get_async_stack_trace", + "get_all_awaited_by", + ): + with self.subTest(method=method_name): + unwinder = make_unwinder(cache_frames=False) + stack_trace = getattr(unwinder, method_name)() + relationships = self._get_awaited_by_relationships( + stack_trace + ) + self.assertEqual( + relationships["victim"], + {"remaining"}, + ) + + client_socket.sendall(b"done") + @skip_if_not_supported @unittest.skipIf( sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, @@ -3128,40 +3227,6 @@ class TestFrameCaching(RemoteInspectionTestBase): All tests verify cache reuse via object identity checks (assertIs). """ - @contextmanager - def _target_process(self, script_body): - """Context manager for running a target process with socket sync.""" - port = find_unused_port() - script = f"""\ -import socket -sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) -sock.connect(('localhost', {port})) -{textwrap.dedent(script_body)} -""" - - with os_helper.temp_dir() as work_dir: - script_dir = os.path.join(work_dir, "script_pkg") - os.mkdir(script_dir) - - server_socket = _create_server_socket(port) - script_name = _make_test_script(script_dir, "script", script) - client_socket = None - - try: - with _managed_subprocess([sys.executable, script_name]) as p: - client_socket, _ = server_socket.accept() - server_socket.close() - server_socket = None - - def make_unwinder(cache_frames=True): - return RemoteUnwinder( - p.pid, all_threads=True, cache_frames=cache_frames - ) - - yield p, client_socket, make_unwinder - finally: - _cleanup_sockets(client_socket, server_socket) - def _get_frames_with_retry(self, unwinder, required_funcs): """Get frames containing required_funcs, with retry for transient errors.""" for _ in range(MAX_TRIES): @@ -4005,5 +4070,163 @@ def test_get_stats_disabled_raises(self): client_socket.sendall(b"done") +@requires_remote_subprocess_debugging() +class TestFrameChainLimits(RemoteInspectionTestBase): + """Frame chain walks abort instead of looping/overflowing on deep chains.""" + + # Limits plus one, to exceed them (must match MAX_FRAME_CHAIN_DEPTH / + # MAX_TASK_WAITER_WALK_TASKS from _remote_debugging.h) + FRAME_CHAIN_DEPTH = 1024 + 512 + 1 + TASK_WAITER_WALK_TASKS = 2**14 + 1 + + def _assert_unwinder_limit_error(self, unwind, expected_substring): + """Call unwind() until it raises the frame chain limit error. + + unwind must construct the RemoteUnwinder and call it, so that + transient RuntimeErrors from either step are retried; a successful + call means the limit never triggered and fails immediately. + """ + last_error = None + for _ in busy_retry(SHORT_TIMEOUT, error=False): + try: + unwind() + except TRANSIENT_ERRORS as e: + if expected_substring in str(e): + return + last_error = e + continue + self.fail( + "frame chain limit did not trigger; call returned a result" + ) + self.fail( + f"frame chain limit never raised; last transient error: " + f"{last_error!r}" + ) + + @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_get_stack_trace_deep_frame_chain_aborts(self): + """Test that a frame chain deeper than the limit aborts the + synchronous stack walk instead of walking it indefinitely.""" + script_body = f"""\ + import sys + sys.setrecursionlimit({self.FRAME_CHAIN_DEPTH * 2}) + + def recurse(n): + if n <= 0: + sock.sendall(b"ready") + sock.recv(16) + return + recurse(n - 1) + + recurse({self.FRAME_CHAIN_DEPTH}) + """ + 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_stack_trace(), + "Too many stack frames", + ) + client_socket.sendall(b"done") + + @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_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.""" + script_body = f"""\ + import asyncio + + async def chain(n): + if n <= 0: + sock.sendall(b"ready") + sock.recv(16) + return + + task = asyncio.create_task(chain(n - 1)) + await task + + asyncio.run(chain({self.TASK_WAITER_WALK_TASKS})) + """ + 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", + ) + client_socket.sendall(b"done") + + @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_get_async_stack_trace_deep_frame_chain_aborts(self): + """Test that a frame chain deeper than the limit aborts the async + stack walk instead of walking it indefinitely.""" + script_body = f"""\ + import sys, asyncio + sys.setrecursionlimit({self.FRAME_CHAIN_DEPTH * 2}) + + def recurse(n): + if n <= 0: + sock.sendall(b"ready") + sock.recv(16) + return + recurse(n - 1) + + async def deep(): + recurse({self.FRAME_CHAIN_DEPTH}) + + asyncio.run(deep()) + """ + 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 async stack frames", + ) + client_socket.sendall(b"done") + + @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_get_all_awaited_by_deep_coro_chain_aborts(self): + """Test that a coroutine await chain deeper than the limit aborts + the walk instead of overflowing the C stack.""" + script_body = f"""\ + import sys, asyncio + sys.setrecursionlimit({self.FRAME_CHAIN_DEPTH * 2}) + + async def chain(n): + if n <= 0: + await asyncio.sleep(10_000) + return + await chain(n - 1) + + async def main(): + task = asyncio.create_task(chain({self.FRAME_CHAIN_DEPTH})) + await asyncio.sleep(0) + sock.sendall(b"ready") + await task + + asyncio.run(main()) + """ + 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_all_awaited_by(), + "Too many coroutine frames", + ) + + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Library/2026-07-08-22-18-04.gh-issue-153364.JBFHEg.rst b/Misc/NEWS.d/next/Library/2026-07-08-22-18-04.gh-issue-153364.JBFHEg.rst new file mode 100644 index 00000000000000..b60a75b1c59738 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-08-22-18-04.gh-issue-153364.JBFHEg.rst @@ -0,0 +1,3 @@ +Make frame, coroutine and task-waiter walks iterative and bounded, avoiding +potential hangs and stack overflows. Fix asyncio task inspection when +awaited-by sets contain removed entries. Patch by Maurycy Pawłowski-Wieroński. diff --git a/Modules/_remote_debugging/_remote_debugging.h b/Modules/_remote_debugging/_remote_debugging.h index fa37fb7b2167ec..3aecf3982b95d7 100644 --- a/Modules/_remote_debugging/_remote_debugging.h +++ b/Modules/_remote_debugging/_remote_debugging.h @@ -147,6 +147,8 @@ typedef enum _WIN32_THREADSTATE { #define MAX_STACK_CHUNK_SIZE (16 * 1024 * 1024) /* 16 MB max for stack chunks */ #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)) @@ -478,12 +480,6 @@ typedef int (*thread_processor_func)( void *context ); -typedef int (*set_entry_processor_func)( - RemoteUnwinderObject *unwinder, - uintptr_t key_addr, - void *context -); - typedef int (*interpreter_processor_func)( RuntimeOffsets *offsets, uintptr_t interpreter_state_addr, @@ -528,7 +524,6 @@ extern int validate_debug_offsets(struct _Py_DebugOffsets *debug_offsets); * ============================================================================ */ extern int read_ptr(RemoteUnwinderObject *unwinder, uintptr_t address, uintptr_t *result); -extern int read_Py_ssize_t(RemoteUnwinderObject *unwinder, uintptr_t address, Py_ssize_t *result); extern int read_char(RemoteUnwinderObject *unwinder, uintptr_t address, char *result); extern int read_py_ptr(RemoteUnwinderObject *unwinder, uintptr_t address, uintptr_t *ptr_addr); @@ -729,12 +724,6 @@ extern int parse_task( PyObject *render_to ); -extern int parse_coro_chain( - RemoteUnwinderObject *unwinder, - uintptr_t coro_address, - PyObject *render_to -); - extern int parse_async_frame_chain( RemoteUnwinderObject *unwinder, PyObject *calls, @@ -742,22 +731,6 @@ extern int parse_async_frame_chain( uintptr_t running_task_code_obj ); -/* Set iteration */ -extern int iterate_set_entries( - RemoteUnwinderObject *unwinder, - uintptr_t set_addr, - set_entry_processor_func processor, - void *context -); - -/* Task awaited_by processing */ -extern int process_task_awaited_by( - RemoteUnwinderObject *unwinder, - uintptr_t task_address, - set_entry_processor_func processor, - void *context -); - extern int process_single_task_node( RemoteUnwinderObject *unwinder, uintptr_t task_addr, @@ -765,12 +738,6 @@ extern int process_single_task_node( PyObject *result ); -extern int process_task_and_waiters( - RemoteUnwinderObject *unwinder, - uintptr_t task_addr, - PyObject *result -); - extern int find_running_task_in_thread( RemoteUnwinderObject *unwinder, uintptr_t thread_state_addr, diff --git a/Modules/_remote_debugging/asyncio.c b/Modules/_remote_debugging/asyncio.c index 44a9a3cbce0061..aa3f5b638931f8 100644 --- a/Modules/_remote_debugging/asyncio.c +++ b/Modules/_remote_debugging/asyncio.c @@ -116,12 +116,11 @@ ensure_async_debug_offsets(RemoteUnwinderObject *unwinder) * SET ITERATION FUNCTIONS * ============================================================================ */ -int +static int iterate_set_entries( RemoteUnwinderObject *unwinder, uintptr_t set_addr, - set_entry_processor_func processor, - void *context + PyObject *awaited_by ) { char set_object[SIZEOF_SET_OBJ]; if (_Py_RemoteDebug_PagedReadRemoteMemory(&unwinder->handle, set_addr, @@ -146,28 +145,22 @@ iterate_set_entries( Py_ssize_t i = 0; Py_ssize_t els = 0; while (i < set_len && els < num_els) { - uintptr_t key_addr; - if (read_py_ptr(unwinder, table_ptr, &key_addr) < 0) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read set entry key"); + setentry entry; + if (_Py_RemoteDebug_PagedReadRemoteMemory( + &unwinder->handle, table_ptr, sizeof(entry), &entry) < 0) + { + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read set entry"); return -1; } - if ((void*)key_addr != NULL) { - Py_ssize_t ref_cnt; - if (read_Py_ssize_t(unwinder, table_ptr, &ref_cnt) < 0) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read set entry ref count"); + uintptr_t key_addr = (uintptr_t)entry.key; + if (key_addr != 0 && entry.hash != -1) { + if (parse_task(unwinder, key_addr, awaited_by) < 0) { return -1; } - - if (ref_cnt) { - // Process this valid set entry - if (processor(unwinder, key_addr, context) < 0) { - return -1; - } - els++; - } + els++; } - table_ptr += sizeof(void*) * 2; + table_ptr += sizeof(entry); i++; } @@ -248,12 +241,14 @@ parse_task_name( * ============================================================================ */ static int -handle_yield_from_frame( +get_awaited_coro_address( RemoteUnwinderObject *unwinder, uintptr_t gi_iframe_addr, uintptr_t gen_type_addr, - PyObject *render_to + uintptr_t *next_coro ) { + *next_coro = 0; + // Read the entire interpreter frame at once char iframe[SIZEOF_INTERP_FRAME]; int err = _Py_RemoteDebug_PagedReadRemoteMemory( @@ -309,11 +304,7 @@ handle_yield_from_frame( doesn't match the type of whatever it points to in its cr_await. */ - err = parse_coro_chain(unwinder, gi_await_addr, render_to); - if (err) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse coroutine chain in yield_from"); - return -1; - } + *next_coro = gi_await_addr; } } } @@ -321,7 +312,7 @@ handle_yield_from_frame( return 0; } -int +static int parse_coro_chain( RemoteUnwinderObject *unwinder, uintptr_t coro_address, @@ -329,49 +320,64 @@ parse_coro_chain( ) { assert((void*)coro_address != NULL); - // Read the entire generator object at once - char gen_object[SIZEOF_GEN_OBJ]; - int err = _Py_RemoteDebug_PagedReadRemoteMemory( - &unwinder->handle, - coro_address, - SIZEOF_GEN_OBJ, - gen_object); - if (err < 0) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read generator object in coro chain"); - return -1; - } + for (size_t depth = 0; (void*)coro_address != NULL; depth++) { + if (depth >= MAX_FRAME_CHAIN_DEPTH) { + PyErr_SetString(PyExc_RuntimeError, + "Too many coroutine frames (possible infinite loop)"); + set_exception_cause(unwinder, PyExc_RuntimeError, + "Coroutine chain depth limit exceeded"); + return -1; + } - int8_t frame_state = GET_MEMBER(int8_t, gen_object, unwinder->debug_offsets.gen_object.gi_frame_state); - if (frame_state == FRAME_CLEARED) { - return 0; - } + // Read the entire generator object at once + char gen_object[SIZEOF_GEN_OBJ]; + int err = _Py_RemoteDebug_PagedReadRemoteMemory( + &unwinder->handle, + coro_address, + SIZEOF_GEN_OBJ, + gen_object); + if (err < 0) { + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read generator object in coro chain"); + return -1; + } - uintptr_t gen_type_addr = GET_MEMBER(uintptr_t, gen_object, unwinder->debug_offsets.pyobject.ob_type); + int8_t frame_state = GET_MEMBER(int8_t, gen_object, unwinder->debug_offsets.gen_object.gi_frame_state); + if (frame_state == FRAME_CLEARED) { + return 0; + } - PyObject* name = NULL; + uintptr_t gen_type_addr = GET_MEMBER(uintptr_t, gen_object, unwinder->debug_offsets.pyobject.ob_type); - // Parse the previous frame using the gi_iframe from local copy - uintptr_t prev_frame; - uintptr_t gi_iframe_addr = coro_address + (uintptr_t)unwinder->debug_offsets.gen_object.gi_iframe; - uintptr_t address_of_code_object = 0; - if (parse_frame_object(unwinder, &name, gi_iframe_addr, &address_of_code_object, &prev_frame) < 0) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse frame object in coro chain"); - return -1; - } + PyObject* name = NULL; - if (!name) { - return 0; - } + // Parse the previous frame using the gi_iframe from local copy + uintptr_t prev_frame; + uintptr_t gi_iframe_addr = coro_address + (uintptr_t)unwinder->debug_offsets.gen_object.gi_iframe; + uintptr_t address_of_code_object = 0; + if (parse_frame_object(unwinder, &name, gi_iframe_addr, &address_of_code_object, &prev_frame) < 0) { + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to parse frame object in coro chain"); + return -1; + } + + if (!name) { + return 0; + } - if (PyList_Append(render_to, name)) { + if (PyList_Append(render_to, name)) { + Py_DECREF(name); + set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to append frame to coro chain"); + return -1; + } Py_DECREF(name); - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to append frame to coro chain"); - return -1; - } - Py_DECREF(name); - if (frame_state == FRAME_SUSPENDED_YIELD_FROM) { - return handle_yield_from_frame(unwinder, gi_iframe_addr, gen_type_addr, render_to); + if (frame_state != FRAME_SUSPENDED_YIELD_FROM) { + return 0; + } + + if (get_awaited_coro_address(unwinder, gi_iframe_addr, gen_type_addr, + &coro_address) < 0) { + return -1; + } } return 0; @@ -513,35 +519,11 @@ parse_task( * TASK AWAITED_BY PROCESSING * ============================================================================ */ -// Forward declaration for mutual recursion -static int process_waiter_task(RemoteUnwinderObject *unwinder, uintptr_t key_addr, void *context); - -// Processor function for parsing tasks in sets -static int -process_task_parser( - RemoteUnwinderObject *unwinder, - uintptr_t key_addr, - void *context -) { - PyObject *awaited_by = (PyObject *)context; - return parse_task(unwinder, key_addr, awaited_by); -} - static int parse_task_awaited_by( RemoteUnwinderObject *unwinder, uintptr_t task_address, PyObject *awaited_by -) { - return process_task_awaited_by(unwinder, task_address, process_task_parser, awaited_by); -} - -int -process_task_awaited_by( - RemoteUnwinderObject *unwinder, - uintptr_t task_address, - set_entry_processor_func processor, - void *context ) { // Read the entire TaskObj at once char task_obj[SIZEOF_TASK_OBJ]; @@ -560,10 +542,10 @@ process_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) { - return iterate_set_entries(unwinder, task_ab_addr, processor, context); + return iterate_set_entries(unwinder, task_ab_addr, awaited_by); } else { // Single task waiting - return processor(unwinder, task_ab_addr, context); + return parse_task(unwinder, task_ab_addr, awaited_by); } } @@ -658,30 +640,40 @@ process_single_task_node( return -1; } -int -process_task_and_waiters( +static int +process_task_waiters( RemoteUnwinderObject *unwinder, - uintptr_t task_addr, PyObject *result ) { - // First, add this task to the result - if (process_single_task_node(unwinder, task_addr, NULL, result) < 0) { - return -1; + for (Py_ssize_t i = 0; i < PyList_GET_SIZE(result); i++) { + PyObject *task_info = PyList_GET_ITEM(result, i); + 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); + 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; + } + if (process_single_task_node( + unwinder, (uintptr_t)task_ptr, NULL, result) < 0) + { + return -1; + } + } } - // Now find all tasks that are waiting for this task and process them - return process_task_awaited_by(unwinder, task_addr, process_waiter_task, result); -} - -// Processor function for task waiters -static int -process_waiter_task( - RemoteUnwinderObject *unwinder, - uintptr_t key_addr, - void *context -) { - PyObject *result = (PyObject *)context; - return process_task_and_waiters(unwinder, key_addr, result); + return 0; } /* ============================================================================ @@ -776,7 +768,13 @@ parse_async_frame_chain( return -1; } + size_t frame_count = 0; while ((void*)address_of_current_frame != NULL) { + if (++frame_count > MAX_FRAME_CHAIN_DEPTH) { + PyErr_SetString(PyExc_RuntimeError, "Too many async stack frames (possible infinite loop)"); + set_exception_cause(unwinder, PyExc_RuntimeError, "Async frame chain iteration limit exceeded"); + return -1; + } PyObject* frame_info = NULL; uintptr_t address_of_code_object; int res = parse_frame_object( @@ -978,7 +976,7 @@ process_running_task_chain( } // Now find all tasks that are waiting for this task and process them - if (process_task_awaited_by(unwinder, running_task_addr, process_waiter_task, result) < 0) { + if (process_task_waiters(unwinder, result) < 0) { return -1; } diff --git a/Modules/_remote_debugging/frames.c b/Modules/_remote_debugging/frames.c index 46968acc6ff1fe..fde3474a053919 100644 --- a/Modules/_remote_debugging/frames.c +++ b/Modules/_remote_debugging/frames.c @@ -305,9 +305,7 @@ process_frame_chain( uintptr_t frame_addr = ctx->frame_addr; uintptr_t prev_frame_addr = 0; uintptr_t last_frame_addr = 0; - const size_t MAX_FRAMES = 1024 + 512; size_t frame_count = 0; - assert(MAX_FRAMES > 0 && MAX_FRAMES < 10000); ctx->stopped_at_cached_frame = 0; ctx->last_frame_visited = 0; @@ -318,12 +316,12 @@ process_frame_chain( uintptr_t stackpointer = 0; last_frame_addr = frame_addr; - if (++frame_count > MAX_FRAMES) { + if (++frame_count > MAX_FRAME_CHAIN_DEPTH) { PyErr_SetString(PyExc_RuntimeError, "Too many stack frames (possible infinite loop)"); set_exception_cause(unwinder, PyExc_RuntimeError, "Frame chain iteration limit exceeded"); return -1; } - assert(frame_count <= MAX_FRAMES); + assert(frame_count <= MAX_FRAME_CHAIN_DEPTH); if (ctx->chunks && ctx->chunks->count > 0) { if (parse_frame_from_chunks(unwinder, &frame, frame_addr, &next_frame_addr, &stackpointer, ctx->chunks) == 0) { diff --git a/Modules/_remote_debugging/object_reading.c b/Modules/_remote_debugging/object_reading.c index 56d9f80a80fd0f..743970db4ddc88 100644 --- a/Modules/_remote_debugging/object_reading.c +++ b/Modules/_remote_debugging/object_reading.c @@ -25,7 +25,6 @@ read_##type_name(RemoteUnwinderObject *unwinder, uintptr_t address, c_type *resu } DEFINE_MEMORY_READER(ptr, uintptr_t, "Failed to read pointer from remote memory") -DEFINE_MEMORY_READER(Py_ssize_t, Py_ssize_t, "Failed to read Py_ssize_t from remote memory") DEFINE_MEMORY_READER(char, char, "Failed to read char from remote memory") int From fd0dd345398a63692167f2edaa87cee9a9487de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurycy=20Paw=C5=82owski-Wiero=C5=84ski?= Date: Mon, 5 Oct 2026 11:21:24 +0200 Subject: [PATCH 02/15] [3.15] gh-155811: Add a seqcount to `gc_stats` to prevent torn reads (GH-155828) (#158829) * update_seq * no need for XCHGL, MOVL is enough? * gh-155811: Retry an inconsistent GC snapshot once --------- (cherry picked from commit 5fecd448bb120378978a37dde65dfce233d88c0d) Co-authored-by: Pablo Galindo Salgado Co-authored-by: Claude Fable 5.1 (cherry picked from commit 028124039c276f73fbc26036f1629c91be4c2891) --- Include/internal/pycore_interp_structs.h | 1 + ...-08-15-10-20-40.gh-issue-155811.knP-YB.rst | 3 ++ Modules/_remote_debugging/gc_stats.c | 42 ++++++++++++++++--- Python/gc.c | 10 ++++- Python/gc_free_threading.c | 7 ++++ 5 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-15-10-20-40.gh-issue-155811.knP-YB.rst diff --git a/Include/internal/pycore_interp_structs.h b/Include/internal/pycore_interp_structs.h index 58a15eb87d2aad..36d05efc4ce4b6 100644 --- a/Include/internal/pycore_interp_structs.h +++ b/Include/internal/pycore_interp_structs.h @@ -219,6 +219,7 @@ struct gc_old_stats_buffer { struct gc_stats { struct gc_young_stats_buffer young; struct gc_old_stats_buffer old[2]; + uint32_t update_seq; }; struct _gc_runtime_state { diff --git a/Misc/NEWS.d/next/Library/2026-08-15-10-20-40.gh-issue-155811.knP-YB.rst b/Misc/NEWS.d/next/Library/2026-08-15-10-20-40.gh-issue-155811.knP-YB.rst new file mode 100644 index 00000000000000..2032fd74380db8 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-15-10-20-40.gh-issue-155811.knP-YB.rst @@ -0,0 +1,3 @@ +Add a sequence counter to GC statistics to prevent :mod:`!_remote_debugging` +returning inconsistent snapshots caused by non-atomic reads. Patch by Maurycy +Pawłowski-Wieroński. diff --git a/Modules/_remote_debugging/gc_stats.c b/Modules/_remote_debugging/gc_stats.c index d5d05edb8ecf5e..23fa879b503283 100644 --- a/Modules/_remote_debugging/gc_stats.c +++ b/Modules/_remote_debugging/gc_stats.c @@ -103,12 +103,42 @@ get_gc_stats_from_interpreter_state(RuntimeOffsets *offsets, } struct gc_stats stats; - if (_Py_RemoteDebug_ReadRemoteMemory(&offsets->handle, - gc_stats_addr, - sizeof(stats), - &stats) < 0) { - set_exception_cause(offsets, PyExc_RuntimeError, "Failed to read GC state"); - return -1; + uintptr_t sequence_address = gc_stats_addr + + offsetof(struct gc_stats, update_seq); + /* A short GC update may finish before a second attempt. */ + for (int attempt = 0; attempt < 2; attempt++) { + uint32_t before; + if (_Py_RemoteDebug_ReadRemoteMemory(&offsets->handle, + sequence_address, + sizeof(before), &before) < 0) { + set_exception_cause(offsets, PyExc_RuntimeError, + "Failed to read GC update sequence"); + return -1; + } + if (_Py_RemoteDebug_ReadRemoteMemory(&offsets->handle, + gc_stats_addr, + sizeof(stats), + &stats) < 0) { + set_exception_cause(offsets, PyExc_RuntimeError, "Failed to read GC state"); + return -1; + } + + uint32_t after; + if (_Py_RemoteDebug_ReadRemoteMemory(&offsets->handle, + sequence_address, + sizeof(after), &after) < 0) { + set_exception_cause(offsets, PyExc_RuntimeError, + "Failed to read GC update sequence"); + return -1; + } + if (before == after && before == stats.update_seq && !(after & 1)) { + break; + } + if (attempt == 1) { + PyErr_SetString(PyExc_RuntimeError, + "GC stats changed while being read; retry later"); + return -1; + } } if (read_gc_stats(&stats, iid, ctx->result, diff --git a/Python/gc.c b/Python/gc.c index 201c621bcc3cb9..bb20dae5a6543f 100644 --- a/Python/gc.c +++ b/Python/gc.c @@ -1399,6 +1399,13 @@ gc_get_prev_stats(GCState *gcstate, int gen) static void add_stats(GCState *gcstate, int gen, struct gc_generation_stats *stats) { + struct gc_stats *generation_stats = gcstate->generation_stats; + uint32_t seq = _Py_atomic_load_uint32_relaxed(&generation_stats->update_seq); + assert((seq & 1) == 0); + /* Odd seq tells the reader that an update is in progress. */ + _Py_atomic_store_uint32_relaxed(&generation_stats->update_seq, seq + 1); + _Py_atomic_fence_seq_cst(); + struct gc_generation_stats *prev_stats = gc_get_prev_stats(gcstate, gen); struct gc_generation_stats *cur_stats = gc_get_stats(gcstate, gen); @@ -1412,9 +1419,8 @@ add_stats(GCState *gcstate, int gen, struct gc_generation_stats *stats) cur_stats->duration += stats->duration; cur_stats->heap_size = stats->heap_size; - /* Publish ts_stop last so remote readers do not select a partially - updated stats record as the latest collection. */ cur_stats->ts_stop = stats->ts_stop; + _Py_atomic_store_uint32_release(&generation_stats->update_seq, seq + 2); } /* This is the main function. Read this to understand how the diff --git a/Python/gc_free_threading.c b/Python/gc_free_threading.c index 8e27649bfd6941..f408f239ab1693 100644 --- a/Python/gc_free_threading.c +++ b/Python/gc_free_threading.c @@ -2282,6 +2282,12 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) } /* Update stats */ + struct gc_stats *generation_stats = gcstate->generation_stats; + uint32_t seq = _Py_atomic_load_uint32_relaxed(&generation_stats->update_seq); + assert((seq & 1) == 0); + /* Odd seq tells the reader that an update is in progress. */ + _Py_atomic_store_uint32_relaxed(&generation_stats->update_seq, seq + 1); + _Py_atomic_fence_seq_cst(); struct gc_generation_stats *stats = get_stats(gcstate, generation); stats->ts_start = start; stats->ts_stop = stop; @@ -2290,6 +2296,7 @@ gc_collect_main(PyThreadState *tstate, int generation, _PyGC_Reason reason) stats->uncollectable += n; stats->duration += duration; stats->candidates += state.candidates; + _Py_atomic_store_uint32_release(&generation_stats->update_seq, seq + 2); GC_STAT_ADD(generation, objects_collected, m); #ifdef Py_STATS From 65cdbbfc3578010463d26c3006c87435989da868 Mon Sep 17 00:00:00 2001 From: "Miss Islington (bot)" <31488909+miss-islington@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:40:35 -0700 Subject: [PATCH 03/15] [3.15] gh-151292: `_remote_debugging`: Do not corrupt the binary file when hitting `OverflowError` (GH-152892) (#158830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gh-151292: `_remote_debugging`: Do not corrupt the binary file when hitting `OverflowError` (GH-152892) * the kolektor * test * better test * news * =Q, move const to the base, not self.running * gh-151292: Track binary writer finalization state --------- (cherry picked from commit f839c061d383bc87c3456d19bbdcbc1c30ed399b) Co-authored-by: Maurycy Pawłowski-Wieroński Co-authored-by: Pablo Galindo Salgado (cherry picked from commit 8a7c23f316a6bdb3cc60c6920d465d2a81d3cf35) --- Lib/profiling/sampling/binary_collector.py | 22 ++- .../test_binary_format.py | 138 ++++++++++++++++++ ...-07-02-15-26-51.gh-issue-151292.nmnQlp.rst | 3 + Modules/_remote_debugging/binary_io.h | 9 ++ Modules/_remote_debugging/binary_io_writer.c | 30 +++- Modules/_remote_debugging/module.c | 25 +++- 6 files changed, 213 insertions(+), 14 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-02-15-26-51.gh-issue-151292.nmnQlp.rst diff --git a/Lib/profiling/sampling/binary_collector.py b/Lib/profiling/sampling/binary_collector.py index 7a35044b1ee1d7..3d6d988077cfa2 100644 --- a/Lib/profiling/sampling/binary_collector.py +++ b/Lib/profiling/sampling/binary_collector.py @@ -1,5 +1,6 @@ """Thin Python wrapper around C binary writer for profiling data.""" +import sys import time import _remote_debugging @@ -81,6 +82,7 @@ def __init__(self, filename, sample_interval_usec, *, skip_idle=False, self.filename = filename self.sample_interval_usec = sample_interval_usec self.skip_idle = skip_idle + self.running = True compression_type = _resolve_compression(compression) start_time_us = int(time.monotonic() * 1_000_000) @@ -102,9 +104,19 @@ def collect(self, stack_frames, timestamp_us=None): timestamp_us: Optional timestamp in microseconds. If not provided, uses time.monotonic() to generate one. """ + if not self.running: + return if timestamp_us is None: timestamp_us = int(time.monotonic() * 1_000_000) - self._writer.write_sample(stack_frames, timestamp_us) + try: + self._writer.write_sample(stack_frames, timestamp_us) + except OverflowError as e: + if not self._writer.limit_reached: + raise + self.running = False + print(f"Warning: {e}; stopping early and keeping the data " + "collected so far.", + file=sys.stderr) def collect_failed_sample(self): """Record a failed sample attempt (no-op for binary format).""" @@ -143,9 +155,5 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit - finalize unless there was an error.""" - if exc_type is None: - self._writer.finalize() - else: - self._writer.close() - return False + """Finalize if the writer can still produce a valid file.""" + return self._writer.__exit__(exc_type, exc_val, exc_tb) diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py b/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py index ff944b3163ec3e..d1ffc17a21573d 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py @@ -9,6 +9,8 @@ import unittest from collections import defaultdict +from test.support import captured_stderr + try: import _remote_debugging from _remote_debugging import ( @@ -1031,6 +1033,142 @@ def test_writer_total_samples_after_close_returns_zero(self): w.close() self.assertEqual(w.total_samples, 0) + def test_binary_collector_stops_gracefully_on_overflow(self): + """OverflowError from the writer stops collection via the running + protocol instead of propagating and corrupting the file. + See gh-151292.""" + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + + collector = BinaryCollector(filename, 1000, compression="none") + self.assertTrue(collector.running) + + sample = [ + make_interpreter(0, [make_thread(1, [make_frame("a.py", 1, "f")])]) + ] + + # Collect real samples first, then hit the limit. + for i in range(3): + collector.collect(sample, timestamp_us=(i + 1) * 1000) + self.assertTrue(collector.running) + + bad = [make_interpreter(2**32, sample[0].threads)] + with captured_stderr() as stderr: + collector.collect(bad, timestamp_us=4000) + collector.collect(sample, timestamp_us=5000) + + self.assertFalse(collector.running) + self.assertTrue(collector._writer.limit_reached) + self.assertEqual(stderr.getvalue().count("Warning:"), 1) + self.assertIn("interpreter_id", stderr.getvalue()) + + collector.export(None) + + self.assertEqual(collector.total_samples, 3) + + reader_collector = RawCollector() + with BinaryReader(filename) as reader: + self.assertEqual(reader.replay_samples(reader_collector), 3) + + def test_interpreter_id_overflow_rejected(self): + """An interpreter_id wider than u32 raises OverflowError before any + writer state is mutated: subsequent valid samples are still accepted + and finalize produces a readable file.""" + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + + good = [ + make_interpreter(0, [make_thread(1, [make_frame("a.py", 1, "f")])]) + ] + bad = [ + make_interpreter(2**32, [make_thread(1, [make_frame("a.py", 1, "f")])]) + ] + + writer = _remote_debugging.BinaryWriter(filename, 1000, 0, compression=0) + writer.write_sample(good, 1000) + with self.assertRaises(OverflowError): + writer.write_sample(bad, 2000) + writer.write_sample(good, 3000) + writer.finalize() + self.assertEqual(writer.total_samples, 2) + + reader_collector = RawCollector() + with BinaryReader(filename) as reader: + self.assertEqual(reader.replay_samples(reader_collector), 2) + + def test_writer_finalizes_after_format_limit(self): + for compression in (0, 1) if ZSTD_AVAILABLE else (0,): + with self.subTest(compression=compression): + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + good = [make_interpreter(0, [ + make_thread(1, [make_frame("a.py", 1, "f")]) + ])] + bad = [make_interpreter(2**32, good[0].threads)] + writer = _remote_debugging.BinaryWriter( + filename, 1000, 0, compression=compression + ) + with self.assertRaises(OverflowError): + with writer: + writer.write_sample(good, 1000) + writer.write_sample(good, 2000) + # The first interpreter is committed before the limit. + writer.write_sample(good + bad, 3000) + self.assertEqual(writer.total_samples, 3) + with BinaryReader(filename) as reader: + self.assertEqual(reader.replay_samples(RawCollector()), 3) + + def test_collector_does_not_swallow_unrelated_overflow(self): + class BadStatus: + def __index__(self): + raise OverflowError("status conversion failed") + + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + collector = BinaryCollector(filename, 1000, compression="none") + self.addCleanup(collector._writer.close) + sample = [make_interpreter(0, [make_thread(1, [], BadStatus())])] + with captured_stderr() as stderr: + with self.assertRaisesRegex(OverflowError, "status conversion failed"): + collector.collect(sample, timestamp_us=1000) + self.assertEqual(stderr.getvalue(), "") + self.assertFalse(collector._writer.limit_reached) + with self.assertRaisesRegex(ValueError, "broken"): + collector.export() + with self.assertRaisesRegex(ValueError, "broken"): + collector._writer.write_sample([], 2000) + # Closing a broken writer must not attempt to finalize it. + collector.__exit__(None, None, None) + + def test_collector_finalizes_after_external_exception(self): + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + with self.assertRaisesRegex(RuntimeError, "sampling failed"): + with BinaryCollector(filename, 1000, compression="none") as collector: + collector.collect([make_interpreter(0, [make_thread(1, [])])]) + raise RuntimeError("sampling failed") + self.assertEqual(collector.total_samples, 1) + with BinaryReader(filename) as reader: + self.assertEqual(reader.replay_samples(RawCollector()), 1) + + @unittest.skipUnless(os.path.exists("/dev/full"), "requires /dev/full") + def test_finalize_failure_breaks_writer(self): + writer = _remote_debugging.BinaryWriter("/dev/full", 1000, 0) + self.addCleanup(writer.close) + writer.write_sample([make_interpreter(0, [make_thread(1, [])])], 1000) + with self.assertRaises(OSError): + writer.finalize() + self.assertFalse(writer.limit_reached) + with self.assertRaisesRegex(ValueError, "broken"): + writer.finalize() + with self.assertRaisesRegex(ValueError, "broken"): + writer.write_sample([], 2000) + class TestBinaryFormatValidation(BinaryFormatTestBase): """Tests for malformed binary files.""" diff --git a/Misc/NEWS.d/next/Library/2026-07-02-15-26-51.gh-issue-151292.nmnQlp.rst b/Misc/NEWS.d/next/Library/2026-07-02-15-26-51.gh-issue-151292.nmnQlp.rst new file mode 100644 index 00000000000000..8825a70047eedd --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-02-15-26-51.gh-issue-151292.nmnQlp.rst @@ -0,0 +1,3 @@ +Fix ``profiling.sampling --binary`` leaving unreadable profile files when +the binary format reaches a size limit. Preserve collected samples when the +writer can still finalize safely. Patch by Maurycy Pawłowski-Wieroński. diff --git a/Modules/_remote_debugging/binary_io.h b/Modules/_remote_debugging/binary_io.h index c936d3372e5acd..6a2c5b795823e1 100644 --- a/Modules/_remote_debugging/binary_io.h +++ b/Modules/_remote_debugging/binary_io.h @@ -290,9 +290,18 @@ typedef struct { size_t pending_rle_samples; } ThreadEntry; +/* Limit errors occur before emitting an incomplete sample. Other write + * failures may leave partial records and must prevent finalization. */ +typedef enum { + BINARY_WRITER_OPEN, + BINARY_WRITER_LIMIT_REACHED, + BINARY_WRITER_BROKEN, +} BinaryWriterState; + /* Main binary writer structure */ typedef struct { FILE *fp; + BinaryWriterState state; /* Write buffer for batched I/O */ uint8_t *write_buffer; diff --git a/Modules/_remote_debugging/binary_io_writer.c b/Modules/_remote_debugging/binary_io_writer.c index 6af81515e7131d..4cf81ca3cccd41 100644 --- a/Modules/_remote_debugging/binary_io_writer.c +++ b/Modules/_remote_debugging/binary_io_writer.c @@ -371,6 +371,7 @@ writer_intern_string(BinaryWriter *writer, PyObject *string, uint32_t *index) } if (writer->string_count >= UINT32_MAX) { + writer->state = BINARY_WRITER_LIMIT_REACHED; PyErr_SetString(PyExc_OverflowError, "too many strings for binary format"); return -1; @@ -380,6 +381,9 @@ writer_intern_string(BinaryWriter *writer, PyObject *string, uint32_t *index) (void **)&writer->string_lengths, &writer->string_capacity, sizeof(char *), sizeof(size_t)) < 0) { + if (PyErr_ExceptionMatches(PyExc_OverflowError)) { + writer->state = BINARY_WRITER_LIMIT_REACHED; + } return -1; } } @@ -390,6 +394,7 @@ writer_intern_string(BinaryWriter *writer, PyObject *string, uint32_t *index) return -1; } if ((uintmax_t)str_len > UINT32_MAX) { + writer->state = BINARY_WRITER_LIMIT_REACHED; PyErr_Format(PyExc_OverflowError, "string length %zd exceeds binary format maximum %u", str_len, UINT32_MAX); @@ -438,12 +443,16 @@ writer_intern_frame(BinaryWriter *writer, const FrameEntry *entry, uint32_t *ind } if (writer->frame_count >= UINT32_MAX) { + writer->state = BINARY_WRITER_LIMIT_REACHED; PyErr_SetString(PyExc_OverflowError, "too many frames for binary format"); return -1; } if (GROW_ARRAY(writer->frame_entries, writer->frame_count, writer->frame_capacity, FrameEntry) < 0) { + if (PyErr_ExceptionMatches(PyExc_OverflowError)) { + writer->state = BINARY_WRITER_LIMIT_REACHED; + } return -1; } @@ -487,6 +496,7 @@ writer_get_or_create_thread_entry(BinaryWriter *writer, uint64_t thread_id, } if (writer->thread_count >= UINT32_MAX) { + writer->state = BINARY_WRITER_LIMIT_REACHED; PyErr_SetString(PyExc_OverflowError, "too many threads for binary format"); return NULL; @@ -496,6 +506,9 @@ writer_get_or_create_thread_entry(BinaryWriter *writer, uint64_t thread_id, &writer->thread_capacity, sizeof(ThreadEntry)); if (!new_entries) { + if (PyErr_ExceptionMatches(PyExc_OverflowError)) { + writer->state = BINARY_WRITER_LIMIT_REACHED; + } return NULL; } writer->thread_entries = new_entries; @@ -928,6 +941,12 @@ static int process_thread_sample(BinaryWriter *writer, PyObject *thread_info, uint32_t interpreter_id, uint64_t timestamp_us) { + if (writer->total_samples == UINT64_MAX) { + writer->state = BINARY_WRITER_LIMIT_REACHED; + PyErr_SetString(PyExc_OverflowError, "too many samples for binary format"); + return -1; + } + PyObject *thread_id_obj = PyStructSequence_GET_ITEM(thread_info, 0); PyObject *status_obj = PyStructSequence_GET_ITEM(thread_info, 1); PyObject *frame_list = PyStructSequence_GET_ITEM(thread_info, 2); @@ -950,7 +969,6 @@ process_thread_sample(BinaryWriter *writer, PyObject *thread_info, /* Calculate timestamp delta */ uint64_t delta = timestamp_us - entry->prev_timestamp; - entry->prev_timestamp = timestamp_us; /* Process frames and build current stack */ uint32_t curr_stack[MAX_STACK_DEPTH]; @@ -1006,6 +1024,7 @@ process_thread_sample(BinaryWriter *writer, PyObject *thread_info, entry->prev_stack_depth = curr_depth; } + entry->prev_timestamp = timestamp_us; writer->total_samples++; return 0; } @@ -1025,15 +1044,16 @@ binary_writer_write_sample(BinaryWriter *writer, PyObject *stack_frames, uint64_ PyObject *interp_id_obj = PyStructSequence_GET_ITEM(interp_info, 0); PyObject *threads = PyStructSequence_GET_ITEM(interp_info, 1); - unsigned long interp_id_long = PyLong_AsUnsignedLong(interp_id_obj); - if (interp_id_long == (unsigned long)-1 && PyErr_Occurred()) { + unsigned long long interp_id_long = PyLong_AsUnsignedLongLong(interp_id_obj); + if (interp_id_long == (unsigned long long)-1 && PyErr_Occurred()) { return -1; } /* Bounds check: interpreter_id is stored as uint32_t in binary format */ if (interp_id_long > UINT32_MAX) { + writer->state = BINARY_WRITER_LIMIT_REACHED; PyErr_Format(PyExc_OverflowError, - "interpreter_id %lu exceeds maximum value %lu", - interp_id_long, (unsigned long)UINT32_MAX); + "interpreter_id %llu exceeds maximum value %u", + interp_id_long, UINT32_MAX); return -1; } uint32_t interpreter_id = (uint32_t)interp_id_long; diff --git a/Modules/_remote_debugging/module.c b/Modules/_remote_debugging/module.c index 5dff0f844c8bd6..03b398eb248e8f 100644 --- a/Modules/_remote_debugging/module.c +++ b/Modules/_remote_debugging/module.c @@ -1789,7 +1789,15 @@ _remote_debugging_BinaryWriter_write_sample_impl(BinaryWriterObject *self, return NULL; } + if (self->writer->state == BINARY_WRITER_BROKEN) { + PyErr_SetString(PyExc_ValueError, "Writer is broken"); + return NULL; + } + self->writer->state = BINARY_WRITER_OPEN; if (binary_writer_write_sample(self->writer, stack_frames, timestamp_us) < 0) { + if (self->writer->state != BINARY_WRITER_LIMIT_REACHED) { + self->writer->state = BINARY_WRITER_BROKEN; + } return NULL; } @@ -1852,7 +1860,12 @@ _remote_debugging_BinaryWriter_set_stats_impl(BinaryWriterObject *self, static int binary_writer_finalize_and_cache(BinaryWriterObject *self) { + if (self->writer->state == BINARY_WRITER_BROKEN) { + PyErr_SetString(PyExc_ValueError, "Writer is broken"); + return -1; + } if (binary_writer_finalize(self->writer) < 0) { + self->writer->state = BINARY_WRITER_BROKEN; return -1; } self->cached_total_samples = self->writer->total_samples; @@ -1933,8 +1946,7 @@ _remote_debugging_BinaryWriter___exit___impl(BinaryWriterObject *self, /*[clinic end generated code: output=61831f47c72a53c6 input=12334ce1009af37f]*/ { if (self->writer) { - /* Only finalize on normal exit (no exception) */ - if (exc_type == Py_None) { + if (self->writer->state != BINARY_WRITER_BROKEN) { if (binary_writer_finalize_and_cache(self) < 0) { if (self->writer) { binary_writer_destroy(self->writer); @@ -1983,8 +1995,17 @@ BinaryWriter_get_total_samples(PyObject *op, void *closure) return PyLong_FromUnsignedLongLong(self->writer->total_samples); } +static PyObject * +BinaryWriter_get_limit_reached(PyObject *op, void *closure) +{ + BinaryWriter *writer = BinaryWriter_CAST(op)->writer; + return PyBool_FromLong(writer && writer->state == BINARY_WRITER_LIMIT_REACHED); +} + static PyGetSetDef BinaryWriter_getset[] = { {"total_samples", BinaryWriter_get_total_samples, NULL, "Total samples written", NULL}, + {"limit_reached", BinaryWriter_get_limit_reached, NULL, + "A format limit was reached; the collected samples can still be finalized", NULL}, {NULL} }; From a194a8862de655a369cbf82249fe48a4932d7fe4 Mon Sep 17 00:00:00 2001 From: "Miss Islington (bot)" <31488909+miss-islington@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:56:51 -0700 Subject: [PATCH 04/15] [3.15] gh-158583: Fix uninitialized memory read in bytes.fromhex() (GH-158584) (#158691) gh-158583: Fix uninitialized memory read in bytes.fromhex() (GH-158584) (cherry picked from commit 9d22a5334bd5273962adceeb697a1337e9a0ca21) Co-authored-by: Victor Stinner (cherry picked from commit 4f7af46e25f09bf54b6f499c5c40b661786f0d69) --- Lib/test/test_bytes.py | 9 +++++++++ ...6-10-03-16-14-26.gh-issue-158583.7EUMvS.rst | 2 ++ Objects/bytesobject.c | 18 ++++++++++-------- 3 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-03-16-14-26.gh-issue-158583.7EUMvS.rst diff --git a/Lib/test/test_bytes.py b/Lib/test/test_bytes.py index c714fc2e76e83c..d31d97ce616b6c 100644 --- a/Lib/test/test_bytes.py +++ b/Lib/test/test_bytes.py @@ -508,6 +508,15 @@ def test_fromhex(self): self.type2test.fromhex(data) self.assertIn('at position %s' % pos, str(cm.exception)) + # gh-158583: Check for out of bounds reads (uninitialized bytes). + # Create an array from a list to not overallocate. + a = array.array('B', list(b'1234 ')) # Py_ISSPACE() loop + self.assertEqual(self.type2test.fromhex(a), b'\x12\x34') + + a = array.array('B', list(b'12345')) # Missing second digit + with self.assertRaises(ValueError): + self.type2test.fromhex(a) + def test_hex(self): self.assertRaises(TypeError, self.type2test.hex) self.assertRaises(TypeError, self.type2test.hex, 1) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-03-16-14-26.gh-issue-158583.7EUMvS.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-03-16-14-26.gh-issue-158583.7EUMvS.rst new file mode 100644 index 00000000000000..c94fcc58add88c --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-03-16-14-26.gh-issue-158583.7EUMvS.rst @@ -0,0 +1,2 @@ +:meth:`bytes.fromhex` and :meth:`bytearray.fromhex`: Fix uninitialized +memory read. Patch by Victor Stinner. diff --git a/Objects/bytesobject.c b/Objects/bytesobject.c index a537f084129eea..5ed28c18bb91fd 100644 --- a/Objects/bytesobject.c +++ b/Objects/bytesobject.c @@ -2691,9 +2691,10 @@ _PyBytes_FromHex(PyObject *string, int use_bytearray) if (Py_ISSPACE(*str)) { do { str++; + if (str >= end) { + goto done; + } } while (Py_ISSPACE(*str)); - if (str >= end) - break; } top = _PyLong_DigitValue[*str]; @@ -2701,16 +2702,16 @@ _PyBytes_FromHex(PyObject *string, int use_bytearray) invalid_char = str - start; goto error; } + str++; + if (str >= end) { + invalid_char = -1; + goto error; + } bot = _PyLong_DigitValue[*str]; if (bot >= 16) { - /* Check if we had a second digit */ - if (str >= end){ - invalid_char = -1; - } else { - invalid_char = str - start; - } + invalid_char = str - start; goto error; } str++; @@ -2718,6 +2719,7 @@ _PyBytes_FromHex(PyObject *string, int use_bytearray) *buf++ = (unsigned char)((top << 4) + bot); } + done: if (view.obj != NULL) { PyBuffer_Release(&view); } From 8f7c71302c7f36a886a8cb5b02c6a06cd32586d1 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 5 Oct 2026 11:03:58 +0100 Subject: [PATCH 05/15] [3.15] gh-154194: Degrade frames in Tachyon instead of failing the sample (GH-154195) (#158831) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * gh-154194: Degrade frames in Tachyon instead of failing the sample (#154195) * degrade gracefully * news * better NEWS wording * do not raise on MAX_REMOTE_STR_READ * bye MAX_REMOTE_STR_READ * fix -m asyncio ps|pstree * test truncation and linetable sentinel * simpler * simpler * redundant now * respect #157790 in the news --------- Co-authored-by: Pablo Galindo Salgado (cherry picked from commit 7d25916b41de4fc28e04b4944864ad1094a9e8e0) * Preserve the stable ABI when creating fallback frame names --------- Co-authored-by: Maurycy Pawłowski-Wieroński (cherry picked from commit c27f4940612b094fe225875a815f8ee6a4f7f9ec) --- Lib/asyncio/tools.py | 10 ++- Lib/test/test_asyncio/test_tools.py | 76 ++++++++++++++++ Lib/test/test_external_inspection.py | 87 +++++++++++++++++++ .../test_binary_format.py | 20 +++++ ...-07-19-22-08-05.gh-issue-154194.1bqRdx.rst | 3 + Modules/_remote_debugging/_remote_debugging.h | 2 +- Modules/_remote_debugging/code_objects.c | 58 +++++++++++-- Python/remote_debug.h | 11 ++- 8 files changed, 253 insertions(+), 14 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-19-22-08-05.gh-issue-154194.1bqRdx.rst diff --git a/Lib/asyncio/tools.py b/Lib/asyncio/tools.py index 2ac1738d15c6c7..fd494912549bf8 100644 --- a/Lib/asyncio/tools.py +++ b/Lib/asyncio/tools.py @@ -27,6 +27,10 @@ def __init__( # ─── indexing helpers ─────────────────────────────────────────── def _format_stack_entry(elem: str|FrameInfo) -> str: if not isinstance(elem, str): + if elem.location is None: + if elem.filename in ("", "~"): + return f"{elem.funcname}" + return f"{elem.funcname} {elem.filename}" if elem.location.lineno == 0 and elem.filename == "": return f"{elem.funcname}" else: @@ -190,8 +194,7 @@ def build_task_table(result): # Build coroutine stack string frames = [frame for coro in task_info.coroutine_stack for frame in coro.call_stack] - coro_stack = " -> ".join(_format_stack_entry(x).split(" ")[0] - for x in frames) + coro_stack = " -> ".join(x.funcname for x in frames) # Handle tasks with no awaiters if not task_info.awaited_by: @@ -202,8 +205,7 @@ def build_task_table(result): # Handle tasks with awaiters for coro_info in task_info.awaited_by: parent_id = coro_info.task_name - awaiter_frames = [_format_stack_entry(x).split(" ")[0] - for x in coro_info.call_stack] + awaiter_frames = [x.funcname for x in coro_info.call_stack] awaiter_chain = " -> ".join(awaiter_frames) awaiter_name = id2name.get(parent_id, "Unknown") parent_id_str = (hex(parent_id) if isinstance(parent_id, int) diff --git a/Lib/test/test_asyncio/test_tools.py b/Lib/test/test_asyncio/test_tools.py index df934164eb9fd6..2b8e4940333196 100644 --- a/Lib/test/test_asyncio/test_tools.py +++ b/Lib/test/test_asyncio/test_tools.py @@ -1558,6 +1558,82 @@ def test_table_output_format(self): class TestAsyncioToolsEdgeCases(unittest.TestCase): + def test_frames_without_location_tree(self): + """Frames the unwinder could not fully read - should not crash.""" + input_ = [ + AwaitedInfo( + thread_id=1, + awaited_by=[ + TaskInfo( + task_id=1, + task_name="Task-A", + coroutine_stack=[ + CoroInfo( + call_stack=[ + FrameInfo("", "~", None), + FrameInfo("", "app.py", None), + FrameInfo("big", "big.py", None), + ], + task_name=1 + ) + ], + awaited_by=[] + ) + ] + ) + ] + self.assertEqual( + tools.build_async_tree(input_), + [[ + "└── (T) Task-A", + " └── big big.py", + " └── app.py", + " └── ", + ]], + ) + + def test_frames_without_location_table(self): + """Frame names are not truncated at the first space.""" + input_ = [ + AwaitedInfo( + thread_id=1, + awaited_by=[ + TaskInfo( + task_id=1, + task_name="Task-A", + coroutine_stack=[ + CoroInfo( + call_stack=[ + FrameInfo("", "~", None) + ], + task_name=1 + ) + ], + awaited_by=[ + CoroInfo( + call_stack=[ + FrameInfo("", "app.py", None) + ], + task_name=2 + ) + ] + ) + ] + ) + ] + self.assertEqual( + tools.build_task_table(input_), + [[ + 1, + "0x1", + "Task-A", + "", + "", + "Unknown", + "0x2", + ]], + ) + def test_task_awaits_self(self): """A task directly awaits itself - should raise a cycle.""" input_ = [ diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index d48a2b9eb6e02a..a0bedf4910fa2a 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -4070,6 +4070,93 @@ def test_get_stats_disabled_raises(self): client_socket.sendall(b"done") +@requires_remote_subprocess_debugging() +@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", +) +class TestMetadataDegradation(RemoteInspectionTestBase): + """Tests for graceful degradation of oversized code-object metadata.""" + + def test_long_qualname_truncated_not_dropped(self): + """A qualname longer than 1024 chars is truncated instead of + failing the whole sample.""" + name = "f" * 1100 + src = f"def {name}(sample):\n return sample()\n" + ns = {} + exec(src, ns) + + trace = ns[name](RemoteUnwinder(os.getpid()).get_stack_trace) + frame = self._find_frame_in_trace( + trace, lambda f: f.funcname.startswith("fff") + ) + self.assertIsNotNone(frame) + self.assertEqual(frame.funcname, "f" * 1024) + + def test_long_filename_truncated(self): + """A filename longer than 1024 chars is truncated instead of + failing the whole sample.""" + src = "def g(sample):\n return sample()\n" + ns = {} + exec(compile(src, "x" * 1500 + ".py", "exec"), ns) + + trace = ns["g"](RemoteUnwinder(os.getpid()).get_stack_trace) + frame = self._find_frame_in_trace(trace, lambda f: f.funcname == "g") + self.assertIsNotNone(frame) + self.assertEqual(frame.filename, "x" * 1024) + + def test_oversized_linetable_degrades_to_no_location(self): + """A linetable over MAX_LINETABLE_SIZE degrades to a frame without + location instead of failing the whole sample.""" + src = ( + "def big(sample):\n" + + " x = 1\n" * 20_000 + + " return sample()\n" + ) + ns = {} + exec(compile(src, "big_linetable.py", "exec"), ns) + big = ns["big"] + self.assertGreater(len(big.__code__.co_linetable), 64 * 1024) + + trace = big(RemoteUnwinder(os.getpid()).get_stack_trace) + frame = self._find_frame_in_trace( + trace, lambda f: f.funcname == "big" + ) + self.assertIsNone(frame.location) + self.assertEqual(frame.filename, "big_linetable.py") + + @unittest.skipIf( + sys.platform == "win32", + "Process death maps to ProcessLookupError only on POSIX platforms", + ) + def test_dead_process_raises_not_degrades(self): + """Death of the target raises ProcessLookupError instead of + degrading to synthetic frames.""" + script_body = """\ + import time + sock.sendall(b"ready") + time.sleep(10_000) + """ + with self._target_process(script_body) as (p, client_socket, make_unwinder): + _wait_for_signal(client_socket, b"ready") + unwinder = make_unwinder() + _get_stack_trace_with_retry(unwinder) + + p.kill() + p.wait() + + for _ in busy_retry(SHORT_TIMEOUT, error=False): + try: + unwinder.get_stack_trace() + except ProcessLookupError: + break + except RuntimeError: + continue + else: + self.fail("ProcessLookupError never raised for dead process") + + @requires_remote_subprocess_debugging() class TestFrameChainLimits(RemoteInspectionTestBase): """Frame chain walks abort instead of looping/overflowing on deep chains.""" diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py b/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py index d1ffc17a21573d..97075a1e2fed80 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py @@ -667,6 +667,26 @@ def test_same_line_different_columns(self): collector, count = self.roundtrip(samples) self.assertEqual(count, 3) + def test_synthetic_frames_roundtrip(self): + """Degraded/sentinel frames (location=None) survive the binary format.""" + frames = [ + FrameInfo(("~", None, name, None)) + for name in ( + "", + "", + "", + "", + "", + ) + ] + frames.append(FrameInfo(("app.py", None, "", None))) + frames.append(FrameInfo(("", None, "real_func", None))) + samples = [[make_interpreter(0, [make_thread(1, frames)])]] + + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + class TestBinaryEdgeCases(BinaryFormatTestBase): """Tests for edge cases in binary format.""" diff --git a/Misc/NEWS.d/next/Library/2026-07-19-22-08-05.gh-issue-154194.1bqRdx.rst b/Misc/NEWS.d/next/Library/2026-07-19-22-08-05.gh-issue-154194.1bqRdx.rst new file mode 100644 index 00000000000000..9971cd3f8dc25b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-19-22-08-05.gh-issue-154194.1bqRdx.rst @@ -0,0 +1,3 @@ +Fix the sampling profiler dropping entire samples when a non-fatal read fails; +frames now keep any readable metadata, and long funcnames and filenames are +truncated instead. Patch by Maurycy Pawłowski-Wieroński. diff --git a/Modules/_remote_debugging/_remote_debugging.h b/Modules/_remote_debugging/_remote_debugging.h index 3aecf3982b95d7..cf923014445f55 100644 --- a/Modules/_remote_debugging/_remote_debugging.h +++ b/Modules/_remote_debugging/_remote_debugging.h @@ -182,7 +182,7 @@ typedef enum _WIN32_THREADSTATE { #define set_exception_cause(unwinder, exc_type, message) \ do { \ assert(PyErr_Occurred() && "function returned -1 without setting exception"); \ - if (unwinder->debug && !_Py_RemoteDebug_HasPermissionError()) { \ + if (unwinder->debug && !_Py_RemoteDebug_IsFatalReadError()) { \ _set_debug_exception_cause(exc_type, message); \ } \ } while (0) diff --git a/Modules/_remote_debugging/code_objects.c b/Modules/_remote_debugging/code_objects.c index 6ad05885019626..2789c95e6e321b 100644 --- a/Modules/_remote_debugging/code_objects.c +++ b/Modules/_remote_debugging/code_objects.c @@ -346,6 +346,7 @@ parse_code_object(RemoteUnwinderObject *unwinder, PyObject *func = NULL; PyObject *file = NULL; PyObject *linetable = NULL; + int code_metadata_incomplete = 0; #ifdef Py_GIL_DISABLED // In free threading builds, code object addresses might have the low bit set @@ -369,30 +370,59 @@ parse_code_object(RemoteUnwinderObject *unwinder, if (_Py_RemoteDebug_PagedReadRemoteMemory( &unwinder->handle, real_address, SIZEOF_CODE_OBJ, code_object) < 0) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read code object"); - goto error; + if (_Py_RemoteDebug_IsFatalReadError()) { + goto error; + } + PyErr_Clear(); + func = PyUnicode_FromString(""); + if (!func) { + goto error; + } + file = Py_NewRef(_Py_LATIN1_CHR('~')); + goto degraded; } func = read_py_str(unwinder, GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.qualname), 1024); if (!func) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read function name from code object"); - goto error; + if (_Py_RemoteDebug_IsFatalReadError()) { + goto error; + } + PyErr_Clear(); + func = PyUnicode_FromString(""); + if (!func) { + goto error; + } + code_metadata_incomplete = 1; } file = read_py_str(unwinder, GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.filename), 1024); if (!file) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read filename from code object"); - goto error; + if (_Py_RemoteDebug_IsFatalReadError()) { + goto error; + } + PyErr_Clear(); + file = PyUnicode_FromString(""); + if (!file) { + goto error; + } + code_metadata_incomplete = 1; + } + + if (code_metadata_incomplete) { + goto degraded; } linetable = read_py_bytes(unwinder, GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.linetable), MAX_LINETABLE_SIZE); if (!linetable) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read linetable from code object"); - goto error; + if (_Py_RemoteDebug_IsFatalReadError()) { + goto error; + } + PyErr_Clear(); + goto degraded; } meta = PyMem_RawMalloc(sizeof(CachedCodeMetadata)); @@ -561,6 +591,18 @@ parse_code_object(RemoteUnwinderObject *unwinder, *result = tuple; return 0; +degraded: { + PyObject *degraded_tuple = make_frame_info(unwinder, file, Py_None, + func, Py_None); + Py_CLEAR(func); + Py_CLEAR(file); + if (!degraded_tuple) { + return -1; + } + *result = degraded_tuple; + return 0; +} + error: Py_XDECREF(func); Py_XDECREF(file); diff --git a/Python/remote_debug.h b/Python/remote_debug.h index 16229df936ffef..30cad806a6f210 100644 --- a/Python/remote_debug.h +++ b/Python/remote_debug.h @@ -107,9 +107,18 @@ _Py_RemoteDebug_HasPermissionError(void) && PyErr_ExceptionMatches(PyExc_PermissionError); } +static inline int +_Py_RemoteDebug_IsFatalReadError(void) +{ + return _Py_RemoteDebug_HasPermissionError() + || PyErr_ExceptionMatches(PyExc_MemoryError) + || PyErr_ExceptionMatches(PyExc_ProcessLookupError) + || (PyErr_Occurred() && !PyErr_ExceptionMatches(PyExc_Exception)); +} + #define _set_debug_exception_cause(exception, format, ...) \ do { \ - if (!_Py_RemoteDebug_HasPermissionError()) { \ + if (!_Py_RemoteDebug_IsFatalReadError()) { \ PyThreadState *tstate = _PyThreadState_GET(); \ if (!_PyErr_Occurred(tstate)) { \ _PyErr_Format(tstate, exception, format, ##__VA_ARGS__); \ From cfe0cab94fbab2c48e78ea4e4a870429c8af4a7c Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 5 Oct 2026 11:28:24 +0100 Subject: [PATCH 06/15] [3.15] Add MSan to CI (GH-158625) (#158832) Run a job that the test suite with MSan to the CI (#158625) * Run the test suite with MSan in CI * Additional fixes * Add `_Py_MSAN_UNPOISON_STRING` * Apply Victor's suggestions * Apply Victor's suggestions --------- (cherry picked from commit b93fb19a6e3118857d9a1dc4ffcc179b632a88c1) Co-authored-by: Stan Ulbrych Co-authored-by: Victor Stinner (cherry picked from commit 1bc78dea18dfaff6900eeac5edb1ca651ff700d7) --- .github/workflows/build.yml | 3 +++ .github/workflows/reusable-san.yml | 22 ++++++++++++++++++-- Doc/using/configure.rst | 4 ++++ Include/pyport.h | 4 ++++ Lib/test/test_faulthandler.py | 4 ++-- Modules/_remote_debugging/binary_io_reader.c | 5 +++++ Modules/_remote_debugging/binary_io_writer.c | 6 ++++++ Modules/_testinternalcapi.c | 6 +++--- Modules/posixmodule.c | 1 + Modules/socketmodule.c | 9 ++++++-- Python/instrumentation.c | 1 + configure | 2 +- configure.ac | 2 +- 13 files changed, 58 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 90c5d0fbec686a..2f8f97b34d25b5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -605,6 +605,9 @@ jobs: - check-name: Undefined behavior sanitizer: UBSan free-threading: false + - check-name: Memory + sanitizer: MSan + free-threading: false uses: ./.github/workflows/reusable-san.yml with: sanitizer: ${{ matrix.sanitizer }} diff --git a/.github/workflows/reusable-san.yml b/.github/workflows/reusable-san.yml index ad3232743874d6..da6306a50cf7bc 100644 --- a/.github/workflows/reusable-san.yml +++ b/.github/workflows/reusable-san.yml @@ -60,7 +60,7 @@ jobs: || '' }} - name: UBSan option setup - if: inputs.sanitizer != 'TSan' + if: inputs.sanitizer == 'UBSan' run: >- echo "UBSAN_OPTIONS=${SAN_LOG_OPTION} @@ -69,6 +69,20 @@ jobs: >> "$GITHUB_ENV" env: SAN_LOG_OPTION: log_path=${{ github.workspace }}/san_log + - name: MSan option setup + if: inputs.sanitizer == 'MSan' + run: | + echo "MSAN_OPTIONS=${SAN_LOG_OPTION} allocator_may_return_null=1 handle_segv=0" >> "$GITHUB_ENV" + # MSan reports false positives for memory initialized by libraries + # that are not built with MSan, so disable modules that use them. + # _remote_debugging links to libzstd directly, but we unpoision the memory. + { + echo '*disabled*' + echo '_bz2 _ctypes _curses _curses_panel _dbm _decimal _gdbm _hashlib' + echo '_lzma _sqlite3 _ssl _tkinter _uuid _zstd readline zlib' + } > Modules/Setup.local + env: + SAN_LOG_OPTION: log_path=${{ github.workspace }}/san_log - name: Add ccache to PATH run: | echo "PATH=/usr/lib/ccache:$PATH" >> "$GITHUB_ENV" @@ -93,6 +107,8 @@ jobs: # gh-157958: -O2 instead of the pydebug default -Og to avoid a clang 21 # compile-time blowup on some interpreter files. # (https://github.com/llvm/llvm-project/issues/179695) + # MSan uses --with-assertions instead of --with-pydebug because its + # hooks on the Python memory allocators hide uninitialized reads. - name: Configure CPython run: >- ./configure @@ -101,9 +117,11 @@ jobs: ${{ inputs.sanitizer == 'TSan' && '--with-thread-sanitizer' + || inputs.sanitizer == 'MSan' + && '--with-memory-sanitizer' || '--with-undefined-behavior-sanitizer --with-strict-overflow' }} - --with-pydebug + ${{ inputs.sanitizer == 'MSan' && '--with-assertions' || '--with-pydebug' }} ${{ inputs.sanitizer == 'TSan' && '--with-openssl="$OPENSSL_DIR" --with-openssl-rpath=auto' || '' }} ${{ inputs.free-threading && '--disable-gil' || '' }} - name: Build CPython diff --git a/Doc/using/configure.rst b/Doc/using/configure.rst index 3745c21a567761..d29eb891523243 100644 --- a/Doc/using/configure.rst +++ b/Doc/using/configure.rst @@ -1015,6 +1015,10 @@ Debug options Enable MemorySanitizer allocation error detector, ``msan`` (default is no). + MSan reports false positives for memory initialized by libraries that are + not built with MSan, so either build all dependencies with MSan or disable + the extension modules that use them in :file:`Modules/Setup.local`. + .. versionadded:: 3.6 .. option:: --with-undefined-behavior-sanitizer diff --git a/Include/pyport.h b/Include/pyport.h index 73a3e6cdaf0920..92e65b4d6d1e2a 100644 --- a/Include/pyport.h +++ b/Include/pyport.h @@ -554,6 +554,7 @@ extern "C" { # define _Py_MEMORY_SANITIZER # define _Py_NO_SANITIZE_MEMORY __attribute__((no_sanitize_memory)) # define _Py_MSAN_UNPOISON(PTR, SIZE) (__msan_unpoison(PTR, SIZE)) +# define _Py_MSAN_UNPOISON_STRING(STR) (__msan_unpoison_string(STR)) # endif # endif # if __has_feature(address_sanitizer) @@ -595,6 +596,9 @@ extern "C" { #ifndef _Py_MSAN_UNPOISON # define _Py_MSAN_UNPOISON(PTR, SIZE) #endif +#ifndef _Py_MSAN_UNPOISON_STRING +# define _Py_MSAN_UNPOISON_STRING(STR) +#endif /* AIX has __bool__ redefined in it's system header file. */ #if defined(_AIX) && defined(__bool__) diff --git a/Lib/test/test_faulthandler.py b/Lib/test/test_faulthandler.py index 5a493a4fd95680..82b347c8f8c045 100644 --- a/Lib/test/test_faulthandler.py +++ b/Lib/test/test_faulthandler.py @@ -34,8 +34,8 @@ def skip_if_sanitizer_signal(signame): - return support.skip_if_sanitizer(f"TSAN/UBSan itercepts {signame}", - thread=True, ub=True) + return support.skip_if_sanitizer(f"TSan/UBSan/MSan intercepts {signame}", + thread=True, ub=True, memory=True) def expected_traceback(lineno1, lineno2, header, min_count=1): diff --git a/Modules/_remote_debugging/binary_io_reader.c b/Modules/_remote_debugging/binary_io_reader.c index 9625ee6f301f05..8af1d281cee6b6 100644 --- a/Modules/_remote_debugging/binary_io_reader.c +++ b/Modules/_remote_debugging/binary_io_reader.c @@ -19,6 +19,10 @@ #include #endif +#ifdef _Py_MEMORY_SANITIZER +# include +#endif + /* ============================================================================ * CONSTANTS FOR BINARY FORMAT SIZES * ============================================================================ */ @@ -315,6 +319,7 @@ reader_decompress_samples(BinaryReader *reader, const uint8_t *data) return -1; } + _Py_MSAN_UNPOISON(output.dst, output.pos); total_output += output.pos; } diff --git a/Modules/_remote_debugging/binary_io_writer.c b/Modules/_remote_debugging/binary_io_writer.c index 4cf81ca3cccd41..1794017053da50 100644 --- a/Modules/_remote_debugging/binary_io_writer.c +++ b/Modules/_remote_debugging/binary_io_writer.c @@ -19,6 +19,10 @@ #include #endif +#ifdef _Py_MEMORY_SANITIZER +# include +#endif + /* ============================================================================ * CONSTANTS FOR BINARY FORMAT SIZES * ============================================================================ */ @@ -235,6 +239,7 @@ writer_flush_buffer(BinaryWriter *writer) return -1; } + _Py_MSAN_UNPOISON(writer->zstd.compressed_buffer, output.pos); if (output.pos > 0) { if (fwrite_checked_allow_threads(writer->zstd.compressed_buffer, output.pos, writer->fp) < 0) { return -1; @@ -1104,6 +1109,7 @@ binary_writer_finalize(BinaryWriter *writer) return -1; } + _Py_MSAN_UNPOISON(writer->zstd.compressed_buffer, output.pos); if (output.pos > 0) { if (fwrite_checked_allow_threads(writer->zstd.compressed_buffer, output.pos, writer->fp) < 0) { return -1; diff --git a/Modules/_testinternalcapi.c b/Modules/_testinternalcapi.c index 124e06e5302f45..02c3d4433b79e9 100644 --- a/Modules/_testinternalcapi.c +++ b/Modules/_testinternalcapi.c @@ -438,7 +438,7 @@ next_frame_pointer_is_valid(uintptr_t *frame_pointer, uintptr_t *next_fp, #endif } -static PyObject * +static PyObject * _Py_NO_SANITIZE_MEMORY manual_unwind_from_fp(uintptr_t *frame_pointer) { uintptr_t stack_min = 0; @@ -2049,8 +2049,8 @@ check_pyobject_forbidden_bytes_is_freed(PyObject *self, static PyObject * check_pyobject_freed_is_freed(PyObject *self, PyObject *Py_UNUSED(args)) { - /* ASan or TSan would report an use-after-free error */ -#if defined(_Py_ADDRESS_SANITIZER) || defined(_Py_THREAD_SANITIZER) + /* ASan, MSan or TSan would report an error. */ +#if defined(_Py_ADDRESS_SANITIZER) || defined(_Py_THREAD_SANITIZER) || defined(_Py_MEMORY_SANITIZER) Py_RETURN_NONE; #else PyObject *op = PyObject_CallNoArgs((PyObject *)&PyBaseObject_Type); diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c index 0b42b059541d35..b30ec5789643c8 100644 --- a/Modules/posixmodule.c +++ b/Modules/posixmodule.c @@ -10137,6 +10137,7 @@ os_getlogin_impl(PyObject *module) errno = old_errno; } else { + _Py_MSAN_UNPOISON(name, sizeof(name)); result = PyUnicode_DecodeFSDefault(name); } #else diff --git a/Modules/socketmodule.c b/Modules/socketmodule.c index fc870aaa5c1c2e..53d380eb4626c5 100644 --- a/Modules/socketmodule.c +++ b/Modules/socketmodule.c @@ -754,7 +754,9 @@ set_herror(socket_state *state, int h_error) PyObject *v; #ifdef HAVE_HSTRERROR - v = Py_BuildValue("(iN)", h_error, decode_error_message(hstrerror(h_error))); + const char *errmsg = hstrerror(h_error); + _Py_MSAN_UNPOISON_STRING(errmsg); + v = Py_BuildValue("(iN)", h_error, decode_error_message(errmsg)); #else v = Py_BuildValue("(is)", h_error, "host not found"); #endif @@ -781,7 +783,9 @@ set_gaierror(socket_state *state, int error) #endif #ifdef HAVE_GAI_STRERROR - v = Py_BuildValue("(iN)", error, decode_error_message(gai_strerror(error))); + const char *errmsg = gai_strerror(error); + _Py_MSAN_UNPOISON_STRING(errmsg); + v = Py_BuildValue("(iN)", error, decode_error_message(errmsg)); #else v = Py_BuildValue("(is)", error, "getaddrinfo failed"); #endif @@ -6420,6 +6424,7 @@ socket_getservbyport(PyObject *self, PyObject *args) PyErr_SetString(PyExc_OSError, "port/proto not found"); return NULL; } + _Py_MSAN_UNPOISON_STRING(sp->s_name); return PyUnicode_FromString(sp->s_name); } diff --git a/Python/instrumentation.c b/Python/instrumentation.c index 646fc15c6872e5..88e23b59db1ce6 100644 --- a/Python/instrumentation.c +++ b/Python/instrumentation.c @@ -1690,6 +1690,7 @@ allocate_instrumentation_data(PyCodeObject *code) } monitoring->local_monitors = (_Py_LocalMonitors){ 0 }; monitoring->active_monitors = (_Py_LocalMonitors){ 0 }; + memset(monitoring->tool_versions, 0, sizeof(monitoring->tool_versions)); monitoring->tools = NULL; monitoring->lines = NULL; monitoring->line_tools = NULL; diff --git a/configure b/configure index a68a98c5a4de4a..3e08f8af8650b5 100755 --- a/configure +++ b/configure @@ -16392,7 +16392,7 @@ int main(void) { return 2; } - ffi_arg rc; + ffi_arg rc = 0; ffi_call(&cif, FFI_FN(z_is_expected), &rc, values); return !rc; } diff --git a/configure.ac b/configure.ac index ed8b53dd3a4d28..f5365b0ae194d0 100644 --- a/configure.ac +++ b/configure.ac @@ -4360,7 +4360,7 @@ int main(void) { return 2; } - ffi_arg rc; + ffi_arg rc = 0; ffi_call(&cif, FFI_FN(z_is_expected), &rc, values); return !rc; } From 7e8cfd57ead7660efcdda0c46d9f581ad840f1f5 Mon Sep 17 00:00:00 2001 From: "Miss Islington (bot)" <31488909+miss-islington@users.noreply.github.com> Date: Mon, 5 Oct 2026 06:37:42 -0700 Subject: [PATCH 07/15] [3.15] gh-156810: Write the profiler's collapsed-stack export as UTF-8 (GH-156811) (#156814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gh-156810: Write the profiler's collapsed-stack export as UTF-8 (GH-156811) (cherry picked from commit c3706f4c687d76b85702c1f946abd3f3d3eafcb8) Co-authored-by: tonghuaroot (童话) (cherry picked from commit fce28da9b75d429a7b65eec450df8e804fada001) --- Lib/profiling/sampling/stack_collector.py | 3 ++- .../test_sampling_profiler/test_collectors.py | 22 +++++++++++++++++++ ...-09-02-17-28-16.gh-issue-156810.cLpEnc.rst | 4 ++++ 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-02-17-28-16.gh-issue-156810.cLpEnc.rst diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 1610f4a3655882..7410d07c7e0162 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -60,7 +60,8 @@ def export(self, filename): lines.sort(key=lambda x: (-x[1], x[0])) - with open(filename, "w") as f: + with open(filename, "w", + encoding="utf-8", errors="surrogatepass") as f: for stack, count in lines: f.write(f"{stack} {count}\n") print(f"Collapsed stack output written to {filename}") diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py index 533ca36376e569..282f97e5259e09 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -466,6 +466,28 @@ def test_collapsed_stack_collector_export(self): self.assertIn(stack1_expected, lines) self.assertIn(stack2_expected, lines) + def test_collapsed_stack_collector_export_non_ascii_names(self): + # gh-156810: frame names are written verbatim, so the output must be + # opened with an encoding that can represent non-ASCII and + # surrogate-escaped (undecodable-path) names. + collapsed_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, collapsed_out) + + collector = CollapsedStackCollector(1000) + frame = MockFrameInfo("/tmp/ba\udc80d.py", 5, "计算") + collector.collect([ + MockInterpreterInfo(0, [MockThreadInfo(1, [frame])]) + ]) + + with captured_stdout(), captured_stderr(): + collector.export(collapsed_out.name) + + with open(collapsed_out.name, encoding="utf-8", + errors="surrogatepass") as f: + content = f.read() + self.assertIn("计算", content) + self.assertIn("ba\udc80d.py", content) + def test_flamegraph_collector_basic(self): """Test basic FlamegraphCollector functionality.""" collector = FlamegraphCollector(1000) diff --git a/Misc/NEWS.d/next/Library/2026-09-02-17-28-16.gh-issue-156810.cLpEnc.rst b/Misc/NEWS.d/next/Library/2026-09-02-17-28-16.gh-issue-156810.cLpEnc.rst new file mode 100644 index 00000000000000..610b0b4fcdc1ba --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-02-17-28-16.gh-issue-156810.cLpEnc.rst @@ -0,0 +1,4 @@ +Fix a :exc:`UnicodeEncodeError` crash in the sampling profiler's +collapsed-stack export (``--collapsed``) when a sampled frame's function or +file name contains non-ASCII or surrogate-escaped characters. The output file +is now written as UTF-8. From d70f842298ad44394c4a1d2b0802531f5c23a57f Mon Sep 17 00:00:00 2001 From: "Miss Islington (bot)" <31488909+miss-islington@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:26:36 -0700 Subject: [PATCH 08/15] [3.15] gh-158552: Wait for Windows threads to suspend before blocking sampling (GH-158802) (#158845) gh-158552: Wait for Windows threads to suspend before blocking sampling (GH-158802) * gh-158552: Wait for Windows threads to suspend before blocking sampling * Use a named Windows thread enumeration status constant (cherry picked from commit 1643525f0f89438d87daf4cdbb1cbb1de9df08e8) Co-authored-by: Pablo Galindo Salgado (cherry picked from commit 31288da21d4536eccd8df2e8222687919164e867) --- .../test_sampling_profiler/test_blocking.py | 57 ++++++++++++ ...10-04-20-00-00.gh-issue-158552.windows.rst | 3 + Modules/_remote_debugging/_remote_debugging.h | 1 + Modules/_remote_debugging/threads.c | 89 +++++++++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 Misc/NEWS.d/next/Library/2026-10-04-20-00-00.gh-issue-158552.windows.rst diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_blocking.py b/Lib/test/test_profiling/test_sampling_profiler/test_blocking.py index 0a5541c733d4c7..84b9e7aca0ffbd 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_blocking.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_blocking.py @@ -5,6 +5,7 @@ import subprocess import sys import textwrap +import time import unittest from unittest import mock @@ -213,3 +214,59 @@ def test_run_blocking_exits_after_target_process_exits(self): replay.returncode, 0, f"stdout:\n{replay.stdout}\nstderr:\n{replay.stderr}", ) + + +@requires_remote_subprocess_debugging() +@unittest.skipUnless(sys.platform == "win32", "Windows only") +class TestBlockingModeSuspension(unittest.TestCase): + def test_all_threads_stop_before_pause_returns(self): + import mmap + + tag = f"cpython_blocking_{os.getpid()}_{id(self)}" + script = textwrap.dedent(f''' + import mmap + import struct + import threading + + memory = mmap.mmap(-1, 16, tagname={tag!r}) + + def worker(offset): + counter = 0 + while True: + counter += 1 + struct.pack_into("q", memory, offset, counter) + + for offset in (0, 8): + threading.Thread(target=worker, args=(offset,), daemon=True).start() + _test_sock.sendall(b"working") + _test_sock.recv(1) + ''') + with mmap.mmap(-1, 16, tagname=tag) as memory: + with test_subprocess(script, wait_for_working=True) as subproc: + unwinder = _remote_debugging.RemoteUnwinder( + subproc.process.pid, all_threads=True) + deadline = time.monotonic() + SHORT_TIMEOUT + while not all(memory[offset:offset + 8] != bytes(8) + for offset in (0, 8)): + self.assertLess(time.monotonic(), deadline, + "Worker threads did not start") + time.sleep(0.001) + for _ in range(100): + self.assertTrue(unwinder.pause_threads()) + try: + before = memory[:] + self.assertFalse(unwinder.pause_threads()) + unwinder.get_stack_trace() + time.sleep(0.001) + self.assertEqual(memory[:], before, + "Target memory changed while paused") + finally: + unwinder.resume_threads() + self.assertFalse(unwinder.resume_threads()) + before = memory[:] + deadline = time.monotonic() + SHORT_TIMEOUT + while any(memory[offset:offset + 8] == before[offset:offset + 8] + for offset in (0, 8)): + self.assertLess(time.monotonic(), deadline, + "Worker threads did not resume") + time.sleep(0.001) diff --git a/Misc/NEWS.d/next/Library/2026-10-04-20-00-00.gh-issue-158552.windows.rst b/Misc/NEWS.d/next/Library/2026-10-04-20-00-00.gh-issue-158552.windows.rst new file mode 100644 index 00000000000000..e46f9edcebe09b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-04-20-00-00.gh-issue-158552.windows.rst @@ -0,0 +1,3 @@ +Wait for Windows threads to finish suspending before reading target memory +in the sampling profiler's blocking mode. Resume the target if waiting for +suspension fails. diff --git a/Modules/_remote_debugging/_remote_debugging.h b/Modules/_remote_debugging/_remote_debugging.h index cf923014445f55..c57b30533ce1c1 100644 --- a/Modules/_remote_debugging/_remote_debugging.h +++ b/Modules/_remote_debugging/_remote_debugging.h @@ -105,6 +105,7 @@ typedef struct { #ifdef MS_WINDOWS #define STATUS_SUCCESS ((NTSTATUS)0x00000000L) #define STATUS_INFO_LENGTH_MISMATCH ((NTSTATUS)0xC0000004L) +#define STATUS_NO_MORE_ENTRIES ((NTSTATUS)0x8000001AL) typedef enum _WIN32_THREADSTATE { WIN32_THREADSTATE_INITIALIZED = 0, WIN32_THREADSTATE_READY = 1, diff --git a/Modules/_remote_debugging/threads.c b/Modules/_remote_debugging/threads.c index 04c70cc96d6bd1..666316ceaa28fc 100644 --- a/Modules/_remote_debugging/threads.c +++ b/Modules/_remote_debugging/threads.c @@ -828,6 +828,89 @@ _Py_RemoteDebug_ResumeAllThreads(RemoteUnwinderObject *unwinder, _Py_RemoteDebug #elif defined(MS_WINDOWS) +static int +wait_for_threads_to_stop(RemoteUnwinderObject *unwinder) +{ + typedef NTSTATUS (NTAPI *NtGetNextThreadFunc)( + HANDLE, HANDLE, ACCESS_MASK, ULONG, ULONG, PHANDLE); + static NtGetNextThreadFunc pNtGetNextThread = NULL; + static int tried_load = 0; + + if (!tried_load) { + HMODULE hNtdll = GetModuleHandleW(L"ntdll.dll"); + if (hNtdll) { + pNtGetNextThread = (NtGetNextThreadFunc)GetProcAddress( + hNtdll, "NtGetNextThread"); + } + tried_load = 1; + } + if (pNtGetNextThread == NULL) { + PyErr_SetString(PyExc_RuntimeError, "NtGetNextThread not available"); + return -1; + } + + HANDLE previous = NULL; + int result = -1; + for (;;) { + HANDLE next = NULL; + // Enumerate with the available access, then obtain context access + // separately so threads that deny it are not silently skipped. + NTSTATUS status = pNtGetNextThread(unwinder->handle.hProcess, + previous, MAXIMUM_ALLOWED, 0, 0, &next); + if (previous != NULL) { + CloseHandle(previous); + } + previous = next; + if (status == STATUS_NO_MORE_ENTRIES) { + break; + } + if (status < 0) { + if (!is_process_alive(unwinder->handle.hProcess)) { + PyErr_Format(PyExc_ProcessLookupError, + "Process %d has terminated", unwinder->handle.pid); + } + else { + PyErr_Format(PyExc_RuntimeError, + "NtGetNextThread failed: 0x%lx", status); + } + goto done; + } + HANDLE thread; + if (!DuplicateHandle(GetCurrentProcess(), next, GetCurrentProcess(), + &thread, THREAD_GET_CONTEXT | SYNCHRONIZE, + FALSE, 0)) { + PyErr_SetFromWindowsErr(GetLastError()); + goto done; + } + // Suspension is asynchronous. Reading the context waits for the + // thread to stop before we start reading the target's memory. + CONTEXT context = {.ContextFlags = CONTEXT_CONTROL}; + if (!GetThreadContext(thread, &context)) { + DWORD error = GetLastError(); + int exited = WaitForSingleObject(thread, 0) == WAIT_OBJECT_0; + CloseHandle(thread); + if (exited) { + continue; + } + PyErr_SetFromWindowsErr(error); + goto done; + } + CloseHandle(thread); + } + if (!is_process_alive(unwinder->handle.hProcess)) { + PyErr_Format(PyExc_ProcessLookupError, + "Process %d has terminated", unwinder->handle.pid); + goto done; + } + result = 0; + +done: + if (previous != NULL) { + CloseHandle(previous); + } + return result; +} + void _Py_RemoteDebug_InitThreadsState(RemoteUnwinderObject *unwinder, _Py_RemoteDebug_ThreadsState *st) { @@ -858,6 +941,12 @@ _Py_RemoteDebug_StopAllThreads(RemoteUnwinderObject *unwinder, _Py_RemoteDebug_T if (status >= 0) { st->hProcess = unwinder->handle.hProcess; st->suspended = 1; + if (wait_for_threads_to_stop(unwinder) < 0) { + // pause_threads() has not yet set threads_stopped, so its caller + // will not resume the process when we return an error. + _Py_RemoteDebug_ResumeAllThreads(unwinder, st); + return -1; + } _Py_RemoteDebug_ClearCache(&unwinder->handle); return 0; } From 43e640c74aa1f7ef86e1350225ec9cfda93d5ca3 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 5 Oct 2026 15:42:25 +0100 Subject: [PATCH 09/15] [3.15] gh-152721: Fix quadratic RLE replay time in the profiling binary reader (GH-152722) (#158850) Backport of GH-152722. Co-authored-by: tonghuaroot (cherry picked from commit ebaca2ac2d3ac29cd2760b6a5e4617c3fc5f6333) --- .../test_binary_format.py | 72 +++++++++++++++++++ ...07-01-18-00-00.gh-issue-152721.rlequad.rst | 2 + Modules/_remote_debugging/binary_io_reader.c | 43 +++++------ 3 files changed, 93 insertions(+), 24 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-01-18-00-00.gh-issue-152721.rlequad.rst diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py b/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py index 97075a1e2fed80..66a9c68a61c47d 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py @@ -7,6 +7,7 @@ import struct import tempfile import unittest +from unittest import mock from collections import defaultdict from test.support import captured_stderr @@ -1503,6 +1504,77 @@ def test_alternating_threads_status_changes(self): self.assertEqual(count, 100) self.assert_samples_equal(samples, collector) + def test_rle_alternating_status_batches_correctly(self): + """A repeat record whose status alternates every sample replays as N + single-status batches with the right cumulative timestamps.""" + class BatchCollector: + def __init__(self): + self.batches = [] + + def collect(self, stack_frames, timestamps_us): + for interp in stack_frames: + for thread in interp.threads: + self.batches.append( + (thread.status, list(timestamps_us)) + ) + + def export(self, filename): + pass + + num_samples = 2000 + frame = make_frame("rle.py", 42, "rle_func") + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + + writer = BinaryCollector(filename, 1000, compression="none") + expected = [] + for i in range(num_samples): + status = THREAD_STATUS_HAS_GIL if i % 2 else 0 + ts = 1000 + i + expected.append((status, [ts])) + sample = [ + make_interpreter(0, [make_thread(1, [frame], status)]) + ] + writer.collect(sample, timestamp_us=ts) + writer.export(None) + + collector = BatchCollector() + with BinaryReader(filename) as reader: + count = reader.replay_samples(collector) + + self.assertEqual(count, num_samples) + self.assertEqual(len(collector.batches), num_samples) + self.assertEqual(collector.batches, expected) + + + def test_rle_long_run_splits_batches(self): + # Construct a single repeat record larger than the writer's buffer. + num_samples = 8193 + filename = self.create_binary_file([], compression="none") + data = bytearray(pathlib.Path(filename).read_bytes()) + record = (struct.pack("=QIB", 1, 0, 0) # STACK_REPEAT + + b"\x81\x40" # 8193 as a varint + + b"\x01\x00" * num_samples) # delta=1, status=0 + data[64:64] = record + struct.pack_into("=Q", data, 12, 0) # start timestamp + struct.pack_into("=Q", data, 28, num_samples) + struct.pack_into("=I", data, 36, 1) # thread count + for offset in (40, 48): # string and frame table offsets + old_offset = struct.unpack_from("=Q", data, offset)[0] + struct.pack_into("=Q", data, offset, old_offset + len(record)) + struct.pack_into("=Q", data, len(data) - 24, len(data)) + pathlib.Path(filename).write_bytes(data) + + collector = mock.Mock() + with BinaryReader(filename) as reader: + count = reader.replay_samples(collector) + batches = [call.args[1] for call in collector.collect.call_args_list] + self.assertEqual(count, num_samples) + self.assertEqual([len(batch) for batch in batches], [8192, 1]) + self.assertEqual([ts for batch in batches for ts in batch], + list(range(1, num_samples + 1))) + class TestBinaryStress(BinaryFormatTestBase): """Randomized stress tests for binary format.""" diff --git a/Misc/NEWS.d/next/Library/2026-07-01-18-00-00.gh-issue-152721.rlequad.rst b/Misc/NEWS.d/next/Library/2026-07-01-18-00-00.gh-issue-152721.rlequad.rst new file mode 100644 index 00000000000000..4dac0ed245bd67 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-01-18-00-00.gh-issue-152721.rlequad.rst @@ -0,0 +1,2 @@ +Fix quadratic replay time in the :mod:`profiling.sampling` binary reader when a +profile's run-length-encoded samples alternate thread status. diff --git a/Modules/_remote_debugging/binary_io_reader.c b/Modules/_remote_debugging/binary_io_reader.c index 8af1d281cee6b6..80627db913ea21 100644 --- a/Modules/_remote_debugging/binary_io_reader.c +++ b/Modules/_remote_debugging/binary_io_reader.c @@ -33,6 +33,9 @@ /* Progress callback frequency */ #define PROGRESS_CALLBACK_INTERVAL 1000 +/* Cap per-batch RLE samples to bound the timestamp list (gh-151378) */ +#define MAX_RLE_BATCH_SAMPLES 8192 + /* ============================================================================ * BINARY READER IMPLEMENTATION * ============================================================================ */ @@ -1083,21 +1086,6 @@ emit_sample(RemoteDebuggingState *state, PyObject *collector, return 0; } -/* Helper to trim timestamp list and emit batch. Returns 0 on success, -1 on error. */ -static int -emit_batch(RemoteDebuggingState *state, PyObject *collector, - uint64_t thread_id, uint32_t interpreter_id, uint8_t status, - const uint32_t *frame_indices, size_t stack_depth, - BinaryReader *reader, PyObject *timestamps_list, Py_ssize_t actual_size) -{ - /* Trim list to actual size */ - if (PyList_SetSlice(timestamps_list, actual_size, PyList_GET_SIZE(timestamps_list), NULL) < 0) { - return -1; - } - return emit_sample(state, collector, thread_id, interpreter_id, status, - frame_indices, stack_depth, reader, timestamps_list); -} - /* Helper to invoke progress callback, returns -1 on error */ static inline int invoke_progress_callback(PyObject *callback, Py_ssize_t current, uint64_t total) @@ -1226,17 +1214,18 @@ binary_reader_replay(BinaryReader *reader, PyObject *collector, PyObject *progre ts->prev_timestamp += delta; /* Start new batch on first sample or status change */ - if (i == 0 || status != batch_status) { + if (i == 0 || status != batch_status + || batch_idx >= MAX_RLE_BATCH_SAMPLES) { if (timestamps_list) { - int rc = emit_batch(state, collector, thread_id, interpreter_id, - batch_status, ts->current_stack, ts->current_stack_depth, - reader, timestamps_list, batch_idx); + int rc = emit_sample(state, collector, thread_id, interpreter_id, + batch_status, ts->current_stack, ts->current_stack_depth, + reader, timestamps_list); Py_DECREF(timestamps_list); if (rc < 0) { return -1; } } - timestamps_list = PyList_New(count - i); + timestamps_list = PyList_New(0); if (!timestamps_list) { return -1; } @@ -1249,14 +1238,20 @@ binary_reader_replay(BinaryReader *reader, PyObject *collector, PyObject *progre Py_DECREF(timestamps_list); return -1; } - PyList_SET_ITEM(timestamps_list, batch_idx++, ts_obj); + int append_rc = PyList_Append(timestamps_list, ts_obj); + Py_DECREF(ts_obj); + if (append_rc < 0) { + Py_DECREF(timestamps_list); + return -1; + } + batch_idx++; } /* Emit final batch */ if (timestamps_list) { - int rc = emit_batch(state, collector, thread_id, interpreter_id, - batch_status, ts->current_stack, ts->current_stack_depth, - reader, timestamps_list, batch_idx); + int rc = emit_sample(state, collector, thread_id, interpreter_id, + batch_status, ts->current_stack, ts->current_stack_depth, + reader, timestamps_list); Py_DECREF(timestamps_list); if (rc < 0) { return -1; From e27894e7315b7e3ff3805187c4dc1b32d6bf5554 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 5 Oct 2026 15:43:55 +0100 Subject: [PATCH 10/15] [3.15] gh-156545: Fix flamegraph export RecursionError on deeply recursive programs (GH-156546) (#158851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of GH-156546. Co-authored-by: tonghuaroot (童话) (cherry picked from commit 0ac721704092d8f96720c6fd1d68c5941a4a530a) --- Lib/profiling/sampling/stack_collector.py | 57 +++++++++++-------- .../test_sampling_profiler/test_collectors.py | 35 +++++++++++- ...-08-29-11-37-40.gh-issue-156545.fLaMe1.rst | 3 + 3 files changed, 71 insertions(+), 24 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-29-11-37-40.gh-issue-156545.fLaMe1.rst diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index 7410d07c7e0162..e420bb6d2e9b87 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -68,6 +68,10 @@ def export(self, filename): return True +# Bounded by the unwinder's maximum captured stack depth (MAX_FRAMES). +_FLAMEGRAPH_RECURSION_MARGIN = 2000 + + class FlamegraphCollector(StackTraceCollector): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -166,34 +170,41 @@ def set_mode(self, mode): self.stats["mode"] = mode def export(self, filename): - flamegraph_data = self._convert_to_flamegraph_format() - - # Debug output with string table statistics - num_functions = len(flamegraph_data.get("children", [])) - total_time = flamegraph_data.get("value", 0) - string_count = len(self._string_table) - s1 = "" if num_functions == 1 else "s" - s2 = "" if total_time == 1 else "s" - s3 = "" if string_count == 1 else "s" - print( - f"Flamegraph data: {num_functions} root function{s1}, " - f"{total_time} total sample{s2}, " - f"{string_count} unique string{s3}" - ) - - if num_functions == 0: + # Converting the call tree recurses to the sampled stack depth. + old_limit = sys.getrecursionlimit() + sys.setrecursionlimit(old_limit + _FLAMEGRAPH_RECURSION_MARGIN) + try: + flamegraph_data = self._convert_to_flamegraph_format() + + # Debug output with string table statistics + num_functions = len(flamegraph_data.get("children", [])) + total_time = flamegraph_data.get("value", 0) + string_count = len(self._string_table) + s1 = "" if num_functions == 1 else "s" + s2 = "" if total_time == 1 else "s" + s3 = "" if string_count == 1 else "s" print( - "Warning: No functions found in profiling data. Check if sampling captured any data." + f"Flamegraph data: {num_functions} root function{s1}, " + f"{total_time} total sample{s2}, " + f"{string_count} unique string{s3}" ) - return False - html_content = self._create_flamegraph_html(flamegraph_data) + if num_functions == 0: + print( + "Warning: No functions found in profiling data. " + "Check if sampling captured any data." + ) + return False - with open(filename, "w", encoding="utf-8") as f: - f.write(html_content) + html_content = self._create_flamegraph_html(flamegraph_data) - print(f"Flamegraph saved to: {filename}") - return True + with open(filename, "w", encoding="utf-8") as f: + f.write(html_content) + + print(f"Flamegraph saved to: {filename}") + return True + finally: + sys.setrecursionlimit(old_limit) @staticmethod @functools.lru_cache(maxsize=None) diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py index 282f97e5259e09..eb58c29dd361d3 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -4,10 +4,11 @@ import marshal import opcode import os +import sys import tempfile import unittest -from test.support import is_emscripten +from test.support import is_emscripten, set_recursion_limit try: import _remote_debugging # noqa: F401 @@ -610,6 +611,38 @@ def test_flamegraph_collector_empty_export_fails(self): self.assertFalse(export_ok) self.assertEqual(os.path.getsize(flamegraph_out.name), 0) + def test_flamegraph_deep_stack_export(self): + flamegraph_out = tempfile.NamedTemporaryFile( + suffix=".html", delete=False + ) + self.addCleanup(close_and_unlink, flamegraph_out) + + collector = FlamegraphCollector(1000) + # Deeper than the default recursion limit. + frames = [MockFrameInfo("f.py", i + 1, f"f{i}") for i in range(1536)] + collector.collect( + [MockInterpreterInfo(0, [MockThreadInfo(1, frames)])]) + + with set_recursion_limit(1000), captured_stdout(), captured_stderr(): + export_ok = collector.export(flamegraph_out.name) + self.assertEqual(sys.getrecursionlimit(), 1000) + + self.assertTrue(export_ok) + self.assertGreater(os.path.getsize(flamegraph_out.name), 0) + + def test_flamegraph_export_restores_recursion_limit(self): + collector = FlamegraphCollector(1000) + frame = MockFrameInfo("f.py", 1, "f") + with set_recursion_limit(500), captured_stdout(), captured_stderr(): + self.assertFalse(collector.export(None)) + self.assertEqual(sys.getrecursionlimit(), 500) + collector.collect([ + MockInterpreterInfo(0, [MockThreadInfo(1, [ + frame, MockFrameInfo("f.py", 2, "caller")])])]) + with self.assertRaises(TypeError): + collector.export(None) + self.assertEqual(sys.getrecursionlimit(), 500) + def test_gecko_collector_basic(self): """Test basic GeckoCollector functionality.""" collector = GeckoCollector(1000) diff --git a/Misc/NEWS.d/next/Library/2026-08-29-11-37-40.gh-issue-156545.fLaMe1.rst b/Misc/NEWS.d/next/Library/2026-08-29-11-37-40.gh-issue-156545.fLaMe1.rst new file mode 100644 index 00000000000000..cc4f57ff42adab --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-29-11-37-40.gh-issue-156545.fLaMe1.rst @@ -0,0 +1,3 @@ +Fix the sampling profiler's flamegraph export so that profiling a deeply +recursive program no longer fails with :exc:`RecursionError` instead of +producing a flamegraph. Patch by tonghuaroot. From c2cb2780872b3b827ba3e0121ee767e50f8d415c Mon Sep 17 00:00:00 2001 From: "Miss Islington (bot)" <31488909+miss-islington@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:50:09 -0700 Subject: [PATCH 11/15] [3.15] gh-158540: Add the profiled script's directory to sys.path (GH-158548) (#158844) gh-158540: Add the profiled script's directory to sys.path (GH-158548) * gh-158540: Add the profiled script's directory to sys.path When a script is profiled with ``python -m profiling.sampling run script.py`` from another directory, the script cannot import modules placed next to it, because ``_sync_coordinator._execute_script()`` executes it with the working directory (added by ``_setup_environment()`` for the module case) as ``sys.path[0]`` instead of the script's own directory. Make the script's directory importable in ``_execute_script()``, matching the behavior of ``python script.py``. Add a regression test that runs the coordinator on a script importing a sibling module. * Update _sync_coordinator.py Comment simplified * gh-158540: Resolve symlinks when adding the script directory to sys.path ``python script.py`` resolves symlinks when computing ``sys.path[0]``, so a script reached through a symlink (``link.py -> sub/where.py``) imports modules from the real script's directory. Apply ``os.path.realpath()`` before taking the directory name, and make sure the result is placed at the front of ``sys.path`` even if it was already listed. Add a regression test for a symlinked script. --------- (cherry picked from commit 3f02aab9faa338853425da23541e44e4bd24d478) Co-authored-by: he_tao <53343436+hetaozdh@users.noreply.github.com> Co-authored-by: Eduardo Villalpando Mello (cherry picked from commit f52d831397ec77cf969c71c1ef339c1e28494eca) --- Lib/profiling/sampling/_sync_coordinator.py | 5 + .../test_sync_coordinator.py | 113 ++++++++++++++++++ ...-09-30-22-10-00.gh-issue-158540.kQ9vXm.rst | 4 + 3 files changed, 122 insertions(+) create mode 100644 Lib/test/test_profiling/test_sampling_profiler/test_sync_coordinator.py create mode 100644 Misc/NEWS.d/next/Library/2026-09-30-22-10-00.gh-issue-158540.kQ9vXm.rst diff --git a/Lib/profiling/sampling/_sync_coordinator.py b/Lib/profiling/sampling/_sync_coordinator.py index a1cce314b33b19..bb0ee5b5317d69 100644 --- a/Lib/profiling/sampling/_sync_coordinator.py +++ b/Lib/profiling/sampling/_sync_coordinator.py @@ -168,6 +168,11 @@ def _execute_script(script_path: str, script_args: List[str], cwd: str) -> None: if not os.path.isfile(script_path): raise TargetError(f"Script not found: {script_path}") + script_dir = os.path.dirname(os.path.realpath(script_path)) + if script_dir in sys.path: + sys.path.remove(script_dir) + sys.path.insert(0, script_dir) + # Replace sys.argv to match original script call sys.argv = [script_path] + script_args diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_sync_coordinator.py b/Lib/test/test_profiling/test_sampling_profiler/test_sync_coordinator.py new file mode 100644 index 00000000000000..936c01e206f9fb --- /dev/null +++ b/Lib/test/test_profiling/test_sampling_profiler/test_sync_coordinator.py @@ -0,0 +1,113 @@ +"""Tests for the sampling profiler's synchronization coordinator.""" + +import os +import socket +import subprocess +import sys +import tempfile +import unittest + +from test.support import SHORT_TIMEOUT, os_helper, requires_subprocess + + +@requires_subprocess() +class TestSyncCoordinatorScriptExecution(unittest.TestCase): + """Tests for how the coordinator executes a target script.""" + + def run_coordinator(self, cwd, target, *target_args): + """Execute *target* from *cwd* with the sync coordinator. + + Returns the ``(stdout, stderr)`` captured from the coordinator. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server: + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind(("127.0.0.1", 0)) + server.listen(1) + server.settimeout(SHORT_TIMEOUT) + port = server.getsockname()[1] + + cmd = ( + sys.executable, + "-m", + "profiling.sampling._sync_coordinator", + str(port), + cwd, + target, + ) + tuple(target_args) + process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + conn, _ = server.accept() + with conn: + # _signal_readiness() sends b"ready" before running target. + self.assertEqual(conn.recv(64), b"ready") + stdout, stderr = process.communicate(timeout=SHORT_TIMEOUT) + finally: + if process.poll() is None: + process.kill() + process.wait() + return stdout, stderr + + def test_script_directory_on_sys_path(self): + # gh-158540: The coordinator must put the directory containing the + # script (not the working directory) on sys.path, matching + # ``python script.py``, so that modules next to the script can be + # imported even when the coordinator runs from another directory. + with tempfile.TemporaryDirectory() as tmpdir: + script_dir = os.path.join(tmpdir, "sub") + os.mkdir(script_dir) + with open(os.path.join(script_dir, "helper.py"), "w") as f: + f.write("message = 'helper imported'\n") + with open(os.path.join(script_dir, "where.py"), "w") as f: + f.write( + "import os\n" + "import sys\n" + "print('PATH0:', os.path.realpath(sys.path[0]))\n" + "import helper\n" + "print('HELPER:', helper.message)\n" + ) + + stdout, stderr = self.run_coordinator( + tmpdir, os.path.join("sub", "where.py") + ) + + self.assertNotIn("ModuleNotFoundError", stderr) + self.assertIn("HELPER: helper imported", stdout) + self.assertIn(f"PATH0: {os.path.realpath(script_dir)}", stdout) + + @os_helper.skip_unless_symlink + def test_symlinked_script_uses_real_directory(self): + # gh-158540: ``python script.py`` resolves symlinks when computing + # sys.path[0], so a symlinked script must import modules next to the + # real script, not next to the link. + with tempfile.TemporaryDirectory() as tmpdir: + script_dir = os.path.join(tmpdir, "sub") + os.mkdir(script_dir) + with open(os.path.join(script_dir, "helper.py"), "w") as f: + f.write("message = 'helper imported'\n") + with open(os.path.join(script_dir, "where.py"), "w") as f: + f.write( + "import os\n" + "import sys\n" + "print('PATH0:', os.path.realpath(sys.path[0]))\n" + "import helper\n" + "print('HELPER:', helper.message)\n" + ) + os.symlink( + os.path.join("sub", "where.py"), + os.path.join(tmpdir, "link.py"), + ) + + stdout, stderr = self.run_coordinator(tmpdir, "link.py") + + self.assertNotIn("ModuleNotFoundError", stderr) + self.assertIn("HELPER: helper imported", stdout) + self.assertIn(f"PATH0: {os.path.realpath(script_dir)}", stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/Misc/NEWS.d/next/Library/2026-09-30-22-10-00.gh-issue-158540.kQ9vXm.rst b/Misc/NEWS.d/next/Library/2026-09-30-22-10-00.gh-issue-158540.kQ9vXm.rst new file mode 100644 index 00000000000000..9cbd698cf9a96e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-30-22-10-00.gh-issue-158540.kQ9vXm.rst @@ -0,0 +1,4 @@ +Fix :mod:`profiling.sampling` not adding the directory containing the +profiled script to :data:`sys.path`, which prevented the script from +importing modules placed next to it. The script's directory is now added, +matching the behavior of ``python script.py``. From 78519d4bcaecc3de5fa76508ddf0b0b72eb2aafd Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 5 Oct 2026 15:56:04 +0100 Subject: [PATCH 12/15] [3.15] gh-153838: Skip non-regular source files in the heatmap exporter (GH-153839) (#158853) Backport of GH-153839. Co-authored-by: tonghuaroot (cherry picked from commit 48998df2510c0daa837f98bbbbe6cc49ef60dc43) --- Lib/profiling/sampling/heatmap_collector.py | 14 +++---- Lib/test/test_profiling/test_heatmap.py | 41 ++++++++++++++++--- ...-07-17-12-00-00.gh-issue-153838.HmCp5s.rst | 2 + 3 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-07-17-12-00-00.gh-issue-153838.HmCp5s.rst diff --git a/Lib/profiling/sampling/heatmap_collector.py b/Lib/profiling/sampling/heatmap_collector.py index 0361fd357b222d..38f4d7985ff691 100644 --- a/Lib/profiling/sampling/heatmap_collector.py +++ b/Lib/profiling/sampling/heatmap_collector.py @@ -785,14 +785,14 @@ def _generate_file_html(self, output_path: Path, filename: str, line_counts: Dict[int, int], self_counts: Dict[int, int], file_stat: FileStats): """Generate HTML for a single source file with heatmap coloring.""" - # Read source file + source_lines = [f"# Source file not available: {filename}"] try: - source_lines = Path(filename).read_text(encoding='utf-8', errors='replace').splitlines() - except (IOError, OSError) as e: - if not (filename.startswith('<') or filename.startswith('[') or - filename in ('~', '...', '.') or len(filename) < 2): - print(f"Warning: Could not read source file {filename}: {e}") - source_lines = [f"# Source file not available: {filename}"] + path = Path(filename) + if path.is_file(): + source_lines = path.read_text( + encoding='utf-8', errors='replace').splitlines() + except (IOError, OSError): + pass # Generate HTML for each line max_samples = max(line_counts.values()) if line_counts else 1 diff --git a/Lib/test/test_profiling/test_heatmap.py b/Lib/test/test_profiling/test_heatmap.py index ee27fdd3fa3053..1b4de0caa3d00f 100644 --- a/Lib/test/test_profiling/test_heatmap.py +++ b/Lib/test/test_profiling/test_heatmap.py @@ -4,6 +4,7 @@ import shutil import tempfile import unittest +from unittest import mock from collections import namedtuple from pathlib import Path @@ -615,13 +616,43 @@ def test_export_file_html_has_line_numbers(self): html_files = [f for f in os.listdir(output_path) if f.startswith('file_') and f.endswith('.html')] - if html_files: - with open(os.path.join(output_path, html_files[0]), 'r', encoding='utf-8') as f: - content = f.read() + self.assertEqual(len(html_files), 1) + with open(os.path.join(output_path, html_files[0]), 'r', encoding='utf-8') as f: + content = f.read() + + # Should have line-related content + self.assertIn('line-', content) + + def test_export_skips_nonexistent_source(self): + self.check_unavailable_source(os.path.join(self.test_dir, 'missing.py')) - # Should have line-related content - self.assertIn('line-', content) + def test_export_skips_directory_source(self): + self.check_unavailable_source(self.test_dir) + @unittest.skipUnless(hasattr(os, 'mkfifo'), 'requires os.mkfifo') + def test_export_skips_fifo_source(self): + filename = os.path.join(self.test_dir, 'source.fifo') + os.mkfifo(filename) + self.check_unavailable_source(filename) + + def check_unavailable_source(self, filename): + collector = HeatmapCollector(sample_interval_usec=100) + frames = [(filename, (1, 1, -1, -1), 'f', None)] + collector.process_frames(frames, thread_id=1) + output_path = Path(self.test_dir) / 'unavailable_source' + read_text = Path.read_text + + def check_read(path, *args, **kwargs): + self.assertNotEqual(path, Path(filename), + 'Non-regular source must not be opened') + return read_text(path, *args, **kwargs) + + with (captured_stdout(), captured_stderr(), + mock.patch.object(Path, 'read_text', check_read)): + collector.export(output_path) + content = (output_path / collector.file_index[filename]).read_text( + encoding='utf-8') + self.assertIn('Source file not available', content) class MockFrameInfo: """Mock FrameInfo for testing. diff --git a/Misc/NEWS.d/next/Library/2026-07-17-12-00-00.gh-issue-153838.HmCp5s.rst b/Misc/NEWS.d/next/Library/2026-07-17-12-00-00.gh-issue-153838.HmCp5s.rst new file mode 100644 index 00000000000000..faecab8cff3e7b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-17-12-00-00.gh-issue-153838.HmCp5s.rst @@ -0,0 +1,2 @@ +Skip non-regular files in the ``profiling.sampling`` heatmap exporter +instead of reading them unconditionally. Patch by tonghuaroot. From 0e8dc0901d248c2c4e2554404cc377c785bdfbf9 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 5 Oct 2026 16:17:02 +0100 Subject: [PATCH 13/15] [3.15] gh-158539: Fix exception mode missing handlers in generators/coroutines (GH-158581) (#158852) * [3.15] gh-158539: Fix exception mode missing handlers in generators/coroutines (GH-158581) Backport of GH-158581. Co-authored-by: LucasZhou * [3.15] gh-158539: Use portable static assertion messages * [3.15] gh-158539: Keep layout assertions with debug-offset validation * [3.15] gh-158539: Use the platform guard for in-process inspection tests --------- Co-authored-by: LucasZhou (cherry picked from commit d625ecbb8d36b7dd26c61b13d1c97de117d36642) --- Lib/test/test_external_inspection.py | 236 ++++++++++++++++++ ...-10-01-14-00-00.gh-issue-158539.q1w2e3.rst | 5 + .../debug_offsets_validation.h | 15 +- Modules/_remote_debugging/threads.c | 54 +++- 4 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-01-14-00-00.gh-issue-158539.q1w2e3.rst diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index a0bedf4910fa2a..fa37629c7a9a1b 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -3219,6 +3219,242 @@ def test_finally_no_exception_no_flag(self): self._check_exception_status(p, thread_tid, expect_exception=False) +@skip_if_not_supported +class TestExceptionDetectionInProcess(RemoteInspectionTestBase): + """gh-158539: HAS_EXCEPTION for handlers running in generators/coroutines. + + ``TestExceptionDetectionScenarios`` samples a child process and therefore + needs subprocess debugging permissions. These tests inspect the current + process with ``RemoteUnwinder`` and only need self-inspection, so they also + run on macOS without special entitlements. + """ + + @classmethod + def setUpClass(cls): + try: + RemoteUnwinder(os.getpid(), all_threads=True).get_stack_trace() + except PermissionError as exc: + raise unittest.SkipTest(f"self-inspection is unavailable: {exc}") + + def _check_running_handler( + self, target, expect_exception, *, mode=PROFILING_MODE_ALL, + skip_non_matching_threads=False, + ): + """Run *target* in a thread and check its HAS_EXCEPTION flag. + + *target* receives ``(ready, stop)`` events and must signal ``ready`` + only once it is executing inside the code region under test, then keep + running until ``stop`` is set. + """ + stop = threading.Event() + ready = threading.Event() + failure = [] + + def runner(): + try: + target(ready, stop) + except BaseException as exc: + failure.append(exc) + ready.set() + + thread = threading.Thread(target=runner, daemon=True) + thread.start() + try: + self.assertTrue(ready.wait(SHORT_TIMEOUT), "handler never started") + self.assertFalse(failure, f"handler raised {failure!r}") + + unwinder = RemoteUnwinder( + os.getpid(), + all_threads=True, + mode=mode, + skip_non_matching_threads=skip_non_matching_threads, + ) + observed = [] + for _ in busy_retry(SHORT_TIMEOUT): + with contextlib.suppress(*TRANSIENT_ERRORS): + statuses = self._get_thread_statuses(unwinder.get_stack_trace()) + status = statuses.get(thread.native_id) + if status is None: + continue + has_exception = bool(status & THREAD_STATUS_HAS_EXCEPTION) + observed.append(has_exception) + if has_exception == expect_exception: + break + self.assertTrue( + observed, "target thread status was never observed" + ) + self.assertIn( + expect_exception, + observed, + f"HAS_EXCEPTION was never {expect_exception} while the " + f"handler was running (observed {observed})", + ) + finally: + stop.set() + thread.join(SHORT_TIMEOUT) + + def _busy_until_stopped(self, ready, stop): + ready.set() + while not stop.is_set(): + time.sleep(0.001) + + def test_handler_in_function(self): + def target(ready, stop): + try: + raise ValueError("test") + except ValueError: + self._busy_until_stopped(ready, stop) + + self._check_running_handler(target, expect_exception=True) + + def test_handler_in_generator(self): + def target(ready, stop): + def gen(): + try: + raise ValueError("test") + except ValueError: + self._busy_until_stopped(ready, stop) + yield + + for _ in gen(): + pass + + self._check_running_handler(target, expect_exception=True) + + def test_handler_in_genexpr_callee(self): + def target(ready, stop): + def callee(): + try: + raise ValueError("test") + except ValueError: + self._busy_until_stopped(ready, stop) + + list(callee() for _ in range(1)) + + self._check_running_handler(target, expect_exception=True) + + def test_handler_in_coroutine(self): + async def coro(ready, stop): + try: + raise ValueError("test") + except ValueError: + self._busy_until_stopped(ready, stop) + + def target(ready, stop): + asyncio.run(coro(ready, stop)) + + self._check_running_handler(target, expect_exception=True) + + def test_handler_in_callee_from_coroutine(self): + def callee(ready, stop): + try: + raise ValueError("test") + except ValueError: + self._busy_until_stopped(ready, stop) + + async def coro(ready, stop): + callee(ready, stop) + + def target(ready, stop): + asyncio.run(coro(ready, stop)) + + self._check_running_handler(target, expect_exception=True) + + def test_outer_handler_while_generator_runs(self): + """A generator with no handler of its own must not hide the outer one. + + ``exc_info`` points at the generator's empty ``_PyErr_StackItem`` whose + ``previous_item`` is the thread's ``exc_state``, so the profiler has to + walk the chain to find the exception ``sys.exception()`` reports. + """ + def target(ready, stop): + def gen(): + self._busy_until_stopped(ready, stop) + yield + + try: + raise ValueError("outer") + except ValueError: + for _ in gen(): + pass + + self._check_running_handler(target, expect_exception=True) + + def test_generator_without_exception(self): + def target(ready, stop): + def gen(): + self._busy_until_stopped(ready, stop) + yield + + for _ in gen(): + pass + + self._check_running_handler(target, expect_exception=False) + + def test_outer_handler_while_nested_generators_run(self): + def target(ready, stop): + def gen(depth): + if depth: + yield from gen(depth - 1) + else: + self._busy_until_stopped(ready, stop) + yield + + try: + raise ValueError("outer") + except ValueError: + for _ in gen(32): + pass + + self._check_running_handler( + target, + expect_exception=True, + mode=PROFILING_MODE_EXCEPTION, + skip_non_matching_threads=True, + ) + + def test_generator_finally_after_except(self): + """The handled exception is cleared before the generator's finally.""" + def target(ready, stop): + def gen(): + try: + raise ValueError("test") + except ValueError: + pass + finally: + self._busy_until_stopped(ready, stop) + yield + + for _ in gen(): + pass + + self._check_running_handler(target, expect_exception=False) + + def test_exception_mode_filter_keeps_generator_handler(self): + """The exception-mode thread filter must not drop a generator handler. + + This mirrors what ``--mode=exception`` actually does: threads without + HAS_EXCEPTION are skipped before their stack is unwound. + """ + def target(ready, stop): + def gen(): + try: + raise ValueError("test") + except ValueError: + self._busy_until_stopped(ready, stop) + yield + + for _ in gen(): + pass + + self._check_running_handler( + target, + expect_exception=True, + mode=PROFILING_MODE_EXCEPTION, + skip_non_matching_threads=True, + ) + + @requires_remote_subprocess_debugging() class TestFrameCaching(RemoteInspectionTestBase): """Test that frame caching produces correct results. diff --git a/Misc/NEWS.d/next/Library/2026-10-01-14-00-00.gh-issue-158539.q1w2e3.rst b/Misc/NEWS.d/next/Library/2026-10-01-14-00-00.gh-issue-158539.q1w2e3.rst new file mode 100644 index 00000000000000..b1ab9c8e017fb2 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-01-14-00-00.gh-issue-158539.q1w2e3.rst @@ -0,0 +1,5 @@ +Fix :mod:`profiling.sampling` exception mode discarding samples for code +running inside an ``except`` block in a generator or coroutine. The remote +debugger now follows ``tstate->exc_info`` and its ``previous_item`` chain +instead of only reading the embedded ``exc_state``, matching the exception +that :func:`sys.exception` reports. diff --git a/Modules/_remote_debugging/debug_offsets_validation.h b/Modules/_remote_debugging/debug_offsets_validation.h index c0c01a0a639e19..66c35e5522a9c7 100644 --- a/Modules/_remote_debugging/debug_offsets_validation.h +++ b/Modules/_remote_debugging/debug_offsets_validation.h @@ -48,6 +48,15 @@ static_assert( PY_REMOTE_ASYNC_DEBUG_OFFSETS_TOTAL_SIZE, "Update _remote_debugging validation for _Py_AsyncioModuleDebugOffsets"); +/* Derive unexported offsets from adjacent fields to keep the debug-offset + * table compatible across patch releases. */ +static_assert(offsetof(PyThreadState, exc_info) == + offsetof(PyThreadState, current_exception) + sizeof(uintptr_t), + "exc_info must immediately follow current_exception"); +static_assert(offsetof(_PyErr_StackItem, previous_item) == + offsetof(_PyErr_StackItem, exc_value) + sizeof(uintptr_t), + "previous_item must immediately follow exc_value"); + /* * This logic lives in a private header because it is shared by module.c and * asyncio.c. Keep the helpers static inline so they stay local to those users @@ -249,6 +258,7 @@ validate_fixed_field( #define PY_REMOTE_DEBUG_RUNTIME_STATE_FIELDS(APPLY, buffer_size) \ APPLY(runtime_state, interpreters_head, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size) +/* current_exception also covers the adjacent exc_info pointer. */ #define PY_REMOTE_DEBUG_THREAD_STATE_FIELDS(APPLY, buffer_size) \ APPLY(thread_state, native_thread_id, sizeof(unsigned long), _Alignof(long), buffer_size); \ APPLY(thread_state, interp, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ @@ -256,7 +266,7 @@ validate_fixed_field( APPLY(thread_state, status, FIELD_SIZE(PyThreadState, _status), _Alignof(unsigned int), buffer_size); \ APPLY(thread_state, holds_gil, sizeof(int), _Alignof(int), buffer_size); \ APPLY(thread_state, gil_requested, sizeof(int), _Alignof(int), buffer_size); \ - APPLY(thread_state, current_exception, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ + APPLY(thread_state, current_exception, 2 * sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ APPLY(thread_state, thread_id, sizeof(unsigned long), _Alignof(long), buffer_size); \ APPLY(thread_state, next, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ APPLY(thread_state, current_frame, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ @@ -351,10 +361,11 @@ _PyRemoteDebug_ValidateDebugOffsetsLayout(struct _Py_DebugOffsets *debug_offsets PY_REMOTE_DEBUG_THREAD_STATE_FIELDS( PY_REMOTE_DEBUG_VALIDATE_FIELD, SIZEOF_THREAD_STATE); + /* exc_value also covers the adjacent previous_item pointer. */ PY_REMOTE_DEBUG_VALIDATE_FIXED_FIELD( err_stackitem, exc_value, - sizeof(uintptr_t), + 2 * sizeof(uintptr_t), _Alignof(uintptr_t), sizeof(_PyErr_StackItem)); PY_REMOTE_DEBUG_VALIDATE_NESTED_FIELD( diff --git a/Modules/_remote_debugging/threads.c b/Modules/_remote_debugging/threads.c index 666316ceaa28fc..198134fe6cfbea 100644 --- a/Modules/_remote_debugging/threads.c +++ b/Modules/_remote_debugging/threads.c @@ -17,6 +17,9 @@ #include #endif +/* Bound traversal of corrupted remote exception chains. */ +#define MAX_EXCEPTION_CHAIN_DEPTH (2 << 15) + /* ============================================================================ * THREAD ITERATION FUNCTIONS * ============================================================================ */ @@ -436,16 +439,49 @@ unwind_stack_for_thread( has_exception = 1; } - // Check exc_state.exc_value (exception being handled in except block) - // exc_state is embedded in PyThreadState, so we read it directly from - // the thread state buffer. This catches most cases; nested exception - // handlers where exc_info points elsewhere are rare. + // Generators and coroutines use their own exception stack items. + // Follow exc_info to find the innermost handler, as sys.exception() does. if (!has_exception) { - uintptr_t exc_value = GET_MEMBER(uintptr_t, ts, - unwinder->debug_offsets.thread_state.exc_state + - unwinder->debug_offsets.err_stackitem.exc_value); - if (exc_value != 0) { - has_exception = 1; + uintptr_t exc_info = GET_MEMBER(uintptr_t, ts, + unwinder->debug_offsets.thread_state.current_exception + + sizeof(uintptr_t)); + uintptr_t exc_state_addr = + *current_tstate + unwinder->debug_offsets.thread_state.exc_state; + uintptr_t exc_value_offset = + unwinder->debug_offsets.err_stackitem.exc_value; + uintptr_t previous_item_offset = + exc_value_offset + sizeof(uintptr_t); + + for (int depth = 0; exc_info != 0 && depth < MAX_EXCEPTION_CHAIN_DEPTH; + depth++) + { + if (exc_info == exc_state_addr) { + // Bottom of the chain: the stack item embedded in the thread + // state, which is already in the local thread state buffer. + uintptr_t exc_value = GET_MEMBER(uintptr_t, ts, + unwinder->debug_offsets.thread_state.exc_state + + exc_value_offset); + if (exc_value != 0) { + has_exception = 1; + } + break; + } + uintptr_t exc_value = 0; + if (read_ptr(unwinder, exc_info + exc_value_offset, &exc_value) < 0) { + PyErr_Clear(); // Best effort: treat as no active exception + break; + } + if (exc_value != 0) { + has_exception = 1; + break; + } + uintptr_t previous_item = 0; + if (read_ptr(unwinder, exc_info + previous_item_offset, + &previous_item) < 0) { + PyErr_Clear(); + break; + } + exc_info = previous_item; } } From 9fef40a4888cab97762c4ff492cda23e21ff0cd5 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 5 Oct 2026 17:18:26 +0100 Subject: [PATCH 14/15] [3.15] gh-158522: Fix truncated stack for a task whose coroutine recurses (GH-158526) (#158870) Co-authored-by: Timofei Ivankov <128279579+deadlovelll@users.noreply.github.com> (cherry picked from commit a904b397ed324e404a8abd682b056c146cb48263) --- Lib/test/test_external_inspection.py | 16 ++++++++++ ...-09-30-20-09-46.gh-issue-158522.L6j7tU.rst | 2 ++ Modules/_remote_debugging/_remote_debugging.h | 6 ++-- Modules/_remote_debugging/asyncio.c | 32 +++++++------------ 4 files changed, 32 insertions(+), 24 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-30-20-09-46.gh-issue-158522.L6j7tU.rst diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index fa37629c7a9a1b..eaec2943ecbaaf 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -492,6 +492,22 @@ async def main(): self.assertIn(main_name, names) self.assertEqual([len(n) for n in names if n.startswith("x")], [255]) + @skip_if_not_supported + def test_recursive_coroutine_stack_is_not_truncated(self): + # gh-158522 + async def rec(n): + if n: + return await rec(n - 1) + return [ + frame.funcname.rpartition(".")[2] + for task in RemoteUnwinder( + os.getpid()).get_async_stack_trace()[0].awaited_by + for coro in task.coroutine_stack + for frame in coro.call_stack + ] + + self.assertEqual(asyncio.run(rec(3)), ["rec"] * 4) + @skip_if_not_supported @unittest.skipIf( sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, diff --git a/Misc/NEWS.d/next/Library/2026-09-30-20-09-46.gh-issue-158522.L6j7tU.rst b/Misc/NEWS.d/next/Library/2026-09-30-20-09-46.gh-issue-158522.L6j7tU.rst new file mode 100644 index 00000000000000..30c2df4bacd4a1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-30-20-09-46.gh-issue-158522.L6j7tU.rst @@ -0,0 +1,2 @@ +Fix :mod:`profiling.sampling` showing a single frame for a task whose +coroutine recurses. Patch by Timofei Ivankov. diff --git a/Modules/_remote_debugging/_remote_debugging.h b/Modules/_remote_debugging/_remote_debugging.h index c57b30533ce1c1..79fa4a92e745bd 100644 --- a/Modules/_remote_debugging/_remote_debugging.h +++ b/Modules/_remote_debugging/_remote_debugging.h @@ -729,7 +729,7 @@ extern int parse_async_frame_chain( RemoteUnwinderObject *unwinder, PyObject *calls, uintptr_t address_of_thread, - uintptr_t running_task_code_obj + uintptr_t running_task_frame ); extern int process_single_task_node( @@ -745,10 +745,10 @@ extern int find_running_task_in_thread( uintptr_t *running_task_addr ); -extern int get_task_code_object( +extern int get_task_frame( RemoteUnwinderObject *unwinder, uintptr_t task_addr, - uintptr_t *code_obj_addr + uintptr_t *frame_addr ); extern int append_awaited_by( diff --git a/Modules/_remote_debugging/asyncio.c b/Modules/_remote_debugging/asyncio.c index aa3f5b638931f8..c8d79774a47a73 100644 --- a/Modules/_remote_debugging/asyncio.c +++ b/Modules/_remote_debugging/asyncio.c @@ -716,7 +716,7 @@ find_running_task_in_thread( } int -get_task_code_object(RemoteUnwinderObject *unwinder, uintptr_t task_addr, uintptr_t *code_obj_addr) { +get_task_frame(RemoteUnwinderObject *unwinder, uintptr_t task_addr, uintptr_t *frame_addr) { uintptr_t running_coro_addr = 0; if(read_py_ptr( @@ -733,21 +733,7 @@ get_task_code_object(RemoteUnwinderObject *unwinder, uintptr_t task_addr, uintpt return -1; } - // note: genobject's gi_iframe is an embedded struct so the address to - // the offset leads directly to its first field: f_executable - if (read_py_ptr( - unwinder, - running_coro_addr + (uintptr_t)unwinder->debug_offsets.gen_object.gi_iframe, code_obj_addr) < 0) { - set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read running task code object"); - return -1; - } - - if (*code_obj_addr == 0) { - PyErr_SetString(PyExc_RuntimeError, "Running task code object is NULL"); - set_exception_cause(unwinder, PyExc_RuntimeError, "Running task code object address is NULL"); - return -1; - } - + *frame_addr = running_coro_addr + (uintptr_t)unwinder->debug_offsets.gen_object.gi_iframe; return 0; } @@ -760,7 +746,7 @@ parse_async_frame_chain( RemoteUnwinderObject *unwinder, PyObject *calls, uintptr_t address_of_thread, - uintptr_t running_task_code_obj + uintptr_t running_task_frame ) { uintptr_t address_of_current_frame; if (find_running_frame(unwinder, address_of_thread, &address_of_current_frame) < 0) { @@ -777,6 +763,8 @@ parse_async_frame_chain( } PyObject* frame_info = NULL; uintptr_t address_of_code_object; + + uintptr_t this_frame = address_of_current_frame; int res = parse_frame_object( unwinder, &frame_info, @@ -802,7 +790,9 @@ parse_async_frame_chain( Py_DECREF(frame_info); - if (address_of_code_object == running_task_code_obj) { + // Stop at the task's own frame. Code objects are shared by + // recursive calls, so they cannot identify it. + if (this_frame == running_task_frame) { break; } } @@ -938,8 +928,8 @@ process_running_task_chain( uintptr_t thread_state_addr, PyObject *result ) { - uintptr_t running_task_code_obj = 0; - if(get_task_code_object(unwinder, running_task_addr, &running_task_code_obj) < 0) { + uintptr_t running_task_frame = 0; + if(get_task_frame(unwinder, running_task_addr, &running_task_frame) < 0) { return -1; } @@ -971,7 +961,7 @@ process_running_task_chain( } // Add the chain from the current frame to this task - if (parse_async_frame_chain(unwinder, frame_chain, thread_state_addr, running_task_code_obj) < 0) { + if (parse_async_frame_chain(unwinder, frame_chain, thread_state_addr, running_task_frame) < 0) { return -1; } From 82b7d1fa067656b2e1c4dbedb45da667180e8746 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 5 Oct 2026 20:56:24 +0100 Subject: [PATCH 15/15] gh-156545, gh-158539: Fix deep flamegraph export on small C stacks and macOS runtime lookup (#158874) (cherry picked from commit 114de198c0a05d8169cdbbd8f1e0976dc1319284) Include the C-stack test helper from main, introduced by ce5ae29ef9b8ccb32683c922d135339311c36c9d, which the regression test requires but 3.15 does not yet provide. --- Lib/profiling/sampling/stack_collector.py | 11 ++- Lib/test/support/__init__.py | 95 ++++++++++++++++--- .../test_sampling_profiler/test_collectors.py | 3 +- ...-10-05-18-00-00.gh-issue-156545.z1mQp7.rst | 2 + ...-10-05-18-00-01.gh-issue-158539.k8Lv2a.rst | 2 + Python/remote_debug.h | 69 +++----------- 6 files changed, 105 insertions(+), 77 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-05-18-00-00.gh-issue-156545.z1mQp7.rst create mode 100644 Misc/NEWS.d/next/Library/2026-10-05-18-00-01.gh-issue-158539.k8Lv2a.rst diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index e420bb6d2e9b87..9f73fa0db8dccf 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -68,8 +68,8 @@ def export(self, filename): return True -# Bounded by the unwinder's maximum captured stack depth (MAX_FRAMES). -_FLAMEGRAPH_RECURSION_MARGIN = 2000 +# Allow for tree conversion and the dict/list frames in the Python JSON encoder. +_FLAMEGRAPH_RECURSION_MARGIN = 6000 class FlamegraphCollector(StackTraceCollector): @@ -498,7 +498,12 @@ def _get_source_lines(self, func): return None def _create_flamegraph_html(self, data): - data_json = json.dumps(data) + try: + data_json = json.dumps(data) + except RecursionError: + # The C encoder can exhaust the C stack independently of the + # Python recursion limit. iterencode() uses the Python encoder. + data_json = "".join(json.JSONEncoder().iterencode(data)) template_dir = importlib.resources.files(__package__) vendor_dir = template_dir / "_vendor" diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py index 8e13ab4b9bc794..ac87f3962b29e0 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -46,6 +46,7 @@ "check_disallow_instantiation", "check_sanitizer", "skip_if_sanitizer", "requires_limited_api", "requires_specialization", "thread_unsafe", "skip_if_unlimited_stack_size", "skip_if_huge_c_stack", + "run_with_limited_c_stack", # sys "MS_WINDOWS", "is_jython", "is_android", "is_emscripten", "is_wasi", "is_apple_mobile", "check_impl_detail", "unix_shell", "setswitchinterval", @@ -2830,6 +2831,26 @@ def exceeds_recursion_limit(): return 150_000 +def _has_huge_c_stack(depth): + """Check that *depth* recursive calls cannot exhaust the C stack.""" + try: + from _testinternalcapi import get_c_recursion_remaining + except ImportError: + # Fall back to checking for an unlimited stack size. + if is_emscripten or is_wasi or os.name == "nt": + return False + import resource + soft, hard = resource.getrlimit(resource.RLIMIT_STACK) + return soft == hard and soft in (-1, 0xFFFF_FFFF_FFFF_FFFF) + else: + remaining = get_c_recursion_remaining() + # A negative value means integer overflow in the estimate + # (e.g. with an unlimited RLIMIT_STACK). The estimate is based on + # the size of the interpreter loop frame, so it is only a lower + # bound for recursion with smaller C frames. + return remaining >= depth or remaining < 0 + + def skip_if_huge_c_stack(depth=150_000): """Skip decorator for tests which cannot overflow the C stack. @@ -2837,23 +2858,67 @@ def skip_if_huge_c_stack(depth=150_000): trigger the recursion protection if the C stack is too large (e.g. with a large or unlimited RLIMIT_STACK), and either fail, or run for a very long time, or crash, or consume all memory. + + Prefer run_with_limited_c_stack() for tests recursing to a fixed depth. """ - try: - from _testinternalcapi import get_c_recursion_remaining - except ImportError: - # Fall back to checking for an unlimited stack size. - huge = False - if not (is_emscripten or is_wasi) and os.name != "nt": - import resource - soft, hard = resource.getrlimit(resource.RLIMIT_STACK) - huge = soft == hard and soft in (-1, 0xFFFF_FFFF_FFFF_FFFF) - else: - remaining = get_c_recursion_remaining() - # A negative value means integer overflow in the estimate - # (e.g. with an unlimited RLIMIT_STACK). - huge = remaining >= depth or remaining < 0 return unittest.skipIf( - huge, f"the C stack is large enough for {depth} recursive calls") + _has_huge_c_stack(depth), + f"the C stack is large enough for {depth} recursive calls") + + +# Small enough to be exhausted by tens of thousands of recursive calls, +# but not smaller than Py_C_STACK_SIZE (4 MiB) which the interpreter +# assumes if it cannot query the thread stack size. +C_STACK_SIZE = 8 * 1024 * 1024 + + +def run_with_limited_c_stack(depth=150_000, size=C_STACK_SIZE): + """Decorator for tests exhausting the C stack with *depth* recursive calls. + + Run the test in a separate thread with the C stack of *size* bytes, so + that the outcome does not depend on the C stack size of the main thread + (which can be large or unlimited, see RLIMIT_STACK). + + If a thread with the limited C stack cannot be created, run the test in + the current thread, but skip it if the C stack is too large. + """ + reason = f"the C stack is large enough for {depth} recursive calls" + def decorator(test): + @functools.wraps(test) + def wrapper(*args, **kwargs): + def run_test(): + # The C stack can still be too large if limiting it failed. + if _has_huge_c_stack(depth): + raise unittest.SkipTest(reason) + test(*args, **kwargs) + + try: + import threading + old_size = threading.stack_size(size) + except (ImportError, ValueError, RuntimeError): + # Setting the thread stack size is not supported. + return run_test() + + exceptions = [] + def run(): + try: + run_test() + except BaseException as exc: + exceptions.append(exc) + + thread = threading.Thread(target=run) + try: + thread.start() + except RuntimeError: + # Threads are not supported. + return run_test() + finally: + threading.stack_size(old_size) + thread.join() + if exceptions: + raise exceptions[0] + return wrapper + return decorator # Windows doesn't have os.uname() but it doesn't support s390x. diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py index eb58c29dd361d3..1d398a1f5d7a2a 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -8,7 +8,7 @@ import tempfile import unittest -from test.support import is_emscripten, set_recursion_limit +from test.support import is_emscripten, run_with_limited_c_stack, set_recursion_limit try: import _remote_debugging # noqa: F401 @@ -611,6 +611,7 @@ def test_flamegraph_collector_empty_export_fails(self): self.assertFalse(export_ok) self.assertEqual(os.path.getsize(flamegraph_out.name), 0) + @run_with_limited_c_stack(size=1024 * 1024) def test_flamegraph_deep_stack_export(self): flamegraph_out = tempfile.NamedTemporaryFile( suffix=".html", delete=False diff --git a/Misc/NEWS.d/next/Library/2026-10-05-18-00-00.gh-issue-156545.z1mQp7.rst b/Misc/NEWS.d/next/Library/2026-10-05-18-00-00.gh-issue-156545.z1mQp7.rst new file mode 100644 index 00000000000000..dd67abce09da80 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-05-18-00-00.gh-issue-156545.z1mQp7.rst @@ -0,0 +1,2 @@ +Fix deep flamegraph exports on platforms with small C stacks by falling back +to the Python JSON encoder when the C encoder runs out of stack space. diff --git a/Misc/NEWS.d/next/Library/2026-10-05-18-00-01.gh-issue-158539.k8Lv2a.rst b/Misc/NEWS.d/next/Library/2026-10-05-18-00-01.gh-issue-158539.k8Lv2a.rst new file mode 100644 index 00000000000000..aadac290075abf --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-05-18-00-01.gh-issue-158539.k8Lv2a.rst @@ -0,0 +1,2 @@ +Fix macOS runtime section lookup for remote debugging when virtual memory +region sizes differ from Mach-O segment file sizes. diff --git a/Python/remote_debug.h b/Python/remote_debug.h index 30cad806a6f210..ca6e90676226f8 100644 --- a/Python/remote_debug.h +++ b/Python/remote_debug.h @@ -297,7 +297,6 @@ _Py_RemoteDebug_CleanupProcHandle(proc_handle_t *handle) { static uintptr_t return_section_address64( const char* section, - mach_port_t proc_ref, uintptr_t base, void* map ) { @@ -307,11 +306,6 @@ return_section_address64( int cmd_cnt = 0; struct segment_command_64* cmd = map + sizeof(struct mach_header_64); - mach_vm_size_t size = 0; - mach_msg_type_number_t count = sizeof(vm_region_basic_info_data_64_t); - mach_vm_address_t address = (mach_vm_address_t)base; - vm_region_basic_info_data_64_t r_info; - mach_port_t object_name; uintptr_t vmaddr = 0; for (int i = 0; cmd_cnt < 2 && i < ncmds; i++) { @@ -319,25 +313,8 @@ return_section_address64( vmaddr = cmd->vmaddr; } if (cmd->cmd == LC_SEGMENT_64 && strcmp(cmd->segname, "__DATA") == 0) { - while (cmd->filesize != size) { - address += size; - kern_return_t ret = mach_vm_region( - proc_ref, - &address, - &size, - VM_REGION_BASIC_INFO_64, - (vm_region_info_t)&r_info, // cppcheck-suppress [uninitvar] - &count, - &object_name - ); - if (ret != KERN_SUCCESS) { - PyErr_Format(PyExc_RuntimeError, - "mach_vm_region failed while parsing 64-bit Mach-O binary " - "at base address 0x%lx (kern_return_t: %d)", - base, ret); - return 0; - } - } + // The section address only needs the image's ASLR slide. + // VM regions need not match the segment's on-disk size. int nsects = cmd->nsects; struct section_64* sec = (struct section_64*)( @@ -360,7 +337,6 @@ return_section_address64( static uintptr_t return_section_address32( const char* section, - mach_port_t proc_ref, uintptr_t base, void* map ) { @@ -370,11 +346,6 @@ return_section_address32( int cmd_cnt = 0; struct segment_command* cmd = map + sizeof(struct mach_header); - mach_vm_size_t size = 0; - mach_msg_type_number_t count = sizeof(vm_region_basic_info_data_t); - mach_vm_address_t address = (mach_vm_address_t)base; - vm_region_basic_info_data_t r_info; - mach_port_t object_name; uintptr_t vmaddr = 0; for (int i = 0; cmd_cnt < 2 && i < ncmds; i++) { @@ -382,25 +353,8 @@ return_section_address32( vmaddr = cmd->vmaddr; } if (cmd->cmd == LC_SEGMENT && strcmp(cmd->segname, "__DATA") == 0) { - while (cmd->filesize != size) { - address += size; - kern_return_t ret = mach_vm_region( - proc_ref, - &address, - &size, - VM_REGION_BASIC_INFO, - (vm_region_info_t)&r_info, // cppcheck-suppress [uninitvar] - &count, - &object_name - ); - if (ret != KERN_SUCCESS) { - PyErr_Format(PyExc_RuntimeError, - "mach_vm_region failed while parsing 32-bit Mach-O binary " - "at base address 0x%lx (kern_return_t: %d)", - base, ret); - return 0; - } - } + // The section address only needs the image's ASLR slide. + // VM regions need not match the segment's on-disk size. int nsects = cmd->nsects; struct section* sec = (struct section*)( @@ -423,7 +377,6 @@ return_section_address32( static uintptr_t return_section_address_fat( const char* section, - mach_port_t proc_ref, uintptr_t base, void* map ) { @@ -473,11 +426,11 @@ return_section_address_fat( switch (hdr->magic) { case MH_MAGIC: case MH_CIGAM: - return return_section_address32(section, proc_ref, base, (void*)hdr); + return return_section_address32(section, base, (void*)hdr); case MH_MAGIC_64: case MH_CIGAM_64: - return return_section_address64(section, proc_ref, base, (void*)hdr); + return return_section_address64(section, base, (void*)hdr); default: PyErr_Format(PyExc_RuntimeError, @@ -496,7 +449,7 @@ return_section_address_fat( } static uintptr_t -search_section_in_file(const char* secname, char* path, uintptr_t base, mach_vm_size_t size, mach_port_t proc_ref) +search_section_in_file(const char* secname, char* path, uintptr_t base) { int fd = open(path, O_RDONLY); if (fd == -1) { @@ -533,15 +486,15 @@ search_section_in_file(const char* secname, char* path, uintptr_t base, mach_vm_ switch (magic) { case MH_MAGIC: case MH_CIGAM: - result = return_section_address32(secname, proc_ref, base, map); + result = return_section_address32(secname, base, map); break; case MH_MAGIC_64: case MH_CIGAM_64: - result = return_section_address64(secname, proc_ref, base, map); + result = return_section_address64(secname, base, map); break; case FAT_MAGIC: case FAT_CIGAM: - result = return_section_address_fat(secname, proc_ref, base, map); + result = return_section_address_fat(secname, base, map); break; default: PyErr_Format(PyExc_RuntimeError, @@ -644,7 +597,7 @@ search_map_for_section(proc_handle_t *handle, const char* secname, const char* s if (strncmp(filename, substr, strlen(substr)) == 0) { PyErr_Clear(); uintptr_t result = search_section_in_file( - secname, map_filename, address, size, proc_ref); + secname, map_filename, address); if (result != 0) { if (validator == NULL || validator(handle, result)) { return result;