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
226 changes: 192 additions & 34 deletions Lib/test/test_external_inspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,40 @@ def _run_script_and_get_trace(
finally:
_cleanup_sockets(client_socket, server_socket)

@contextmanager
def _target_process(self, script_body):
"""Context manager for running a target process with socket sync."""
port = find_unused_port()
script = f"""\
import 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

def make_unwinder(cache_frames=True):
return RemoteUnwinder(
p.pid, all_threads=True, cache_frames=cache_frames
)

yield p, client_socket, make_unwinder
finally:
_cleanup_sockets(client_socket, server_socket)

def _find_frame_in_trace(self, stack_trace, predicate):
"""
Find a frame matching predicate in stack trace.
Expand Down Expand Up @@ -2927,40 +2961,6 @@ class TestFrameCaching(RemoteInspectionTestBase):
All tests verify cache reuse via object identity checks (assertIs).
"""

@contextmanager
def _target_process(self, script_body):
"""Context manager for running a target process with socket sync."""
port = find_unused_port()
script = f"""\
import 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

def make_unwinder(cache_frames=True):
return RemoteUnwinder(
p.pid, all_threads=True, cache_frames=cache_frames
)

yield p, client_socket, make_unwinder
finally:
_cleanup_sockets(client_socket, server_socket)

def _get_frames_with_retry(self, unwinder, required_funcs):
"""Get frames containing required_funcs, with retry for transient errors."""
for _ in range(MAX_TRIES):
Expand Down Expand Up @@ -3804,5 +3804,163 @@ def test_get_stats_disabled_raises(self):
client_socket.sendall(b"done")


@requires_remote_subprocess_debugging()
class TestFrameChainLimits(RemoteInspectionTestBase):
"""Frame chain walks abort instead of looping/overflowing on deep chains."""

# Limits plus one, to exceed them (must match MAX_FRAME_CHAIN_DEPTH /
# MAX_TASK_WAITER_WALK_TASKS from _remote_debugging.h)
FRAME_CHAIN_DEPTH = 1024 + 512 + 1
TASK_WAITER_WALK_TASKS = 2**16 + 1

def _assert_unwinder_limit_error(self, unwind, expected_substring):
"""Call unwind() until it raises the frame chain limit error.

unwind must construct the RemoteUnwinder and call it, so that
transient RuntimeErrors from either step are retried; a successful
call means the limit never triggered and fails immediately.
"""
last_error = None
for _ in busy_retry(SHORT_TIMEOUT, error=False):
try:
unwind()
except TRANSIENT_ERRORS as e:
if expected_substring in str(e):
return
last_error = e
continue
self.fail(
"frame chain limit did not trigger; call returned a result"
)
self.fail(
f"frame chain limit never raised; last transient error: "
f"{last_error!r}"
)

@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",
)
def test_get_stack_trace_deep_frame_chain_aborts(self):
"""Test that a frame chain deeper than the limit aborts the
synchronous stack walk instead of walking it indefinitely."""
script_body = f"""\
import sys
sys.setrecursionlimit({self.FRAME_CHAIN_DEPTH * 2})

def recurse(n):
if n <= 0:
sock.sendall(b"ready")
sock.recv(16)
return
recurse(n - 1)

recurse({self.FRAME_CHAIN_DEPTH})
"""
with self._target_process(script_body) as (p, client_socket, _):
_wait_for_signal(client_socket, b"ready")
self._assert_unwinder_limit_error(
lambda: RemoteUnwinder(p.pid).get_stack_trace(),
"Too many stack frames",
)
client_socket.sendall(b"done")

@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",
)
def test_get_async_stack_trace_deep_task_waiter_chain_aborts(self):
"""Test that a task waiter chain deeper than the limit aborts
the walk instead of overflowing the C stack."""
script_body = f"""\
import asyncio

async def chain(n):
if n <= 0:
sock.sendall(b"ready")
sock.recv(16)
return

task = asyncio.create_task(chain(n - 1))
await task

asyncio.run(chain({self.TASK_WAITER_WALK_TASKS}))
"""
with self._target_process(script_body) as (p, client_socket, _):
_wait_for_signal(client_socket, b"ready")
self._assert_unwinder_limit_error(
lambda: RemoteUnwinder(p.pid).get_async_stack_trace(),
"Too many task waiters",
)
client_socket.sendall(b"done")

@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",
)
def test_get_async_stack_trace_deep_frame_chain_aborts(self):
"""Test that a frame chain deeper than the limit aborts the async
stack walk instead of walking it indefinitely."""
script_body = f"""\
import sys, asyncio
sys.setrecursionlimit({self.FRAME_CHAIN_DEPTH * 2})

def recurse(n):
if n <= 0:
sock.sendall(b"ready")
sock.recv(16)
return
recurse(n - 1)

async def deep():
recurse({self.FRAME_CHAIN_DEPTH})

asyncio.run(deep())
"""
with self._target_process(script_body) as (p, client_socket, _):
_wait_for_signal(client_socket, b"ready")
self._assert_unwinder_limit_error(
lambda: RemoteUnwinder(p.pid).get_async_stack_trace(),
"Too many async stack frames",
)
client_socket.sendall(b"done")

@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",
)
def test_get_all_awaited_by_deep_coro_chain_aborts(self):
"""Test that a coroutine await chain deeper than the limit aborts
the walk instead of overflowing the C stack."""
script_body = f"""\
import sys, asyncio
sys.setrecursionlimit({self.FRAME_CHAIN_DEPTH * 2})

async def chain(n):
if n <= 0:
await asyncio.sleep(10_000)
return
await chain(n - 1)

async def main():
task = asyncio.create_task(chain({self.FRAME_CHAIN_DEPTH}))
await asyncio.sleep(0)
sock.sendall(b"ready")
await task

asyncio.run(main())
"""
with self._target_process(script_body) as (p, client_socket, _):
_wait_for_signal(client_socket, b"ready")
self._assert_unwinder_limit_error(
lambda: RemoteUnwinder(p.pid).get_all_awaited_by(),
"Too many coroutine frames",
)


if __name__ == "__main__":
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Make frame, coroutine and task-waiter walks iterative and bounded, avoiding
potential hangs and stack overflows. Patch by Maurycy Pawłowski-Wieroński.
36 changes: 2 additions & 34 deletions Modules/_remote_debugging/_remote_debugging.h
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ typedef enum _WIN32_THREADSTATE {
#define MAX_STACK_CHUNK_SIZE (16 * 1024 * 1024) /* 16 MB max for stack chunks */
#define MAX_LONG_DIGITS 64 /* Allows values up to ~2^1920 */
#define MAX_SET_TABLE_SIZE (1 << 20) /* 1 million entries max for set iteration */
#define MAX_FRAME_CHAIN_DEPTH (1024 + 512) /* Iteration bound for frame chain walks */

@maurycy maurycy Jul 8, 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.

Not the scope but maybe MAX_THREADS, MAX_STACK_CHUNKS, MAX_LINETABLE_ENTRIES or MAX_ITERATIONS are worth keeping here too

#define MAX_TASK_WAITER_WALK_TASKS (1 << 16) /* Total-task bound for waiter walks */

#ifndef MAX
#define MAX(a, b) ((a) > (b) ? (a) : (b))
Expand Down Expand Up @@ -478,12 +480,6 @@ typedef int (*thread_processor_func)(
void *context
);

typedef int (*set_entry_processor_func)(
RemoteUnwinderObject *unwinder,
uintptr_t key_addr,
void *context
);

typedef int (*interpreter_processor_func)(
RuntimeOffsets *offsets,
uintptr_t interpreter_state_addr,
Expand Down Expand Up @@ -729,48 +725,20 @@ extern int parse_task(
PyObject *render_to
);

extern int parse_coro_chain(
RemoteUnwinderObject *unwinder,
uintptr_t coro_address,
PyObject *render_to
);

extern int parse_async_frame_chain(
RemoteUnwinderObject *unwinder,
PyObject *calls,
uintptr_t address_of_thread,
uintptr_t running_task_code_obj
);

/* Set iteration */
extern int iterate_set_entries(
RemoteUnwinderObject *unwinder,
uintptr_t set_addr,
set_entry_processor_func processor,
void *context
);

/* Task awaited_by processing */
extern int process_task_awaited_by(
RemoteUnwinderObject *unwinder,
uintptr_t task_address,
set_entry_processor_func processor,
void *context
);

extern int process_single_task_node(

@maurycy maurycy Jul 14, 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.

It's used only in the asyncio.c. Does it, and friends, need to be in the header?

RemoteUnwinderObject *unwinder,
uintptr_t task_addr,
PyObject **task_info,
PyObject *result
);

extern int process_task_and_waiters(
RemoteUnwinderObject *unwinder,
uintptr_t task_addr,
PyObject *result
);

extern int find_running_task_in_thread(
RemoteUnwinderObject *unwinder,
uintptr_t thread_state_addr,
Expand Down
Loading
Loading