https://github.com/python/cpython/commit/80f9aa0b18d96509eec3950a8db4324d5f6dc3a5
commit: 80f9aa0b18d96509eec3950a8db4324d5f6dc3a5
branch: main
author: Pablo Galindo Salgado <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-09-25T01:04:31+01:00
summary:

gh-157605: Accept empty native-thread stacks when sampling

Accept empty native-thread stacks when sampling instead of raising an error. 
Distinguish interpreter-owned C frames from cleared Python frames so frame 
cleanup cannot produce a false <native> marker.

files:
A Misc/NEWS.d/next/Library/2026-09-19-12-00-00.gh-issue-157605.Qh92xZ.rst
M Lib/test/test_external_inspection.py
M Modules/_remote_debugging/frames.c

diff --git a/Lib/test/test_external_inspection.py 
b/Lib/test/test_external_inspection.py
index fd647153f92be6..b84ef404c54cd0 100644
--- a/Lib/test/test_external_inspection.py
+++ b/Lib/test/test_external_inspection.py
@@ -1684,6 +1684,80 @@ 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,
+        "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", "<native>"), 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/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 00000000000000..6f680eda27268e
--- /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 46968acc6ff1fe..9febe6a472071b 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,
@@ -358,19 +362,19 @@ 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, "<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 &&
+                 parse_result == FRAME_PARSE_INTERPRETER &&
                  next_frame_addr &&
+                 PyList_GET_SIZE(ctx->frame_info) > 0 &&
                  !(unwinder->gc && next_frame_addr == ctx->gc_frame))
         {
             _Py_DECLARE_STR(native, "<native>");

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to