From 8aec82f55795125937b9c1fa24fb343a2c379ec5 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Sat, 19 Sep 2026 18:57:39 +0100 Subject: [PATCH 1/2] gh-157605: Accept empty native-thread stacks when sampling --- Lib/test/test_external_inspection.py | 35 +++++++++++++++++++ ...-09-19-12-00-00.gh-issue-157605.Qh92xZ.rst | 2 ++ Modules/_remote_debugging/frames.c | 9 +++-- 3 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-19-12-00-00.gh-issue-157605.Qh92xZ.rst diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index fd647153f92be6b..b334b9c7dd756b5 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -1684,6 +1684,41 @@ def test_self_trace(self): self.assertEqual(this_thread_stack[1].funcname, "TestGetStackTrace.test_self_trace") self.assertTrue(this_thread_stack[1].filename.endswith("test_external_inspection.py")) + @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_empty_native_thread_stack(self): + _testcapi = import_module("_testcapi") + lock = threading.Lock() + lock.acquire() + # A built-in callback leaves the C thread's Python stack empty. + _testcapi.call_in_temporary_c_thread(lock.acquire, False) + try: + for cache_frames, native in ((False, False), (False, True), + (True, False), (True, True)): + with self.subTest(cache_frames=cache_frames, native=native): + unwinder = RemoteUnwinder( + os.getpid(), all_threads=True, cache_frames=cache_frames, + native=native, + ) + _get_stack_trace_with_retry( + unwinder, condition=lambda trace: len(trace[0].threads) == 2, + ) + threads = unwinder.get_stack_trace()[0].threads + native_stack, python_stack = sorted( + (thread.frame_info for thread in threads), key=len, + ) + self.assertEqual(native_stack, []) + self.assertEqual( + python_stack[0].funcname, + "TestGetStackTrace.test_empty_native_thread_stack", + ) + finally: + lock.release() + _testcapi.join_temporary_c_thread() + @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-19-12-00-00.gh-issue-157605.Qh92xZ.rst b/Misc/NEWS.d/next/Library/2026-09-19-12-00-00.gh-issue-157605.Qh92xZ.rst new file mode 100644 index 000000000000000..6f680eda27268e5 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-19-12-00-00.gh-issue-157605.Qh92xZ.rst @@ -0,0 +1,2 @@ +Fix :mod:`profiling.sampling` failing when a native thread has an empty Python +stack. diff --git a/Modules/_remote_debugging/frames.c b/Modules/_remote_debugging/frames.c index 46968acc6ff1feb..55ffbf179e3f9da 100644 --- a/Modules/_remote_debugging/frames.c +++ b/Modules/_remote_debugging/frames.c @@ -358,19 +358,18 @@ process_frame_chain( continue; } - if (frame == NULL && PyList_GET_SIZE(ctx->frame_info) == 0) { - const char *e = "Failed to parse initial frame in chain"; - PyErr_SetString(PyExc_RuntimeError, e); - return -1; - } PyObject *extra_frame = NULL; if (unwinder->gc && frame_addr == ctx->gc_frame) { _Py_DECLARE_STR(gc, ""); extra_frame = &_Py_STR(gc); } + // A leading frame without Python code marks no transition between + // Python frames: it is a frame being popped or C code the thread is + // returning into. else if (unwinder->native && frame == NULL && next_frame_addr && + PyList_GET_SIZE(ctx->frame_info) > 0 && !(unwinder->gc && next_frame_addr == ctx->gc_frame)) { _Py_DECLARE_STR(native, ""); From e37e929b8347bd0bd5fc7f83f9077d6eb68fd0bc Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Thu, 24 Sep 2026 20:43:53 +0100 Subject: [PATCH 2/2] gh-157605: Distinguish C frames from cleared Python frames --- Lib/test/test_external_inspection.py | 39 ++++++++++++++++++++++++++++ Modules/_remote_debugging/frames.c | 13 +++++++--- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index b334b9c7dd756b5..b84ef404c54cd02 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -1719,6 +1719,45 @@ def test_empty_native_thread_stack(self): lock.release() _testcapi.join_temporary_c_thread() + @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_popping_python_frame_is_not_native(self): + script = """\ +def leaf(depth): + if depth: + leaf(depth - 1) + +while True: + leaf(300) +""" + with _managed_subprocess([sys.executable, "-c", script]) as process: + for _ in busy_retry(SHORT_TIMEOUT): + try: + unwinder = RemoteUnwinder( + process.pid, native=True, gc=False, cache_frames=False, + ) + except RuntimeError: + continue + break + samples = 0 + for _ in range(10_000): + try: + threads = unwinder.get_stack_trace()[0].threads + except TRANSIENT_ERRORS: + continue + if not threads: + continue + frames = threads[0].frame_info + names = [frame.funcname for frame in frames] + if "leaf" not in names: + continue + samples += 1 + self.assertNotIn(("leaf", ""), zip(names, names[1:])) + self.assertGreater(samples, 1000) + @skip_if_not_supported @unittest.skipIf( sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, diff --git a/Modules/_remote_debugging/frames.c b/Modules/_remote_debugging/frames.c index 55ffbf179e3f9da..9febe6a472071b5 100644 --- a/Modules/_remote_debugging/frames.c +++ b/Modules/_remote_debugging/frames.c @@ -163,6 +163,8 @@ find_frame_in_chunks(StackChunkList *chunks, uintptr_t remote_ptr) * FRAME PARSING FUNCTIONS * ============================================================================ */ +enum { FRAME_PARSE_INTERPRETER = 2 }; + int is_frame_valid( RemoteUnwinderObject *unwinder, @@ -170,14 +172,14 @@ is_frame_valid( uintptr_t code_object_addr ) { if ((void*)code_object_addr == NULL) { - return 0; + return 0; // Frame being cleared } void* frame = (void*)frame_addr; char owner = GET_MEMBER(char, frame, unwinder->debug_offsets.interpreter_frame.owner); if (owner == FRAME_OWNED_BY_INTERPRETER) { - return 0; // C frame or sentinel base frame + return FRAME_PARSE_INTERPRETER; // C frame or sentinel base frame } if (owner != FRAME_OWNED_BY_GENERATOR && owner != FRAME_OWNED_BY_THREAD) { @@ -313,6 +315,7 @@ process_frame_chain( ctx->last_frame_visited = 0; while ((void*)frame_addr != NULL) { + int parse_result = 0; PyObject *frame = NULL; uintptr_t next_frame_addr = 0; uintptr_t stackpointer = 0; @@ -326,14 +329,15 @@ process_frame_chain( assert(frame_count <= MAX_FRAMES); if (ctx->chunks && ctx->chunks->count > 0) { - if (parse_frame_from_chunks(unwinder, &frame, frame_addr, &next_frame_addr, &stackpointer, ctx->chunks) == 0) { + parse_result = parse_frame_from_chunks( + unwinder, &frame, frame_addr, &next_frame_addr, &stackpointer, ctx->chunks); + if (parse_result == 0) { goto parsed_frame; } PyErr_Clear(); } { uintptr_t address_of_code_object = 0; - int parse_result; if (ctx->prefetch.frame && ctx->prefetch.frame_addr == frame_addr) { parse_result = parse_frame_buffer( unwinder, &frame, ctx->prefetch.frame, @@ -368,6 +372,7 @@ process_frame_chain( // returning into. else if (unwinder->native && frame == NULL && + parse_result == FRAME_PARSE_INTERPRETER && next_frame_addr && PyList_GET_SIZE(ctx->frame_info) > 0 && !(unwinder->gc && next_frame_addr == ctx->gc_frame))