https://github.com/python/cpython/commit/4753622ec0f62c85c627c6dd23080ea95becb860
commit: 4753622ec0f62c85c627c6dd23080ea95becb860
branch: 3.14
author: Pablo Galindo Salgado <[email protected]>
committer: pablogsal <[email protected]>
date: 2026-10-05T10:04:44Z
summary:

[3.14] gh-154194: Degrade frames instead of failing the sample (GH-154195) 
(#158833)

* gh-154194: Degrade unreadable frames on 3.14

(cherry picked from commit 7d25916b41de4fc28e04b4944864ad1094a9e8e0)

Co-authored-by: Pablo Galindo Salgado <[email protected]>

* Preserve the stable ABI when creating fallback frame names

---------

Co-authored-by: Maurycy Pawłowski-Wieroński <[email protected]>

files:
A Misc/NEWS.d/next/Library/2026-07-19-22-08-05.gh-issue-154194.1bqRdx.rst
M Lib/asyncio/tools.py
M Lib/test/test_asyncio/test_tools.py
M Lib/test/test_external_inspection.py
M Modules/_remote_debugging_module.c
M Python/remote_debug.h

diff --git a/Lib/asyncio/tools.py b/Lib/asyncio/tools.py
index f39e11fdd513b4..7a0fb9ef89769f 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.lineno is None:
+            if elem.filename in ("", "~"):
+                return f"{elem.funcname}"
+            return f"{elem.funcname} {elem.filename}"
         if elem.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 34e94830204cd8..2e5aba5d5529f9 100644
--- a/Lib/test/test_asyncio/test_tools.py
+++ b/Lib/test/test_asyncio/test_tools.py
@@ -1543,6 +1543,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("<unreadable frame>", "~", None),
+                                    FrameInfo("<unknown function>", "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",
+                "        └──  <unknown function> app.py",
+                "            └──  <unreadable frame>",
+            ]],
+        )
+
+    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("<unreadable frame>", "~", None)
+                                ],
+                                task_name=1
+                            )
+                        ],
+                        awaited_by=[
+                            CoroInfo(
+                                call_stack=[
+                                    FrameInfo("<unknown function>", "app.py", 
None)
+                                ],
+                                task_name=2
+                            )
+                        ]
+                    )
+                ]
+            )
+        ]
+        self.assertEqual(
+            tools.build_task_table(input_),
+            [[
+                1,
+                "0x1",
+                "Task-A",
+                "<unreadable frame>",
+                "<unknown function>",
+                "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 cf0289f7f4de24..5f937d61af2957 100644
--- a/Lib/test/test_external_inspection.py
+++ b/Lib/test/test_external_inspection.py
@@ -1598,6 +1598,99 @@ def _get_awaited_by_relationships(self, stack_trace):
 
 
 
+@skip_if_not_supported
[email protected](
+    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 _find_frame_in_trace(self, stack_trace, predicate):
+        for thread_info in stack_trace:
+            for frame in thread_info.frame_info:
+                if predicate(frame):
+                    return frame
+        return None
+
+    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 the 4096-byte limit degrades to a frame without
+        location instead of failing the whole sample."""
+        src = (
+            "def big(sample):\n"
+            + "    x = 1\n" * 1200
+            + "    return sample()\n"
+        )
+        ns = {}
+        exec(compile(src, "big_linetable.py", "exec"), ns)
+        big = ns["big"]
+        self.assertGreater(len(big.__code__.co_linetable), 4096)
+
+        trace = big(RemoteUnwinder(os.getpid()).get_stack_trace)
+        frame = self._find_frame_in_trace(
+            trace, lambda f: f.funcname == "big"
+        )
+        self.assertIsNone(frame.lineno)
+        self.assertEqual(frame.filename, "big_linetable.py")
+
+    @unittest.skipUnless(
+        sys.platform == "linux",
+        "Process death maps to ProcessLookupError only on Linux in 3.14",
+    )
+    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()
+            unwinder.get_stack_trace()
+
+            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")
+
+
 class TestFrameChainLimits(RemoteInspectionTestBase):
     """Frame chain walks abort instead of looping/overflowing on deep 
chains."""
 
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_module.c 
b/Modules/_remote_debugging_module.c
index 3fa3232c355ae3..81851c2cf64c50 100644
--- a/Modules/_remote_debugging_module.c
+++ b/Modules/_remote_debugging_module.c
@@ -16,6 +16,7 @@
 #endif
 #include "Python.h"
 #include <internal/pycore_debug_offsets.h>  // _Py_DebugOffsets
+#include <internal/pycore_global_objects.h>
 #include <internal/pycore_frame.h>          // FRAME_SUSPENDED_YIELD_FROM
 #include <internal/pycore_interpframe.h>    // FRAME_OWNED_BY_CSTACK
 #include <internal/pycore_llist.h>          // struct llist_node
@@ -804,7 +805,7 @@ static int append_awaited_by(RemoteUnwinderObject 
*unwinder, unsigned long tid,
 #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)
@@ -2264,6 +2265,7 @@ parse_code_object(RemoteUnwinderObject *unwinder,
     PyObject *linetable = NULL;
     PyObject *lineno = NULL;
     PyObject *tuple = NULL;
+    int code_metadata_incomplete = 0;
 
 #ifdef Py_GIL_DISABLED
     // In free threading builds, code object addresses might have the low bit 
set
@@ -2282,29 +2284,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("<unreadable frame>");
+            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("<unknown function>");
+            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("<unknown file>");
+            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), 4096);
+            GET_MEMBER(uintptr_t, code_object, 
unwinder->debug_offsets.code_object.linetable),
+            4096);
         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));
@@ -2434,6 +2466,19 @@ parse_code_object(RemoteUnwinderObject *unwinder,
     *result = tuple;
     return 0;
 
+degraded: {
+    RemoteDebuggingState *state = RemoteDebugging_GetStateFromObject((PyObject 
*)unwinder);
+    PyObject *degraded_tuple = PyStructSequence_New(state->FrameInfo_Type);
+    if (!degraded_tuple) {
+        goto error;
+    }
+    PyStructSequence_SetItem(degraded_tuple, 0, file);
+    PyStructSequence_SetItem(degraded_tuple, 1, Py_NewRef(Py_None));
+    PyStructSequence_SetItem(degraded_tuple, 2, func);
+    *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 6a1020ff89a252..7f963b3243ecb6 100644
--- a/Python/remote_debug.h
+++ b/Python/remote_debug.h
@@ -90,9 +90,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__); \

_______________________________________________
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