Skip to content

Commit 4753622

Browse files
pablogsalmaurycy
andauthored
[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 7d25916) Co-authored-by: Pablo Galindo Salgado <pablogsal@gmail.com> * Preserve the stable ABI when creating fallback frame names --------- Co-authored-by: Maurycy Pawłowski-Wieroński <maurycy@maurycy.com>
1 parent 5c33a3b commit 4753622

6 files changed

Lines changed: 243 additions & 15 deletions

File tree

‎Lib/asyncio/tools.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ def __init__(
2727
# ─── indexing helpers ───────────────────────────────────────────
2828
def _format_stack_entry(elem: str|FrameInfo) -> str:
2929
if not isinstance(elem, str):
30+
if elem.lineno is None:
31+
if elem.filename in ("", "~"):
32+
return f"{elem.funcname}"
33+
return f"{elem.funcname} {elem.filename}"
3034
if elem.lineno == 0 and elem.filename == "":
3135
return f"{elem.funcname}"
3236
else:
@@ -190,8 +194,7 @@ def build_task_table(result):
190194
# Build coroutine stack string
191195
frames = [frame for coro in task_info.coroutine_stack
192196
for frame in coro.call_stack]
193-
coro_stack = " -> ".join(_format_stack_entry(x).split(" ")[0]
194-
for x in frames)
197+
coro_stack = " -> ".join(x.funcname for x in frames)
195198

196199
# Handle tasks with no awaiters
197200
if not task_info.awaited_by:
@@ -202,8 +205,7 @@ def build_task_table(result):
202205
# Handle tasks with awaiters
203206
for coro_info in task_info.awaited_by:
204207
parent_id = coro_info.task_name
205-
awaiter_frames = [_format_stack_entry(x).split(" ")[0]
206-
for x in coro_info.call_stack]
208+
awaiter_frames = [x.funcname for x in coro_info.call_stack]
207209
awaiter_chain = " -> ".join(awaiter_frames)
208210
awaiter_name = id2name.get(parent_id, "Unknown")
209211
parent_id_str = (hex(parent_id) if isinstance(parent_id, int)

‎Lib/test/test_asyncio/test_tools.py‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1543,6 +1543,82 @@ def test_table_output_format(self):
15431543

15441544
class TestAsyncioToolsEdgeCases(unittest.TestCase):
15451545

1546+
def test_frames_without_location_tree(self):
1547+
"""Frames the unwinder could not fully read - should not crash."""
1548+
input_ = [
1549+
AwaitedInfo(
1550+
thread_id=1,
1551+
awaited_by=[
1552+
TaskInfo(
1553+
task_id=1,
1554+
task_name="Task-A",
1555+
coroutine_stack=[
1556+
CoroInfo(
1557+
call_stack=[
1558+
FrameInfo("<unreadable frame>", "~", None),
1559+
FrameInfo("<unknown function>", "app.py", None),
1560+
FrameInfo("big", "big.py", None),
1561+
],
1562+
task_name=1
1563+
)
1564+
],
1565+
awaited_by=[]
1566+
)
1567+
]
1568+
)
1569+
]
1570+
self.assertEqual(
1571+
tools.build_async_tree(input_),
1572+
[[
1573+
"└── (T) Task-A",
1574+
" └── big big.py",
1575+
" └── <unknown function> app.py",
1576+
" └── <unreadable frame>",
1577+
]],
1578+
)
1579+
1580+
def test_frames_without_location_table(self):
1581+
"""Frame names are not truncated at the first space."""
1582+
input_ = [
1583+
AwaitedInfo(
1584+
thread_id=1,
1585+
awaited_by=[
1586+
TaskInfo(
1587+
task_id=1,
1588+
task_name="Task-A",
1589+
coroutine_stack=[
1590+
CoroInfo(
1591+
call_stack=[
1592+
FrameInfo("<unreadable frame>", "~", None)
1593+
],
1594+
task_name=1
1595+
)
1596+
],
1597+
awaited_by=[
1598+
CoroInfo(
1599+
call_stack=[
1600+
FrameInfo("<unknown function>", "app.py", None)
1601+
],
1602+
task_name=2
1603+
)
1604+
]
1605+
)
1606+
]
1607+
)
1608+
]
1609+
self.assertEqual(
1610+
tools.build_task_table(input_),
1611+
[[
1612+
1,
1613+
"0x1",
1614+
"Task-A",
1615+
"<unreadable frame>",
1616+
"<unknown function>",
1617+
"Unknown",
1618+
"0x2",
1619+
]],
1620+
)
1621+
15461622
def test_task_awaits_self(self):
15471623
"""A task directly awaits itself - should raise a cycle."""
15481624
input_ = [

‎Lib/test/test_external_inspection.py‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1598,6 +1598,99 @@ def _get_awaited_by_relationships(self, stack_trace):
15981598

15991599

16001600

1601+
@skip_if_not_supported
1602+
@unittest.skipIf(
1603+
sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
1604+
"Test only runs on Linux with process_vm_readv support",
1605+
)
1606+
class TestMetadataDegradation(RemoteInspectionTestBase):
1607+
"""Tests for graceful degradation of oversized code-object metadata."""
1608+
1609+
def _find_frame_in_trace(self, stack_trace, predicate):
1610+
for thread_info in stack_trace:
1611+
for frame in thread_info.frame_info:
1612+
if predicate(frame):
1613+
return frame
1614+
return None
1615+
1616+
def test_long_qualname_truncated_not_dropped(self):
1617+
"""A qualname longer than 1024 chars is truncated instead of
1618+
failing the whole sample."""
1619+
name = "f" * 1100
1620+
src = f"def {name}(sample):\n return sample()\n"
1621+
ns = {}
1622+
exec(src, ns)
1623+
1624+
trace = ns[name](RemoteUnwinder(os.getpid()).get_stack_trace)
1625+
frame = self._find_frame_in_trace(
1626+
trace, lambda f: f.funcname.startswith("fff")
1627+
)
1628+
self.assertIsNotNone(frame)
1629+
self.assertEqual(frame.funcname, "f" * 1024)
1630+
1631+
def test_long_filename_truncated(self):
1632+
"""A filename longer than 1024 chars is truncated instead of
1633+
failing the whole sample."""
1634+
src = "def g(sample):\n return sample()\n"
1635+
ns = {}
1636+
exec(compile(src, "x" * 1500 + ".py", "exec"), ns)
1637+
1638+
trace = ns["g"](RemoteUnwinder(os.getpid()).get_stack_trace)
1639+
frame = self._find_frame_in_trace(trace, lambda f: f.funcname == "g")
1640+
self.assertIsNotNone(frame)
1641+
self.assertEqual(frame.filename, "x" * 1024)
1642+
1643+
def test_oversized_linetable_degrades_to_no_location(self):
1644+
"""A linetable over the 4096-byte limit degrades to a frame without
1645+
location instead of failing the whole sample."""
1646+
src = (
1647+
"def big(sample):\n"
1648+
+ " x = 1\n" * 1200
1649+
+ " return sample()\n"
1650+
)
1651+
ns = {}
1652+
exec(compile(src, "big_linetable.py", "exec"), ns)
1653+
big = ns["big"]
1654+
self.assertGreater(len(big.__code__.co_linetable), 4096)
1655+
1656+
trace = big(RemoteUnwinder(os.getpid()).get_stack_trace)
1657+
frame = self._find_frame_in_trace(
1658+
trace, lambda f: f.funcname == "big"
1659+
)
1660+
self.assertIsNone(frame.lineno)
1661+
self.assertEqual(frame.filename, "big_linetable.py")
1662+
1663+
@unittest.skipUnless(
1664+
sys.platform == "linux",
1665+
"Process death maps to ProcessLookupError only on Linux in 3.14",
1666+
)
1667+
def test_dead_process_raises_not_degrades(self):
1668+
"""Death of the target raises ProcessLookupError instead of
1669+
degrading to synthetic frames."""
1670+
script_body = """\
1671+
import time
1672+
sock.sendall(b"ready")
1673+
time.sleep(10_000)
1674+
"""
1675+
with self._target_process(script_body) as (p, client_socket, make_unwinder):
1676+
_wait_for_signal(client_socket, b"ready")
1677+
unwinder = make_unwinder()
1678+
unwinder.get_stack_trace()
1679+
1680+
p.kill()
1681+
p.wait()
1682+
1683+
for _ in busy_retry(SHORT_TIMEOUT, error=False):
1684+
try:
1685+
unwinder.get_stack_trace()
1686+
except ProcessLookupError:
1687+
break
1688+
except RuntimeError:
1689+
continue
1690+
else:
1691+
self.fail("ProcessLookupError never raised for dead process")
1692+
1693+
16011694
class TestFrameChainLimits(RemoteInspectionTestBase):
16021695
"""Frame chain walks abort instead of looping/overflowing on deep chains."""
16031696

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix the sampling profiler dropping entire samples when a non-fatal read fails;
2+
frames now keep any readable metadata, and long funcnames and filenames are
3+
truncated instead. Patch by Maurycy Pawłowski-Wieroński.

‎Modules/_remote_debugging_module.c‎

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
#endif
1717
#include "Python.h"
1818
#include <internal/pycore_debug_offsets.h> // _Py_DebugOffsets
19+
#include <internal/pycore_global_objects.h>
1920
#include <internal/pycore_frame.h> // FRAME_SUSPENDED_YIELD_FROM
2021
#include <internal/pycore_interpframe.h> // FRAME_OWNED_BY_CSTACK
2122
#include <internal/pycore_llist.h> // struct llist_node
@@ -804,7 +805,7 @@ static int append_awaited_by(RemoteUnwinderObject *unwinder, unsigned long tid,
804805
#define set_exception_cause(unwinder, exc_type, message) \
805806
do { \
806807
assert(PyErr_Occurred() && "function returned -1 without setting exception"); \
807-
if (unwinder->debug && !_Py_RemoteDebug_HasPermissionError()) { \
808+
if (unwinder->debug && !_Py_RemoteDebug_IsFatalReadError()) { \
808809
_set_debug_exception_cause(exc_type, message); \
809810
} \
810811
} while (0)
@@ -2264,6 +2265,7 @@ parse_code_object(RemoteUnwinderObject *unwinder,
22642265
PyObject *linetable = NULL;
22652266
PyObject *lineno = NULL;
22662267
PyObject *tuple = NULL;
2268+
int code_metadata_incomplete = 0;
22672269

22682270
#ifdef Py_GIL_DISABLED
22692271
// In free threading builds, code object addresses might have the low bit set
@@ -2282,29 +2284,59 @@ parse_code_object(RemoteUnwinderObject *unwinder,
22822284
if (_Py_RemoteDebug_PagedReadRemoteMemory(
22832285
&unwinder->handle, real_address, SIZEOF_CODE_OBJ, code_object) < 0)
22842286
{
2285-
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read code object");
2286-
goto error;
2287+
if (_Py_RemoteDebug_IsFatalReadError()) {
2288+
goto error;
2289+
}
2290+
PyErr_Clear();
2291+
func = PyUnicode_FromString("<unreadable frame>");
2292+
if (!func) {
2293+
goto error;
2294+
}
2295+
file = Py_NewRef(_Py_LATIN1_CHR('~'));
2296+
goto degraded;
22872297
}
22882298

22892299
func = read_py_str(unwinder,
22902300
GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.qualname), 1024);
22912301
if (!func) {
2292-
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read function name from code object");
2293-
goto error;
2302+
if (_Py_RemoteDebug_IsFatalReadError()) {
2303+
goto error;
2304+
}
2305+
PyErr_Clear();
2306+
func = PyUnicode_FromString("<unknown function>");
2307+
if (!func) {
2308+
goto error;
2309+
}
2310+
code_metadata_incomplete = 1;
22942311
}
22952312

22962313
file = read_py_str(unwinder,
22972314
GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.filename), 1024);
22982315
if (!file) {
2299-
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read filename from code object");
2300-
goto error;
2316+
if (_Py_RemoteDebug_IsFatalReadError()) {
2317+
goto error;
2318+
}
2319+
PyErr_Clear();
2320+
file = PyUnicode_FromString("<unknown file>");
2321+
if (!file) {
2322+
goto error;
2323+
}
2324+
code_metadata_incomplete = 1;
2325+
}
2326+
2327+
if (code_metadata_incomplete) {
2328+
goto degraded;
23012329
}
23022330

23032331
linetable = read_py_bytes(unwinder,
2304-
GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.linetable), 4096);
2332+
GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.linetable),
2333+
4096);
23052334
if (!linetable) {
2306-
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read linetable from code object");
2307-
goto error;
2335+
if (_Py_RemoteDebug_IsFatalReadError()) {
2336+
goto error;
2337+
}
2338+
PyErr_Clear();
2339+
goto degraded;
23082340
}
23092341

23102342
meta = PyMem_RawMalloc(sizeof(CachedCodeMetadata));
@@ -2434,6 +2466,19 @@ parse_code_object(RemoteUnwinderObject *unwinder,
24342466
*result = tuple;
24352467
return 0;
24362468

2469+
degraded: {
2470+
RemoteDebuggingState *state = RemoteDebugging_GetStateFromObject((PyObject *)unwinder);
2471+
PyObject *degraded_tuple = PyStructSequence_New(state->FrameInfo_Type);
2472+
if (!degraded_tuple) {
2473+
goto error;
2474+
}
2475+
PyStructSequence_SetItem(degraded_tuple, 0, file);
2476+
PyStructSequence_SetItem(degraded_tuple, 1, Py_NewRef(Py_None));
2477+
PyStructSequence_SetItem(degraded_tuple, 2, func);
2478+
*result = degraded_tuple;
2479+
return 0;
2480+
}
2481+
24372482
error:
24382483
Py_XDECREF(func);
24392484
Py_XDECREF(file);

‎Python/remote_debug.h‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,18 @@ _Py_RemoteDebug_HasPermissionError(void)
9090
&& PyErr_ExceptionMatches(PyExc_PermissionError);
9191
}
9292

93+
static inline int
94+
_Py_RemoteDebug_IsFatalReadError(void)
95+
{
96+
return _Py_RemoteDebug_HasPermissionError()
97+
|| PyErr_ExceptionMatches(PyExc_MemoryError)
98+
|| PyErr_ExceptionMatches(PyExc_ProcessLookupError)
99+
|| (PyErr_Occurred() && !PyErr_ExceptionMatches(PyExc_Exception));
100+
}
101+
93102
#define _set_debug_exception_cause(exception, format, ...) \
94103
do { \
95-
if (!_Py_RemoteDebug_HasPermissionError()) { \
104+
if (!_Py_RemoteDebug_IsFatalReadError()) { \
96105
PyThreadState *tstate = _PyThreadState_GET(); \
97106
if (!_PyErr_Occurred(tstate)) { \
98107
_PyErr_Format(tstate, exception, format, ##__VA_ARGS__); \

0 commit comments

Comments
 (0)