Skip to content
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 +673 to +677

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