[3.14] gh-155109: Run tests exhausting the C stack with a limited C stack (GH-155120) (GH-155165)

serhiy-storchaka <[email protected]> Tue, 04 Aug 2026 07:02:21 -0400 (EDT)
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
https://github.com/python/cpython/commit/b669be3c2b44f1f9087f310df4801febc33dc71a
commit: b669be3c2b44f1f9087f310df4801febc33dc71a
branch: 3.14
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-04T11:01:38Z
summary:

[3.14] gh-155109: Run tests exhausting the C stack with a limited C stack (GH-155120) (GH-155165)

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.

(cherry picked from commit ce5ae29ef9b8ccb32683c922d135339311c36c9d)

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>

files:
A Misc/NEWS.d/next/Tests/2026-08-03-20-15-00.gh-issue-155109.limstack.rst
M Lib/test/list_tests.py
M Lib/test/mapping_tests.py
M Lib/test/support/__init__.py
M Lib/test/test_ast/test_ast.py
M Lib/test/test_compile.py
M Lib/test/test_dict.py
M Lib/test/test_dictviews.py
M Lib/test/test_exception_group.py
M Lib/test/test_json/test_recursion.py
M Lib/test/test_pyexpat.py
M Lib/test/test_typing.py
M Lib/test/test_xml_etree.py

diff --git a/Lib/test/list_tests.py b/Lib/test/list_tests.py
index ec2aa59f2cb8728..ad9a9ea83035fb4 100644
--- a/Lib/test/list_tests.py
+++ b/Lib/test/list_tests.py
@@ -6,7 +6,7 @@
 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 @@ def test_repr(self):
         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):
diff --git a/Lib/test/mapping_tests.py b/Lib/test/mapping_tests.py
index b95affd9f78479b..1358200add54a50 100644
--- a/Lib/test/mapping_tests.py
+++ b/Lib/test/mapping_tests.py
@@ -622,7 +622,7 @@ def __repr__(self):
         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)
diff --git a/Lib/test/support/__init__.py b/Lib/test/support/__init__.py
index 005ab19a93d941c..504a0ffb4010bc0 100644
--- a/Lib/test/support/__init__.py
+++ b/Lib/test/support/__init__.py
@@ -45,6 +45,7 @@
     "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",
@@ -2743,6 +2744,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.
 
@@ -2750,23 +2771,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.
diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py
index 3f607a00d2508e0..48de146c45b1157 100644
--- a/Lib/test/test_ast/test_ast.py
+++ b/Lib/test/test_ast/test_ast.py
@@ -990,7 +990,8 @@ def next(self):
         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):
diff --git a/Lib/test/test_compile.py b/Lib/test/test_compile.py
index db1b4c6aa95186d..021c3622566d1a2 100644
--- a/Lib/test/test_compile.py
+++ b/Lib/test/test_compile.py
@@ -724,7 +724,8 @@ def test_yet_more_evil_still_undecodable(self):
 
     @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
diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py
index 80eb0948ca21f80..8f8ebc15febf62d 100644
--- a/Lib/test/test_dict.py
+++ b/Lib/test/test_dict.py
@@ -678,7 +678,7 @@ def __repr__(self):
         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):
diff --git a/Lib/test/test_dictviews.py b/Lib/test/test_dictviews.py
index 9816ae6c033ec74..3af0501765af29e 100644
--- a/Lib/test/test_dictviews.py
+++ b/Lib/test/test_dictviews.py
@@ -3,7 +3,7 @@
 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 @@ def test_recursive_repr(self):
         # 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):
diff --git a/Lib/test/test_exception_group.py b/Lib/test/test_exception_group.py
index 7d690ca59738ebb..c493afcb16e1ee2 100644
--- a/Lib/test/test_exception_group.py
+++ b/Lib/test/test_exception_group.py
@@ -2,7 +2,7 @@
 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):
@@ -537,7 +537,7 @@ def make_deep_eg(self):
             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):
@@ -545,7 +545,7 @@ def test_deep_split(self):
         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):
diff --git a/Lib/test/test_json/test_recursion.py b/Lib/test/test_json/test_recursion.py
index cbae9fbb4d624ba..17c7afe6417d887 100644
--- a/Lib/test/test_json/test_recursion.py
+++ b/Lib/test/test_json/test_recursion.py
@@ -69,7 +69,7 @@ def default(self, o):
 
 
     @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):
diff --git a/Lib/test/test_pyexpat.py b/Lib/test/test_pyexpat.py
index 419edfecb9b63d1..fd3077063b5f163 100644
--- a/Lib/test/test_pyexpat.py
+++ b/Lib/test/test_pyexpat.py
@@ -815,7 +815,7 @@ def test_trigger_leak(self):
         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):
diff --git a/Lib/test/test_typing.py b/Lib/test/test_typing.py
index c8b11b3cfdc6d89..a710fec2e5b69c5 100644
--- a/Lib/test/test_typing.py
+++ b/Lib/test/test_typing.py
@@ -52,8 +52,8 @@
 from test.support import (
     captured_stderr, cpython_only, infinite_recursion, requires_docstrings, import_helper, run_code,
     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,
@@ -4929,7 +4929,7 @@ class MM2(collections.abc.MutableMapping, MutableMapping[str, str]):
         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):
diff --git a/Lib/test/test_xml_etree.py b/Lib/test/test_xml_etree.py
index b6168c935c602e1..6da7c34dc4d3e12 100644
--- a/Lib/test/test_xml_etree.py
+++ b/Lib/test/test_xml_etree.py
@@ -3123,7 +3123,7 @@ def __deepcopy__(self, memo):
         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
index 000000000000000..cc6f057a364180c
--- /dev/null
+++ b/Misc/NEWS.d/next/Tests/2026-08-03-20-15-00.gh-issue-155109.limstack.rst
@@ -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.

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]