Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions Lib/asyncio/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
76 changes: 76 additions & 0 deletions Lib/test/test_asyncio/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_ = [
Expand Down
93 changes: 93 additions & 0 deletions Lib/test/test_external_inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -1598,6 +1598,99 @@ def _get_awaited_by_relationships(self, stack_trace):



@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",
)
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."""

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 55 additions & 10 deletions Modules/_remote_debugging_module.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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));
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 10 additions & 1 deletion Python/remote_debug.h
Original file line number Diff line number Diff line change
Expand Up @@ -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__); \
Expand Down
Loading