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]
