]> git.ipfire.org Git - thirdparty/Python/cpython.git/commitdiff
gh-155109: Run tests exhausting the C stack with a limited C stack (GH-155120)
authorSerhiy Storchaka <storchaka@gmail.com>
Tue, 4 Aug 2026 09:43:48 +0000 (12:43 +0300)
committerGitHub <noreply@github.com>
Tue, 4 Aug 2026 09:43:48 +0000 (09:43 +0000)
Add the @support.run_with_limited_c_stack() decorator which runs the test
in a thread with a 8 MiB C stack, so that the outcome does not depend on
RLIMIT_STACK. Use it in tests which recurse to a fixed depth --
@support.skip_if_huge_c_stack() failed to skip them with a 16 MiB stack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13 files changed:
Lib/test/list_tests.py
Lib/test/mapping_tests.py
Lib/test/support/__init__.py
Lib/test/test_ast/test_ast.py
Lib/test/test_compile.py
Lib/test/test_dict.py
Lib/test/test_dictviews.py
Lib/test/test_exception_group.py
Lib/test/test_json/test_recursion.py
Lib/test/test_pyexpat.py
Lib/test/test_typing.py
Lib/test/test_xml_etree.py
Misc/NEWS.d/next/Tests/2026-08-03-20-15-00.gh-issue-155109.limstack.rst [new file with mode: 0644]

index ec2aa59f2cb8728839eba72869d4854d554b326a..ad9a9ea83035fb43bb33aeb23b73b1650278e5a4 100644 (file)
@@ -6,7 +6,7 @@ import sys
 from functools import cmp_to_key
 
 from test import seq_tests
-from test.support import ALWAYS_EQ, NEVER_EQ, skip_if_huge_c_stack
+from test.support import ALWAYS_EQ, NEVER_EQ, run_with_limited_c_stack
 from test.support import skip_emscripten_stack_overflow, skip_wasi_stack_overflow
 
 
@@ -60,7 +60,7 @@ class CommonTest(seq_tests.CommonTest):
         self.assertEqual(str(a2), "[0, 1, 2, [...], 3]")
         self.assertEqual(repr(a2), "[0, 1, 2, [...], 3]")
 
-    @skip_if_huge_c_stack(200_000)
+    @run_with_limited_c_stack(200_000)
     @skip_wasi_stack_overflow()
     @skip_emscripten_stack_overflow()
     def test_repr_deep(self):
index ae2fb3f5f448e25232a7a506b909571bbc8fbc74..e3f348d272a03c670d628e347bb4caf8f274f314 100644 (file)
@@ -629,7 +629,7 @@ class TestHashMappingProtocol(TestMappingProtocol):
         d = self._full_mapping({1: BadRepr()})
         self.assertRaises(Exc, repr, d)
 
-    @support.skip_if_huge_c_stack()
+    @support.run_with_limited_c_stack()
     @support.skip_wasi_stack_overflow()
     @support.skip_emscripten_stack_overflow()
     @support.skip_if_sanitizer("requires deep stack", ub=True)
index f4c4b4c1acfc182d403fb227a1118a75ea35ae2c..7898ef5e15b2c4043c139cc2443bcc35932276a5 100644 (file)
@@ -46,6 +46,7 @@ __all__ = [
     "check_disallow_instantiation", "check_sanitizer", "skip_if_sanitizer",
     "requires_limited_api", "requires_specialization", "thread_unsafe",
     "skip_if_unlimited_stack_size", "skip_if_huge_c_stack",
+    "run_with_limited_c_stack",
     # sys
     "MS_WINDOWS", "is_jython", "is_android", "is_emscripten", "is_wasi",
     "is_apple_mobile", "check_impl_detail", "unix_shell", "setswitchinterval",
@@ -2839,6 +2840,26 @@ def exceeds_recursion_limit():
     return 150_000
 
 
+def _has_huge_c_stack(depth):
+    """Check that *depth* recursive calls cannot exhaust the C stack."""
+    try:
+        from _testinternalcapi import get_c_recursion_remaining
+    except ImportError:
+        # Fall back to checking for an unlimited stack size.
+        if is_emscripten or is_wasi or os.name == "nt":
+            return False
+        import resource
+        soft, hard = resource.getrlimit(resource.RLIMIT_STACK)
+        return soft == hard and soft in (-1, 0xFFFF_FFFF_FFFF_FFFF)
+    else:
+        remaining = get_c_recursion_remaining()
+        # A negative value means integer overflow in the estimate
+        # (e.g. with an unlimited RLIMIT_STACK).  The estimate is based on
+        # the size of the interpreter loop frame, so it is only a lower
+        # bound for recursion with smaller C frames.
+        return remaining >= depth or remaining < 0
+
+
 def skip_if_huge_c_stack(depth=150_000):
     """Skip decorator for tests which cannot overflow the C stack.
 
@@ -2846,23 +2867,67 @@ def skip_if_huge_c_stack(depth=150_000):
     trigger the recursion protection if the C stack is too large (e.g.
     with a large or unlimited RLIMIT_STACK), and either fail, or run
     for a very long time, or crash, or consume all memory.
+
+    Prefer run_with_limited_c_stack() for tests recursing to a fixed depth.
     """
-    try:
-        from _testinternalcapi import get_c_recursion_remaining
-    except ImportError:
-        # Fall back to checking for an unlimited stack size.
-        huge = False
-        if not (is_emscripten or is_wasi) and os.name != "nt":
-            import resource
-            soft, hard = resource.getrlimit(resource.RLIMIT_STACK)
-            huge = soft == hard and soft in (-1, 0xFFFF_FFFF_FFFF_FFFF)
-    else:
-        remaining = get_c_recursion_remaining()
-        # A negative value means integer overflow in the estimate
-        # (e.g. with an unlimited RLIMIT_STACK).
-        huge = remaining >= depth or remaining < 0
     return unittest.skipIf(
-        huge, f"the C stack is large enough for {depth} recursive calls")
+        _has_huge_c_stack(depth),
+        f"the C stack is large enough for {depth} recursive calls")
+
+
+# Small enough to be exhausted by tens of thousands of recursive calls,
+# but not smaller than Py_C_STACK_SIZE (4 MiB) which the interpreter
+# assumes if it cannot query the thread stack size.
+C_STACK_SIZE = 8 * 1024 * 1024
+
+
+def run_with_limited_c_stack(depth=150_000, size=C_STACK_SIZE):
+    """Decorator for tests exhausting the C stack with *depth* recursive calls.
+
+    Run the test in a separate thread with the C stack of *size* bytes, so
+    that the outcome does not depend on the C stack size of the main thread
+    (which can be large or unlimited, see RLIMIT_STACK).
+
+    If a thread with the limited C stack cannot be created, run the test in
+    the current thread, but skip it if the C stack is too large.
+    """
+    reason = f"the C stack is large enough for {depth} recursive calls"
+    def decorator(test):
+        @functools.wraps(test)
+        def wrapper(*args, **kwargs):
+            def run_test():
+                # The C stack can still be too large if limiting it failed.
+                if _has_huge_c_stack(depth):
+                    raise unittest.SkipTest(reason)
+                test(*args, **kwargs)
+
+            try:
+                import threading
+                old_size = threading.stack_size(size)
+            except (ImportError, ValueError, RuntimeError):
+                # Setting the thread stack size is not supported.
+                return run_test()
+
+            exceptions = []
+            def run():
+                try:
+                    run_test()
+                except BaseException as exc:
+                    exceptions.append(exc)
+
+            thread = threading.Thread(target=run)
+            try:
+                thread.start()
+            except RuntimeError:
+                # Threads are not supported.
+                return run_test()
+            finally:
+                threading.stack_size(old_size)
+            thread.join()
+            if exceptions:
+                raise exceptions[0]
+        return wrapper
+    return decorator
 
 
 # Windows doesn't have os.uname() but it doesn't support s390x.
index 87d63fcd8529336f4f61460ffa3a8675753a598b..28ac6c6fcbccc1f5116efb411d0830d10526791a 100644 (file)
@@ -1025,7 +1025,8 @@ class AST_Tests(unittest.TestCase):
         enum._test_simple_enum(_Precedence, _ast_unparse._Precedence)
 
     @support.cpython_only
-    @support.skip_if_huge_c_stack(100_000 if sys.platform == "android" else 500_000)
+    @support.run_with_limited_c_stack(
+        100_000 if sys.platform == "android" else 500_000)
     @skip_wasi_stack_overflow()
     @skip_emscripten_stack_overflow()
     def test_ast_recursion_limit(self):
index 2c7b1181817cf555ee6c544d9cf021d98c020814..df473d59fff3d8ec5042dde1b703cdaeda04ca40 100644 (file)
@@ -724,7 +724,8 @@ class TestSpecifics(unittest.TestCase):
 
     @support.cpython_only
     @unittest.skipIf(support.is_wasi, "exhausts limited stack on WASI")
-    @support.skip_if_huge_c_stack(100_000 if sys.platform == "android" else 500_000)
+    @support.run_with_limited_c_stack(
+        100_000 if sys.platform == "android" else 500_000)
     @support.skip_emscripten_stack_overflow()
     def test_compiler_recursion_limit(self):
         # Compiler frames are small
index 1e665c86303078c6bc6f0e57a9b5b0bd426e702d..673987733fc8c4fa33214d3f58d8f90cbfb224f1 100644 (file)
@@ -678,7 +678,7 @@ class DictTest(unittest.TestCase):
         d = {1: BadRepr()}
         self.assertRaises(Exc, repr, d)
 
-    @support.skip_if_huge_c_stack()
+    @support.run_with_limited_c_stack()
     @support.skip_wasi_stack_overflow()
     @support.skip_emscripten_stack_overflow()
     def test_repr_deep(self):
index 9816ae6c033ec74a329941c2766a310f58f87ee1..3af0501765af29e182f841a4c3fd6ba7f016b944 100644 (file)
@@ -3,7 +3,7 @@ import copy
 import pickle
 import unittest
 from test.support import (skip_emscripten_stack_overflow,
-                          skip_wasi_stack_overflow, skip_if_huge_c_stack,
+                          skip_wasi_stack_overflow, run_with_limited_c_stack,
                           exceeds_recursion_limit)
 
 class DictSetTest(unittest.TestCase):
@@ -279,7 +279,7 @@ class DictSetTest(unittest.TestCase):
         # Again.
         self.assertIsInstance(r, str)
 
-    @skip_if_huge_c_stack()
+    @run_with_limited_c_stack()
     @skip_wasi_stack_overflow()
     @skip_emscripten_stack_overflow()
     def test_deeply_nested_repr(self):
index 325b1c91fa5aeef803eedc25a7f61b7b9f1c9ff7..f79bfa4ae2d3220db356813a7ab1969f9f02024c 100644 (file)
@@ -2,7 +2,7 @@ import collections
 import types
 import unittest
 from test.support import (skip_emscripten_stack_overflow,
-                          skip_wasi_stack_overflow, skip_if_huge_c_stack,
+                          skip_wasi_stack_overflow, run_with_limited_c_stack,
                           exceeds_recursion_limit)
 
 class TestExceptionGroupTypeHierarchy(unittest.TestCase):
@@ -549,7 +549,7 @@ class DeepRecursionInSplitAndSubgroup(unittest.TestCase):
             e = ExceptionGroup('eg', [e])
         return e
 
-    @skip_if_huge_c_stack()
+    @run_with_limited_c_stack()
     @skip_emscripten_stack_overflow()
     @skip_wasi_stack_overflow()
     def test_deep_split(self):
@@ -557,7 +557,7 @@ class DeepRecursionInSplitAndSubgroup(unittest.TestCase):
         with self.assertRaises(RecursionError):
             e.split(TypeError)
 
-    @skip_if_huge_c_stack()
+    @run_with_limited_c_stack()
     @skip_emscripten_stack_overflow()
     @skip_wasi_stack_overflow()
     def test_deep_subgroup(self):
index cbae9fbb4d624bacb1402d3c3ed5b9a7eb7a12fa..17c7afe6417d887fb7c0d8814d6e1aa20ecec38a 100644 (file)
@@ -69,7 +69,7 @@ class TestRecursion:
 
 
     @support.skip_if_pgo_task  # fails during PGO training w/ some stack sizes
-    @support.skip_if_huge_c_stack(500_000)
+    @support.run_with_limited_c_stack(500_000)
     @support.skip_emscripten_stack_overflow()
     @support.skip_wasi_stack_overflow()
     def test_highly_nested_objects_decoding(self):
index 54dfa95ce8bff1757556bb9fd483a56aeb625307..9869f0e88448cf1321a1fa3b1b933371e3157d48 100644 (file)
@@ -904,7 +904,7 @@ class ElementDeclHandlerTest(unittest.TestCase):
         parser.ElementDeclHandler = lambda _1, _2: None
         self.assertRaises(TypeError, parser.Parse, data, True)
 
-    @support.skip_if_huge_c_stack(800_000)
+    @support.run_with_limited_c_stack(800_000)
     @support.skip_emscripten_stack_overflow()
     @support.skip_wasi_stack_overflow()
     def test_deeply_nested_content_model(self):
index 53c8c9fac6946548f7e954ffb425a3cbe89af0af..619d0cb2fe541a66524f4b89c5eddabe67b3f7e4 100644 (file)
@@ -53,8 +53,8 @@ import types
 from test.support import (
     captured_stderr, cpython_only, requires_docstrings, import_helper, run_code,
     subTests, EqualToForwardRef,
-    exceeds_recursion_limit, skip_if_huge_c_stack, skip_wasi_stack_overflow,
-    skip_emscripten_stack_overflow,
+    exceeds_recursion_limit, run_with_limited_c_stack,
+    skip_wasi_stack_overflow, skip_emscripten_stack_overflow,
 )
 from test.typinganndata import (
     ann_module695, mod_generics_cache, _typed_dict_helper,
@@ -5098,7 +5098,7 @@ class GenericTests(BaseTestCase):
         self.assertEqual(MM2.__bases__, (collections.abc.MutableMapping, Generic))
 
     @cpython_only
-    @skip_if_huge_c_stack()
+    @run_with_limited_c_stack()
     @skip_wasi_stack_overflow()
     @skip_emscripten_stack_overflow()
     def test_parameters_deep_recursion(self):
index 38bc681a267b9512d20e4b0ccbccbcd159a15ec4..0c944516ae115f1500abceefb918c1d3e22467ba 100644 (file)
@@ -3246,7 +3246,7 @@ class BadElementTest(ElementTestCase, unittest.TestCase):
         self.assertEqual([c.tag for c in children[3:]],
                          [a.tag, b.tag, a.tag, b.tag])
 
-    @support.skip_if_huge_c_stack(500_000)
+    @support.run_with_limited_c_stack(500_000)
     @support.skip_emscripten_stack_overflow()
     @support.skip_wasi_stack_overflow()
     def test_deeply_nested_deepcopy(self):
diff --git a/Misc/NEWS.d/next/Tests/2026-08-03-20-15-00.gh-issue-155109.limstack.rst b/Misc/NEWS.d/next/Tests/2026-08-03-20-15-00.gh-issue-155109.limstack.rst
new file mode 100644 (file)
index 0000000..cc6f057
--- /dev/null
@@ -0,0 +1,3 @@
+Add ``test.support.run_with_limited_c_stack()`` and use it in tests that
+exhaust the C stack with a fixed number of recursive calls, so that their
+outcome no longer depends on the C stack size.