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]