Skip to content
225 changes: 191 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,162 @@ 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."""

CHAIN_DEPTH = 1024 + 512 + 1

TASK_WAITER_CHAIN_DEPTH = 256 + 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.CHAIN_DEPTH * 2})

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

recurse({self.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_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_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.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.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.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.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,3 @@
Add limits to frame, coroutine and task-waiter walks in the Tachyon sampling
profiler. This avoids potential hangs and stack overflows. Patch by Maurycy
Pawłowski-Wieroński.
17 changes: 12 additions & 5 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_CHAIN_DEPTH 256

#ifndef MAX
#define MAX(a, b) ((a) > (b) ? (a) : (b))
Expand Down Expand Up @@ -481,7 +483,8 @@ typedef int (*thread_processor_func)(
typedef int (*set_entry_processor_func)(
RemoteUnwinderObject *unwinder,
uintptr_t key_addr,
void *context
void *context,
size_t depth
);

typedef int (*interpreter_processor_func)(
Expand Down Expand Up @@ -732,7 +735,8 @@ extern int parse_task(
extern int parse_coro_chain(
RemoteUnwinderObject *unwinder,
uintptr_t coro_address,
PyObject *render_to
PyObject *render_to,
size_t depth
);

extern int parse_async_frame_chain(
Expand All @@ -747,15 +751,17 @@ extern int iterate_set_entries(
RemoteUnwinderObject *unwinder,
uintptr_t set_addr,
set_entry_processor_func processor,
void *context
void *context,
size_t depth
);

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

extern int process_single_task_node(
Expand All @@ -768,7 +774,8 @@ extern int process_single_task_node(
extern int process_task_and_waiters(
RemoteUnwinderObject *unwinder,
uintptr_t task_addr,
PyObject *result
PyObject *result,
size_t depth
);

extern int find_running_task_in_thread(
Expand Down
Loading
Loading