Skip to content

Commit 8aaf6f0

Browse files
committed
degrade gracefully
1 parent 40f7fbf commit 8aaf6f0

10 files changed

Lines changed: 295 additions & 45 deletions

File tree

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
@@ -54,6 +54,9 @@ struct _Py_global_strings {
5454
STRUCT_FOR_STR(native, "<native>")
5555
STRUCT_FOR_STR(str_replace_inf, "1e309")
5656
STRUCT_FOR_STR(type_params, ".type_params")
57+
STRUCT_FOR_STR(unknown_file, "<unknown file>")
58+
STRUCT_FOR_STR(unknown_function, "<unknown function>")
59+
STRUCT_FOR_STR(unreadable_frame, "<unreadable frame>")
5760
STRUCT_FOR_STR(utf_8, "utf-8")
5861
} literals;
5962

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/test/test_external_inspection.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3854,5 +3854,155 @@ def test_get_stats_disabled_raises(self):
38543854
client_socket.sendall(b"done")
38553855

38563856

3857+
@requires_remote_subprocess_debugging()
3858+
@skip_if_not_supported
3859+
@unittest.skipIf(
3860+
sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED,
3861+
"Test only runs on Linux with process_vm_readv support",
3862+
)
3863+
class TestMetadataDegradation(RemoteInspectionTestBase):
3864+
"""Tests for graceful degradation of oversized code-object metadata."""
3865+
3866+
@contextmanager
3867+
def _running_target(self, script_body):
3868+
"""Run a target script (socket handshake prepended), yield (process, socket)."""
3869+
port = find_unused_port()
3870+
script = (
3871+
textwrap.dedent(
3872+
f"""\
3873+
import time, socket
3874+
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
3875+
sock.connect(('localhost', {port}))
3876+
"""
3877+
)
3878+
+ textwrap.dedent(script_body)
3879+
)
3880+
3881+
with os_helper.temp_dir() as work_dir:
3882+
script_dir = os.path.join(work_dir, "script_pkg")
3883+
os.mkdir(script_dir)
3884+
3885+
server_socket = _create_server_socket(port)
3886+
script_name = _make_test_script(script_dir, "script", script)
3887+
client_socket = None
3888+
3889+
try:
3890+
with _managed_subprocess([sys.executable, script_name]) as p:
3891+
client_socket, _ = server_socket.accept()
3892+
server_socket.close()
3893+
server_socket = None
3894+
yield p, client_socket
3895+
finally:
3896+
_cleanup_sockets(client_socket, server_socket)
3897+
3898+
def _sample_until_frame(self, pid, predicate):
3899+
"""Sample until a frame matching predicate appears; return that frame."""
3900+
unwinder = RemoteUnwinder(pid, all_threads=True)
3901+
traces = _get_stack_trace_with_retry(
3902+
unwinder,
3903+
condition=lambda t: self._find_frame_in_trace(t, predicate)
3904+
is not None,
3905+
)
3906+
return self._find_frame_in_trace(traces, predicate)
3907+
3908+
def test_long_qualname_truncated_not_dropped(self):
3909+
"""A qualname longer than 1024 chars is truncated with a marker
3910+
instead of failing the whole sample."""
3911+
script_body = """\
3912+
src = ("def " + "f" * 1100 + "():\\n"
3913+
" sock.sendall(b'ready')\\n"
3914+
" time.sleep(10_000)\\n")
3915+
ns = {"sock": sock, "time": time}
3916+
exec(src, ns)
3917+
ns["f" * 1100]()
3918+
"""
3919+
with self._running_target(script_body) as (p, client_socket):
3920+
_wait_for_signal(client_socket, b"ready")
3921+
frame = self._sample_until_frame(
3922+
p.pid, lambda f: f.funcname.startswith("fff")
3923+
)
3924+
self.assertEqual(
3925+
frame.funcname, "f" * 1024 + "(len=1100)"
3926+
)
3927+
3928+
def test_long_filename_truncated(self):
3929+
"""A filename longer than 1024 chars is truncated with a marker
3930+
instead of failing the whole sample."""
3931+
script_body = """\
3932+
src = ("def g():\\n"
3933+
" sock.sendall(b'ready')\\n"
3934+
" time.sleep(10_000)\\n")
3935+
ns = {"sock": sock, "time": time}
3936+
exec(compile(src, "x" * 1500 + ".py", "exec"), ns)
3937+
ns["g"]()
3938+
"""
3939+
with self._running_target(script_body) as (p, client_socket):
3940+
_wait_for_signal(client_socket, b"ready")
3941+
frame = self._sample_until_frame(
3942+
p.pid, lambda f: f.funcname == "g"
3943+
)
3944+
self.assertEqual(
3945+
frame.filename, "x" * 1024 + "(len=1503)"
3946+
)
3947+
3948+
def test_large_linetable_keeps_line_numbers(self):
3949+
"""A linetable larger than the old 4096-byte cap keeps line numbers."""
3950+
script_body = """\
3951+
body = "\\n".join(" x%d = %d" % (i, i) for i in range(4000))
3952+
src = ("def big():\\n" + body + "\\n"
3953+
" sock.sendall(b'ready')\\n"
3954+
" time.sleep(10_000)\\n")
3955+
ns = {"sock": sock, "time": time}
3956+
exec(src, ns)
3957+
sock.sendall(b"lt:%d\\n" % len(ns["big"].__code__.co_linetable))
3958+
ns["big"]()
3959+
"""
3960+
with self._running_target(script_body) as (p, client_socket):
3961+
buffer = _wait_for_signal(client_socket, [b"lt:", b"ready"])
3962+
linetable_size = int(
3963+
buffer.partition(b"lt:")[2].partition(b"\n")[0]
3964+
)
3965+
self.assertGreater(linetable_size, 4096)
3966+
3967+
frame = self._sample_until_frame(
3968+
p.pid, lambda f: f.funcname == "big"
3969+
)
3970+
self.assertIsNotNone(
3971+
frame.location, "line info degraded for large linetable"
3972+
)
3973+
self.assertGreater(frame.location.lineno, 4000)
3974+
3975+
@unittest.skipIf(
3976+
sys.platform == "win32",
3977+
"Process death maps to ProcessLookupError only on POSIX platforms",
3978+
)
3979+
def test_dead_process_raises_not_degrades(self):
3980+
"""Death of the target raises ProcessLookupError instead of
3981+
degrading to synthetic frames."""
3982+
script_body = """\
3983+
sock.sendall(b"ready")
3984+
time.sleep(10_000)
3985+
"""
3986+
with self._running_target(script_body) as (p, client_socket):
3987+
_wait_for_signal(client_socket, b"ready")
3988+
unwinder = RemoteUnwinder(p.pid, all_threads=True)
3989+
_get_stack_trace_with_retry(unwinder)
3990+
3991+
p.kill()
3992+
p.wait()
3993+
3994+
for _ in busy_retry(SHORT_TIMEOUT, error=False):
3995+
try:
3996+
unwinder.get_stack_trace()
3997+
except ProcessLookupError:
3998+
break
3999+
except RuntimeError:
4000+
continue
4001+
else:
4002+
self.fail(
4003+
"ProcessLookupError never raised for dead process"
4004+
)
4005+
4006+
38574007
if __name__ == "__main__":
38584008
unittest.main()

Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,26 @@ def test_same_line_different_columns(self):
628628
collector, count = self.roundtrip(samples)
629629
self.assertEqual(count, 3)
630630

631+
def test_synthetic_frames_roundtrip(self):
632+
"""Degraded/sentinel frames (location=None) survive the binary format."""
633+
frames = [
634+
FrameInfo(("~", None, name, None))
635+
for name in (
636+
"<GC>",
637+
"<native>",
638+
"<unknown function>",
639+
"<unknown file>",
640+
"<unreadable frame>",
641+
)
642+
]
643+
frames.append(FrameInfo(("app.py", None, "<unknown function>", None)))
644+
frames.append(FrameInfo(("<unknown file>", None, "real_func", None)))
645+
samples = [[make_interpreter(0, [make_thread(1, frames)])]]
646+
647+
collector, count = self.roundtrip(samples)
648+
self.assertEqual(count, 1)
649+
self.assert_samples_equal(samples, collector)
650+
631651

632652
class TestBinaryEdgeCases(BinaryFormatTestBase):
633653
"""Tests for edge cases in binary format."""

Modules/_remote_debugging/_remote_debugging.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ typedef enum _WIN32_THREADSTATE {
144144
#define SIZEOF_INTERPRETER_STATE sizeof(PyInterpreterState)
145145

146146
/* Maximum sizes for validation to prevent buffer overflows from corrupted data */
147+
#define MAX_REMOTE_READ ((Py_ssize_t)(256 * 1024)) /* 256 KB max for variable-length object reads */
147148
#define MAX_STACK_CHUNK_SIZE (16 * 1024 * 1024) /* 16 MB max for stack chunks */
148149
#define MAX_LONG_DIGITS 64 /* Allows values up to ~2^1920 */
149150
#define MAX_SET_TABLE_SIZE (1 << 20) /* 1 million entries max for set iteration */
@@ -180,7 +181,7 @@ typedef enum _WIN32_THREADSTATE {
180181
#define set_exception_cause(unwinder, exc_type, message) \
181182
do { \
182183
assert(PyErr_Occurred() && "function returned -1 without setting exception"); \
183-
if (unwinder->debug && !_Py_RemoteDebug_HasPermissionError()) { \
184+
if (unwinder->debug && !_Py_RemoteDebug_IsFatalReadError()) { \
184185
_set_debug_exception_cause(exc_type, message); \
185186
} \
186187
} while (0)

Modules/_remote_debugging/code_objects.c

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -338,11 +338,16 @@ parse_code_object(RemoteUnwinderObject *unwinder,
338338
PyObject **result,
339339
const CodeObjectContext *ctx)
340340
{
341+
_Py_DECLARE_STR(unknown_function, "<unknown function>");
342+
_Py_DECLARE_STR(unknown_file, "<unknown file>");
343+
_Py_DECLARE_STR(unreadable_frame, "<unreadable frame>");
344+
341345
void *key = (void *)ctx->code_addr;
342346
CachedCodeMetadata *meta = NULL;
343347
PyObject *func = NULL;
344348
PyObject *file = NULL;
345349
PyObject *linetable = NULL;
350+
int code_metadata_incomplete = 0;
346351

347352
#ifdef Py_GIL_DISABLED
348353
// In free threading builds, code object addresses might have the low bit set
@@ -366,30 +371,59 @@ parse_code_object(RemoteUnwinderObject *unwinder,
366371
if (_Py_RemoteDebug_PagedReadRemoteMemory(
367372
&unwinder->handle, real_address, SIZEOF_CODE_OBJ, code_object) < 0)
368373
{
369-
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read code object");
370-
goto error;
374+
if (_Py_RemoteDebug_IsFatalReadError()) {
375+
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read code object");
376+
goto error;
377+
}
378+
PyErr_Clear();
379+
PyObject *tuple = make_frame_info(
380+
unwinder, _Py_LATIN1_CHR('~'), Py_None,
381+
&_Py_STR(unreadable_frame), Py_None);
382+
if (tuple == NULL) {
383+
goto error;
384+
}
385+
*result = tuple;
386+
return 0;
371387
}
372388

373389
func = read_py_str(unwinder,
374390
GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.qualname), 1024);
375391
if (!func) {
376-
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read function name from code object");
377-
goto error;
392+
if (_Py_RemoteDebug_IsFatalReadError()) {
393+
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read function name from code object");
394+
goto error;
395+
}
396+
PyErr_Clear();
397+
func = Py_NewRef(&_Py_STR(unknown_function));
398+
code_metadata_incomplete = 1;
378399
}
379400

380401
file = read_py_str(unwinder,
381402
GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.filename), 1024);
382403
if (!file) {
383-
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read filename from code object");
384-
goto error;
404+
if (_Py_RemoteDebug_IsFatalReadError()) {
405+
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read filename from code object");
406+
goto error;
407+
}
408+
PyErr_Clear();
409+
file = Py_NewRef(&_Py_STR(unknown_file));
410+
code_metadata_incomplete = 1;
411+
}
412+
413+
if (code_metadata_incomplete) {
414+
goto degraded;
385415
}
386416

387417
linetable = read_py_bytes(unwinder,
388418
GET_MEMBER(uintptr_t, code_object, unwinder->debug_offsets.code_object.linetable),
389419
MAX_LINETABLE_SIZE);
390420
if (!linetable) {
391-
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read linetable from code object");
392-
goto error;
421+
if (_Py_RemoteDebug_IsFatalReadError()) {
422+
set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read linetable from code object");
423+
goto error;
424+
}
425+
PyErr_Clear();
426+
goto degraded;
393427
}
394428

395429
meta = PyMem_RawMalloc(sizeof(CachedCodeMetadata));
@@ -542,6 +576,18 @@ parse_code_object(RemoteUnwinderObject *unwinder,
542576
*result = tuple;
543577
return 0;
544578

579+
degraded: {
580+
PyObject *degraded_tuple = make_frame_info(unwinder, file, Py_None,
581+
func, Py_None);
582+
Py_CLEAR(func);
583+
Py_CLEAR(file);
584+
if (!degraded_tuple) {
585+
return -1;
586+
}
587+
*result = degraded_tuple;
588+
return 0;
589+
}
590+
545591
error:
546592
Py_XDECREF(func);
547593
Py_XDECREF(file);

0 commit comments

Comments
 (0)