Skip to content

Commit b9bc2e2

Browse files
maurycypablogsal
andcommitted
gh-154194: Degrade unreadable frames on 3.14
(cherry picked from commit 7d25916) Co-authored-by: Pablo Galindo Salgado <pablogsal@gmail.com>
1 parent 5c33a3b commit b9bc2e2

10 files changed

Lines changed: 259 additions & 15 deletions

‎Include/internal/pycore_global_objects_fini_generated.h‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Include/internal/pycore_global_strings.h‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ struct _Py_global_strings {
5252
STRUCT_FOR_STR(list_err, "list index out of range")
5353
STRUCT_FOR_STR(str_replace_inf, "1e309")
5454
STRUCT_FOR_STR(type_params, ".type_params")
55+
STRUCT_FOR_STR(unknown_file, "<unknown file>")
56+
STRUCT_FOR_STR(unknown_function, "<unknown function>")
57+
STRUCT_FOR_STR(unreadable_frame, "<unreadable frame>")
5558
STRUCT_FOR_STR(utf_8, "utf-8")
5659
} literals;
5760

‎Include/internal/pycore_runtime_init_generated.h‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Include/internal/pycore_unicodeobject_generated.h‎

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎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: 50 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)
@@ -2257,13 +2258,18 @@ parse_code_object(RemoteUnwinderObject *unwinder,
22572258
uintptr_t *previous_frame,
22582259
int32_t tlbc_index)
22592260
{
2261+
_Py_DECLARE_STR(unknown_function, "<unknown function>");
2262+
_Py_DECLARE_STR(unknown_file, "<unknown file>");
2263+
_Py_DECLARE_STR(unreadable_frame, "<unreadable frame>");
2264+
22602265
void *key = (void *)address;
22612266
CachedCodeMetadata *meta = NULL;
22622267
PyObject *func = NULL;
22632268
PyObject *file = NULL;
22642269
PyObject *linetable = NULL;
22652270
PyObject *lineno = NULL;
22662271
PyObject *tuple = NULL;
2272+
int code_metadata_incomplete = 0;
22672273

22682274
#ifdef Py_GIL_DISABLED
22692275
// In free threading builds, code object addresses might have the low bit set
@@ -2282,29 +2288,50 @@ parse_code_object(RemoteUnwinderObject *unwinder,
22822288
if (_Py_RemoteDebug_PagedReadRemoteMemory(
22832289
&unwinder->handle, real_address, SIZEOF_CODE_OBJ, code_object) < 0)
22842290
{
2285-
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read code object");
2286-
goto error;
2291+
if (_Py_RemoteDebug_IsFatalReadError()) {
2292+
goto error;
2293+
}
2294+
PyErr_Clear();
2295+
func = Py_NewRef(&_Py_STR(unreadable_frame));
2296+
file = Py_NewRef(_Py_LATIN1_CHR('~'));
2297+
goto degraded;
22872298
}
22882299

22892300
func = read_py_str(unwinder,
22902301
GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.qualname), 1024);
22912302
if (!func) {
2292-
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read function name from code object");
2293-
goto error;
2303+
if (_Py_RemoteDebug_IsFatalReadError()) {
2304+
goto error;
2305+
}
2306+
PyErr_Clear();
2307+
func = Py_NewRef(&_Py_STR(unknown_function));
2308+
code_metadata_incomplete = 1;
22942309
}
22952310

22962311
file = read_py_str(unwinder,
22972312
GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.filename), 1024);
22982313
if (!file) {
2299-
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read filename from code object");
2300-
goto error;
2314+
if (_Py_RemoteDebug_IsFatalReadError()) {
2315+
goto error;
2316+
}
2317+
PyErr_Clear();
2318+
file = Py_NewRef(&_Py_STR(unknown_file));
2319+
code_metadata_incomplete = 1;
2320+
}
2321+
2322+
if (code_metadata_incomplete) {
2323+
goto degraded;
23012324
}
23022325

23032326
linetable = read_py_bytes(unwinder,
2304-
GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.linetable), 4096);
2327+
GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.linetable),
2328+
4096);
23052329
if (!linetable) {
2306-
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read linetable from code object");
2307-
goto error;
2330+
if (_Py_RemoteDebug_IsFatalReadError()) {
2331+
goto error;
2332+
}
2333+
PyErr_Clear();
2334+
goto degraded;
23082335
}
23092336

23102337
meta = PyMem_RawMalloc(sizeof(CachedCodeMetadata));
@@ -2434,6 +2461,19 @@ parse_code_object(RemoteUnwinderObject *unwinder,
24342461
*result = tuple;
24352462
return 0;
24362463

2464+
degraded: {
2465+
RemoteDebuggingState *state = RemoteDebugging_GetStateFromObject((PyObject *)unwinder);
2466+
PyObject *degraded_tuple = PyStructSequence_New(state->FrameInfo_Type);
2467+
if (!degraded_tuple) {
2468+
goto error;
2469+
}
2470+
PyStructSequence_SetItem(degraded_tuple, 0, file);
2471+
PyStructSequence_SetItem(degraded_tuple, 1, Py_NewRef(Py_None));
2472+
PyStructSequence_SetItem(degraded_tuple, 2, func);
2473+
*result = degraded_tuple;
2474+
return 0;
2475+
}
2476+
24372477
error:
24382478
Py_XDECREF(func);
24392479
Py_XDECREF(file);

0 commit comments

Comments
 (0)