Skip to content

GH-91079: Raise recursion error on C stack overflow, instead of crashing. #96466

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 15 commits into from
Closed
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
15 changes: 13 additions & 2 deletions Include/cpython/pystate.h
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,17 @@ struct _ts {
/* Was this thread state statically allocated? */
int _static;

int recursion_remaining;
/* Python recursion limit handling */
int py_recursion_remaining;
int recursion_limit;
int recursion_headroom; /* Allow 50 more calls to handle any errors. */
int py_recursion_headroom; /* Allow 50 more calls to handle any errors. */

/* C stack overflow handling */
intptr_t stack_base;
intptr_t stack_limit;
intptr_t stack_top;
int stack_in_yellow;
int stack_grows;

/* 'tracing' keeps track of the execution depth when tracing/profiling.
This is to prevent the actual trace/profile code from being recorded in
Expand Down Expand Up @@ -367,3 +375,6 @@ typedef int (*crossinterpdatafunc)(PyObject *, _PyCrossInterpreterData *);

PyAPI_FUNC(int) _PyCrossInterpreterData_RegisterClass(PyTypeObject *, crossinterpdatafunc);
PyAPI_FUNC(crossinterpdatafunc) _PyCrossInterpreterData_Lookup(PyObject *);


PyAPI_FUNC(int) _Py_OS_GetStackLimits(void** low, void** high);
2 changes: 0 additions & 2 deletions Include/internal/pycore_ast_state.h

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

44 changes: 24 additions & 20 deletions Include/internal/pycore_ceval.h
Original file line number Diff line number Diff line change
Expand Up @@ -114,40 +114,44 @@ extern void _PyEval_DeactivateOpCache(void);

/* --- _Py_EnterRecursiveCall() ----------------------------------------- */

#ifdef USE_STACKCHECK
/* With USE_STACKCHECK macro defined, trigger stack checks in
_Py_CheckRecursiveCall() on every 64th call to _Py_EnterRecursiveCall. */
static inline int _Py_MakeRecCheck(PyThreadState *tstate) {
return (tstate->recursion_remaining-- <= 0
|| (tstate->recursion_remaining & 63) == 0);
}
#else
static inline int _Py_MakeRecCheck(PyThreadState *tstate) {
return tstate->recursion_remaining-- <= 0;
}
#endif

PyAPI_FUNC(int) _Py_CheckRecursiveCall(
PyThreadState *tstate,
const char *where);

static inline int _Py_EnterRecursiveCallTstate(PyThreadState *tstate,
const char *where) {
return (_Py_MakeRecCheck(tstate) && _Py_CheckRecursiveCall(tstate, where));

PyAPI_FUNC(int) _Py_StackOverflowCheckCall(
PyThreadState *tstate,
const char *where,
intptr_t scaled_location);

static inline int
_Py_StackOverflowCheck(PyThreadState *tstate, const char *where)
{
char addr;
intptr_t here = ((uintptr_t)&addr)/SIZEOF_VOID_P;
intptr_t here_upward = here*tstate->stack_grows;
if (here_upward < tstate->stack_limit) {
return 0;
}
return _Py_StackOverflowCheckCall(tstate, where, here_upward);
}

#define _Py_EnterRecursiveCallTstate _Py_StackOverflowCheck

static inline int _Py_EnterRecursiveCall(const char *where) {
PyThreadState *tstate = _PyThreadState_GET();
return _Py_EnterRecursiveCallTstate(tstate, where);
return _Py_StackOverflowCheck(tstate, where);
}

static inline int Py_StackOverflowCheck(const char *where) {
PyThreadState *tstate = _PyThreadState_GET();
return _Py_StackOverflowCheck(tstate, where);
}

static inline void _Py_LeaveRecursiveCallTstate(PyThreadState *tstate) {
tstate->recursion_remaining++;
}

static inline void _Py_LeaveRecursiveCall(void) {
PyThreadState *tstate = _PyThreadState_GET();
_Py_LeaveRecursiveCallTstate(tstate);
}

extern struct _PyInterpreterFrame* _PyEval_GetFrame(void);
Expand Down
2 changes: 0 additions & 2 deletions Include/internal/pycore_compile.h
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,6 @@ typedef struct {
int optimize;
int ff_features;

int recursion_depth; /* current recursion depth */
int recursion_limit; /* recursion limit */
} _PyASTOptimizeState;

extern int _PyAST_Optimize(
Expand Down
2 changes: 2 additions & 0 deletions Include/internal/pycore_pystate.h
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ PyAPI_FUNC(int) _PyState_AddModule(

PyAPI_FUNC(int) _PyOS_InterruptOccurred(PyThreadState *tstate);

int _Py_UpdateStackLimits(PyThreadState *tstate);

#ifdef __cplusplus
}
#endif
Expand Down
2 changes: 0 additions & 2 deletions Include/internal/pycore_symtable.h
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,6 @@ struct symtable {
PyObject *st_private; /* name of current class or NULL */
PyFutureFeatures *st_future; /* module's future features that affect
the symbol table */
int recursion_depth; /* current recursion depth */
int recursion_limit; /* recursion limit */
};

typedef struct _symtable_entry {
Expand Down
2 changes: 1 addition & 1 deletion Lib/test/list_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def test_repr(self):

def test_repr_deep(self):
a = self.type2test([])
for i in range(sys.getrecursionlimit() + 100):
for i in range(100_000):
a = self.type2test([a])
self.assertRaises(RecursionError, repr, a)

Expand Down
2 changes: 1 addition & 1 deletion Lib/test/mapping_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ def __repr__(self):

def test_repr_deep(self):
d = self._empty_mapping()
for i in range(sys.getrecursionlimit() + 100):
for i in range(100_000):
d0 = d
d = self._empty_mapping()
d[1] = d0
Expand Down
16 changes: 7 additions & 9 deletions Lib/test/test_ast.py
Original file line number Diff line number Diff line change
Expand Up @@ -816,19 +816,17 @@ def next(self):

@support.cpython_only
def test_ast_recursion_limit(self):
fail_depth = sys.getrecursionlimit() * 3
crash_depth = sys.getrecursionlimit() * 300
success_depth = int(fail_depth * 0.75)
crash_depth = 100_000
success_depth = 1000

def check_limit(prefix, repeated):
expect_ok = prefix + repeated * success_depth
ast.parse(expect_ok)
for depth in (fail_depth, crash_depth):
broken = prefix + repeated * depth
details = "Compiling ({!r} + {!r} * {})".format(
prefix, repeated, depth)
with self.assertRaises(RecursionError, msg=details):
ast.parse(broken)
broken = prefix + repeated * crash_depth
details = "Compiling ({!r} + {!r} * {})".format(
prefix, repeated, crash_depth)
with self.assertRaises(RecursionError, msg=details):
ast.parse(broken)

check_limit("a", "()")
check_limit("a", ".b")
Expand Down
38 changes: 38 additions & 0 deletions Lib/test/test_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,44 @@ def test_multiple_values(self):
with self.check_raises_type_error(msg):
A().method_two_args("x", "y", x="oops")

@cpython_only
class TestRecursion(unittest.TestCase):

def test_super_deep(self):

def recurse(n):
if n:
recurse(n-1)

def py_recurse(n, m):
if n:
py_recurse(n-1, m)
else:
c_py_recurse(m-1)

def c_recurse(n):
if n:
_testcapi.pyobject_fastcall(c_recurse, (n-1,))

def c_py_recurse(m):
if m:
_testcapi.pyobject_fastcall(py_recurse, (1000, m))

depth = sys.getrecursionlimit()
sys.setrecursionlimit(100_000)
try:
recurse(90_000)
with self.assertRaises(RecursionError):
recurse(101_000)
c_recurse(100)
with self.assertRaises(RecursionError):
c_recurse(90_000)
c_py_recurse(90)
with self.assertRaises(RecursionError):
c_py_recurse(100_000)
finally:
sys.setrecursionlimit(depth)


if __name__ == "__main__":
unittest.main()
28 changes: 10 additions & 18 deletions Lib/test/test_compile.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,7 @@ def __getitem__(self, key):

@unittest.skipIf(support.is_wasi, "exhausts limited stack on WASI")
def test_extended_arg(self):
# default: 1000 * 2.5 = 2500 repetitions
repeat = int(sys.getrecursionlimit() * 2.5)
repeat = 700
longexpr = 'x = x or ' + '-x' * repeat
g = {}
code = '''
Expand Down Expand Up @@ -545,27 +544,20 @@ def test_yet_more_evil_still_undecodable(self):

@support.cpython_only
@unittest.skipIf(support.is_wasi, "exhausts limited stack on WASI")
@support.skip_if_sanitizer(memory=True, address=True,
reason= "sanitizer consumes too much stack space")
def test_compiler_recursion_limit(self):
# Expected limit is sys.getrecursionlimit() * the scaling factor
# in symtable.c (currently 3)
# We expect to fail *at* that limit, because we use up some of
# the stack depth limit in the test suite code
# So we check the expected limit and 75% of that
# XXX (ncoghlan): duplicating the scaling factor here is a little
# ugly. Perhaps it should be exposed somewhere...
fail_depth = sys.getrecursionlimit() * 3
crash_depth = sys.getrecursionlimit() * 300
success_depth = int(fail_depth * 0.75)
crash_depth = 100_000
success_depth = 1000

def check_limit(prefix, repeated, mode="single"):
expect_ok = prefix + repeated * success_depth
compile(expect_ok, '<test>', mode)
for depth in (fail_depth, crash_depth):
broken = prefix + repeated * depth
details = "Compiling ({!r} + {!r} * {})".format(
prefix, repeated, depth)
with self.assertRaises(RecursionError, msg=details):
compile(broken, '<test>', mode)
broken = prefix + repeated * crash_depth
details = "Compiling ({!r} + {!r} * {})".format(
prefix, repeated, crash_depth)
with self.assertRaises(RecursionError, msg=details):
compile(broken, '<test>', mode)

check_limit("a", "()")
check_limit("a", ".b")
Expand Down
2 changes: 1 addition & 1 deletion Lib/test/test_dict.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,7 @@ def __repr__(self):

def test_repr_deep(self):
d = {}
for i in range(sys.getrecursionlimit() + 100):
for i in range(100_000):
d = {1: d}
self.assertRaises(RecursionError, repr, d)

Expand Down
6 changes: 2 additions & 4 deletions Lib/test/test_dynamic.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,9 @@ class MyGlobals(dict):
def __missing__(self, key):
return int(key.removeprefix("_number_"))

# 1,000 on most systems
limit = sys.getrecursionlimit()
code = "lambda: " + "+".join(f"_number_{i}" for i in range(limit))
code = "lambda: " + "+".join(f"_number_{i}" for i in range(700))
sum_func = eval(code, MyGlobals())
expected = sum(range(limit))
expected = sum(range(700))
# Warm up the the function for quickening (PEP 659)
for _ in range(30):
self.assertEqual(sum_func(), expected)
Expand Down
2 changes: 1 addition & 1 deletion Lib/test/test_exception_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,7 @@ def test_basics_split_by_predicate__match(self):
class DeepRecursionInSplitAndSubgroup(unittest.TestCase):
def make_deep_eg(self):
e = TypeError(1)
for i in range(2000):
for i in range(100_000):
e = ExceptionGroup('eg', [e])
return e

Expand Down
13 changes: 1 addition & 12 deletions Lib/test/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1375,16 +1375,6 @@ def test_recursion_normalizing_exception(self):

class MyException(Exception): pass

def setrecursionlimit(depth):
while 1:
try:
sys.setrecursionlimit(depth)
return depth
except RecursionError:
# sys.setrecursionlimit() raises a RecursionError if
# the new recursion limit is too low (issue #25274).
depth += 1

def recurse(cnt):
cnt -= 1
if cnt:
Expand All @@ -1405,9 +1395,8 @@ def gen():
# tstate->recursion_depth is equal to (recursion_limit - 1)
# and is equal to recursion_limit when _gen_throw() calls
# PyErr_NormalizeException().
recurse(setrecursionlimit(depth + 2) - depth)
recurse(1000)
finally:
sys.setrecursionlimit(recursionlimit)
print('Done.')
""" % __file__
rc, out, err = script_helper.assert_python_failure("-Wd", "-c", code)
Expand Down
12 changes: 6 additions & 6 deletions Lib/test/test_isinstance.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from test import support



class TestIsInstanceExceptions(unittest.TestCase):
# Test to make sure that an AttributeError when accessing the instance's
# class's bases is masked. This was actually a bug in Python 2.2 and
Expand Down Expand Up @@ -97,7 +97,7 @@ def getclass(self):
class D: pass
self.assertRaises(RuntimeError, isinstance, c, D)


# These tests are similar to above, but tickle certain code paths in
# issubclass() instead of isinstance() -- really PyObject_IsSubclass()
# vs. PyObject_IsInstance().
Expand Down Expand Up @@ -147,7 +147,7 @@ def getbases(self):
self.assertRaises(TypeError, issubclass, B, C())



# meta classes for creating abstract classes and instances
class AbstractClass(object):
def __init__(self, bases):
Expand Down Expand Up @@ -179,7 +179,7 @@ class Super:

class Child(Super):
pass

class TestIsInstanceIsSubclass(unittest.TestCase):
# Tests to ensure that isinstance and issubclass work on abstract
# classes and instances. Before the 2.2 release, TypeErrors were
Expand Down Expand Up @@ -353,10 +353,10 @@ def blowstack(fxn, arg, compare_to):
# Make sure that calling isinstance with a deeply nested tuple for its
# argument will raise RecursionError eventually.
tuple_arg = (compare_to,)
for cnt in range(sys.getrecursionlimit()+5):
for cnt in range(100_000):
tuple_arg = (tuple_arg,)
fxn(arg, tuple_arg)


if __name__ == '__main__':
unittest.main()
7 changes: 4 additions & 3 deletions Lib/test/test_sys_settrace.py
Original file line number Diff line number Diff line change
Expand Up @@ -2751,16 +2751,17 @@ def test_trace_unpack_long_sequence(self):
self.assertEqual(counts, {'call': 1, 'line': 301, 'return': 1})

def test_trace_lots_of_globals(self):
COUNT = 700
code = """if 1:
def f():
return (
{}
)
""".format("\n+\n".join(f"var{i}\n" for i in range(1000)))
ns = {f"var{i}": i for i in range(1000)}
""".format("\n+\n".join(f"var{i}\n" for i in range(700)))
ns = {f"var{i}": i for i in range(700)}
exec(code, ns)
counts = self.count_traces(ns["f"])
self.assertEqual(counts, {'call': 1, 'line': 2000, 'return': 1})
self.assertEqual(counts, {'call': 1, 'line': 1400, 'return': 1})


class TestEdgeCases(unittest.TestCase):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Recursion that would have overflowed the C stack is prevented and a
RecursionError raised. Prevents VM crashes when the recursion limit is set
too high.
Loading