Skip to content
Open
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
3 changes: 3 additions & 0 deletions Include/internal/pycore_global_objects_fini_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Include/internal/pycore_global_strings.h
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ struct _Py_global_strings {
STRUCT_FOR_STR(native, "<native>")
STRUCT_FOR_STR(str_replace_inf, "1e309")
STRUCT_FOR_STR(type_params, ".type_params")
STRUCT_FOR_STR(unknown_file, "<unknown file>")
STRUCT_FOR_STR(unknown_function, "<unknown function>")
STRUCT_FOR_STR(unreadable_frame, "<unreadable frame>")
STRUCT_FOR_STR(utf_8, "utf-8")
} literals;

Expand Down
3 changes: 3 additions & 0 deletions Include/internal/pycore_runtime_init_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions Include/internal/pycore_unicodeobject_generated.h

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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):
Comment on lines 28 to 29

@maurycy maurycy Jul 24, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that isinstance(elem, str) is dead since #135436. Perhaps we should clean this defensive code everywhere.

if elem.location is None:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regular collectors rely on these helpers:

if location is None:
return 0

if location is None:
return DEFAULT_LOCATION

if elem.filename in ("", "~"):
return f"{elem.funcname}"
return f"{elem.funcname} {elem.filename}"
if elem.location.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]

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does not work for the <unknown function> sentinel.

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 @@ -1558,6 +1558,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
173 changes: 173 additions & 0 deletions Lib/test/test_external_inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -3854,5 +3854,178 @@ def test_get_stats_disabled_raises(self):
client_socket.sendall(b"done")


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

@contextmanager
def _running_target(self, script_body):
"""Run a target script (socket handshake prepended), yield (process, socket)."""
port = find_unused_port()
script = (
textwrap.dedent(
f"""\
import time, socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('localhost', {port}))
"""
)
+ textwrap.dedent(script_body)
)

with os_helper.temp_dir() as work_dir:
script_dir = os.path.join(work_dir, "script_pkg")
os.mkdir(script_dir)

server_socket = _create_server_socket(port)
script_name = _make_test_script(script_dir, "script", script)
client_socket = None

try:
with _managed_subprocess([sys.executable, script_name]) as p:
client_socket, _ = server_socket.accept()
server_socket.close()
server_socket = None
yield p, client_socket
finally:
_cleanup_sockets(client_socket, server_socket)

def _sample_until_frame(self, pid, predicate):
"""Sample until a frame matching predicate appears; return that frame."""
unwinder = RemoteUnwinder(pid, all_threads=True)
traces = _get_stack_trace_with_retry(
unwinder,
condition=lambda t: self._find_frame_in_trace(t, predicate)
is not None,
)
return self._find_frame_in_trace(traces, predicate)

def test_long_qualname_truncated_not_dropped(self):
"""A qualname longer than 1024 chars is truncated with a marker
instead of failing the whole sample."""
script_body = """\
src = ("def " + "f" * 1100 + "():\\n"
" sock.sendall(b'ready')\\n"
" time.sleep(10_000)\\n")
ns = {"sock": sock, "time": time}
exec(src, ns)
ns["f" * 1100]()
"""
with self._running_target(script_body) as (p, client_socket):
_wait_for_signal(client_socket, b"ready")
frame = self._sample_until_frame(
p.pid, lambda f: f.funcname.startswith("fff")
)
self.assertEqual(
frame.funcname, "f" * 1024 + "(len=1100)"
)

def test_long_filename_truncated(self):
"""A filename longer than 1024 chars is truncated with a marker
instead of failing the whole sample."""
script_body = """\
src = ("def g():\\n"
" sock.sendall(b'ready')\\n"
" time.sleep(10_000)\\n")
ns = {"sock": sock, "time": time}
exec(compile(src, "x" * 1500 + ".py", "exec"), ns)
ns["g"]()
"""
with self._running_target(script_body) as (p, client_socket):
_wait_for_signal(client_socket, b"ready")
frame = self._sample_until_frame(
p.pid, lambda f: f.funcname == "g"
)
self.assertEqual(
frame.filename, "x" * 1024 + "(len=1503)"
)

def test_long_task_name_truncated(self):
"""An asyncio task name longer than 255 chars is truncated with a
marker instead of failing the whole sample."""
script_body = """\
import asyncio

async def worker():
sock.sendall(b"ready")
await asyncio.sleep(10_000)

async def main():
await asyncio.create_task(worker(), name="T" * 300)

asyncio.run(main())
"""
with self._running_target(script_body) as (p, client_socket):
_wait_for_signal(client_socket, b"ready")
names = [
task.task_name
for awaited_info in get_all_awaited_by(p.pid)
for task in awaited_info.awaited_by
]
self.assertIn("T" * 255 + "(len=300)", names)

def test_oversized_linetable_degrades_to_no_location(self):
"""A linetable over MAX_LINETABLE_SIZE degrades to a frame without
location instead of failing the whole sample."""
script_body = """\
body = " x = 1\\n" * 16_000
src = ("def big():\\n" + body + "\\n"
" sock.sendall(b'ready')\\n"
" time.sleep(10_000)\\n")
ns = {"sock": sock, "time": time}
exec(compile(src, "big_linetable.py", "exec"), ns)
sock.sendall(b"lt:%d\\n" % len(ns["big"].__code__.co_linetable))
ns["big"]()
"""
with self._running_target(script_body) as (p, client_socket):
buffer = _wait_for_signal(client_socket, [b"lt:", b"ready"])
linetable_size = int(
buffer.partition(b"lt:")[2].partition(b"\n")[0]
)
self.assertGreater(linetable_size, 64 * 1024)

frame = self._sample_until_frame(
p.pid, lambda f: f.funcname == "big"
)
self.assertIsNone(frame.location)
self.assertEqual(frame.filename, "big_linetable.py")

@unittest.skipIf(
sys.platform == "win32",
"Process death maps to ProcessLookupError only on POSIX platforms",
)
def test_dead_process_raises_not_degrades(self):
"""Death of the target raises ProcessLookupError instead of
degrading to synthetic frames."""
script_body = """\
sock.sendall(b"ready")
time.sleep(10_000)
"""
with self._running_target(script_body) as (p, client_socket):
_wait_for_signal(client_socket, b"ready")
unwinder = RemoteUnwinder(p.pid, all_threads=True)
_get_stack_trace_with_retry(unwinder)

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"
)


if __name__ == "__main__":
unittest.main()
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,26 @@ def test_same_line_different_columns(self):
collector, count = self.roundtrip(samples)
self.assertEqual(count, 3)

def test_synthetic_frames_roundtrip(self):
"""Degraded/sentinel frames (location=None) survive the binary format."""
frames = [
FrameInfo(("~", None, name, None))
for name in (
"<GC>",
"<native>",
"<unknown function>",
"<unknown file>",
"<unreadable frame>",
Comment on lines +636 to +640

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we could add something like is_synthetic to FrameInfo?

)
]
frames.append(FrameInfo(("app.py", None, "<unknown function>", None)))
frames.append(FrameInfo(("<unknown file>", None, "real_func", None)))
samples = [[make_interpreter(0, [make_thread(1, frames)])]]

collector, count = self.roundtrip(samples)
self.assertEqual(count, 1)
self.assert_samples_equal(samples, collector)


class TestBinaryEdgeCases(BinaryFormatTestBase):
"""Tests for edge cases in binary format."""
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, filenames and
asyncio task names are truncated instead. Patch by Maurycy Pawłowski-Wieroński.
2 changes: 1 addition & 1 deletion Modules/_remote_debugging/_remote_debugging.h
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ typedef enum _WIN32_THREADSTATE {
#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
Loading
Loading