From 36250a9b45cd898aa51c438cfb61fa1408266ffc Mon Sep 17 00:00:00 2001 From: Kumar Aditya Date: Tue, 4 Aug 2026 12:00:27 +0530 Subject: [PATCH 1/6] gh-104091: Suggest TaskGroup for running background tasks in asyncio.create_task() docs (#155133) --- Doc/library/asyncio-task.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Doc/library/asyncio-task.rst b/Doc/library/asyncio-task.rst index b6f3662862eb38f..38364138a17d790 100644 --- a/Doc/library/asyncio-task.rst +++ b/Doc/library/asyncio-task.rst @@ -288,6 +288,17 @@ Creating tasks # completion: task.add_done_callback(background_tasks.discard) + Note that this approach never awaits the tasks, so if a task + fails, its exception is never retrieved and asyncio logs a + "Task exception was never retrieved" message when the task is + garbage collected. To avoid this, use :class:`asyncio.TaskGroup` + which keeps a strong reference to each task, awaits them and + propagates their exceptions:: + + async with asyncio.TaskGroup() as tg: + for i in range(10): + tg.create_task(some_coro(param=i)) + .. versionadded:: 3.7 .. versionchanged:: 3.8 From 089b713e01665c5fec8865a16716d1e4bce7c1d9 Mon Sep 17 00:00:00 2001 From: Timofei Ivankov <128279579+deadlovelll@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:34:54 +0300 Subject: [PATCH 2/6] gh-154971: Split IocpProactor._poll() (#155142) --- Lib/asyncio/windows_events.py | 75 +++++++++++--------- Lib/test/test_asyncio/test_windows_events.py | 22 ++++++ 2 files changed, 64 insertions(+), 33 deletions(-) diff --git a/Lib/asyncio/windows_events.py b/Lib/asyncio/windows_events.py index efc7c0c158d3905..2a7c18cda8a76a5 100644 --- a/Lib/asyncio/windows_events.py +++ b/Lib/asyncio/windows_events.py @@ -760,6 +760,46 @@ def _get_accept_socket(self, family): s.settimeout(0) return s + def _process_completion_status(self, status): + """Process a single status from the completion port. + + A caller that waits on the completion port itself can pass each + status it receives here. + """ + err, transferred, key, address = status + try: + f, ov, obj, callback = self._cache.pop(address) + except KeyError: + if self._loop.get_debug(): + self._loop.call_exception_handler({ + 'message': ('GetQueuedCompletionStatus() returned an ' + 'unexpected event'), + 'status': ('err=%s transferred=%s key=%#x address=%#x' + % (err, transferred, key, address)), + }) + + # key is either zero, or it is used to return a pipe + # handle which should be closed to avoid a leak. + if key not in (0, _overlapped.INVALID_HANDLE_VALUE): + _winapi.CloseHandle(key) + return + + if obj in self._stopped_serving: + f.cancel() + # Don't call the callback if _register() already read the result or + # if the overlapped has been cancelled + elif not f.done(): + try: + value = callback(transferred, key, ov) + except OSError as e: + f.set_exception(e) + self._results.append(f) + else: + f.set_result(value) + self._results.append(f) + finally: + f = None + def _poll(self, timeout=None): if timeout is None: ms = INFINITE @@ -778,39 +818,8 @@ def _poll(self, timeout=None): break ms = 0 - err, transferred, key, address = status - try: - f, ov, obj, callback = self._cache.pop(address) - except KeyError: - if self._loop.get_debug(): - self._loop.call_exception_handler({ - 'message': ('GetQueuedCompletionStatus() returned an ' - 'unexpected event'), - 'status': ('err=%s transferred=%s key=%#x address=%#x' - % (err, transferred, key, address)), - }) - - # key is either zero, or it is used to return a pipe - # handle which should be closed to avoid a leak. - if key not in (0, _overlapped.INVALID_HANDLE_VALUE): - _winapi.CloseHandle(key) - continue - - if obj in self._stopped_serving: - f.cancel() - # Don't call the callback if _register() already read the result or - # if the overlapped has been cancelled - elif not f.done(): - try: - value = callback(transferred, key, ov) - except OSError as e: - f.set_exception(e) - self._results.append(f) - else: - f.set_result(value) - self._results.append(f) - finally: - f = None + # gh-154971: split out so custom event loops can call it directly + self._process_completion_status(status) # Remove unregistered futures for ov in self._unregistered: diff --git a/Lib/test/test_asyncio/test_windows_events.py b/Lib/test/test_asyncio/test_windows_events.py index c23427b8652069d..bb4ba74f19a17f0 100644 --- a/Lib/test/test_asyncio/test_windows_events.py +++ b/Lib/test/test_asyncio/test_windows_events.py @@ -327,6 +327,28 @@ def threadMain(): stop.set() thr.join() + def test_custom_poll_integration(self): + # gh-154971: a caller can wait on the completion port and process statuses itself + proactor = self.loop._proactor + + a, b = socket.socketpair() + self.addCleanup(a.close) + self.addCleanup(b.close) + + fut = proactor.recv(a, 100) + self.assertFalse(fut.done()) + + b.send(b'data') + + deadline = time.monotonic() + support.SHORT_TIMEOUT + while not fut.done() and time.monotonic() < deadline: + status = _overlapped.GetQueuedCompletionStatus(proactor._iocp, 100) + if status is not None: + proactor._process_completion_status(status) + + self.assertTrue(fut.done()) + self.assertEqual(fut.result(), b'data') + class ProactorPipeObjectSupportTests(unittest.TestCase): From 416c3465e247bc5a36f86b832360f65e06a0767a Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 4 Aug 2026 11:49:38 +0300 Subject: [PATCH 3/6] gh-155146: Do not depend on the exact number of allocations in test_class (GH-155150) Try to fail every one of the first allocations and accept the first one which fails in the code detaching the instance dictionary from the object, instead of assuming that this is the first allocation after set_nomemory(). Run the test with the test.support.isolation.runInSubprocess() decorator instead of executing it as a source string in a subprocess. Co-authored-by: Claude Opus 5 (1M context) --- Lib/test/test_class.py | 57 +++++++++++++++++++++++++----------------- 1 file changed, 34 insertions(+), 23 deletions(-) diff --git a/Lib/test/test_class.py b/Lib/test/test_class.py index 62d8806b75d9db0..e07efd269669459 100644 --- a/Lib/test/test_class.py +++ b/Lib/test/test_class.py @@ -2,7 +2,7 @@ import unittest from test import support -from test.support import cpython_only, import_helper, script_helper +from test.support import cpython_only, import_helper, isolation testmeths = [ @@ -1014,32 +1014,43 @@ class C: C.a = X() @support.nomemtest + @isolation.runInSubprocess() def test_detach_materialized_dict_no_memory(self): - code = """if 1: - import test.support - import _testcapi - - class A: - def __init__(self): - self.a = 1 - self.b = 2 + import _testcapi + + class A: + def __init__(self): + self.a = 1 + self.b = 2 + + # The failing allocation should be the one which detaches the + # dictionary from the object, but other allocations can happen + # first, so try to fail every one of the first allocations. + raised = False + for n in range(20): a = A() d = a.__dict__ - with test.support.catch_unraisable_exception() as ex: - _testcapi.set_nomemory(0, 1) - del a - assert ex.unraisable.exc_type is MemoryError try: - d["a"] - except KeyError: - pass - else: - assert False, "KeyError not raised" - """ - rc, out, err = script_helper.assert_python_ok("-c", code) - self.assertEqual(rc, 0) - self.assertFalse(out, msg=out.decode('utf-8')) - self.assertFalse(err, msg=err.decode('utf-8')) + with support.catch_unraisable_exception() as ex: + _testcapi.set_nomemory(n, n + 1) + try: + del a + finally: + _testcapi.remove_mem_hooks() + exc_type = ex.unraisable and ex.unraisable.exc_type + except MemoryError: + # The failing allocation was not in the deallocation code. + continue + if exc_type is not MemoryError: + continue + raised = True + if "a" not in d: + # The dictionary was cleared, as expected. + break + else: + if not raised: + self.fail("MemoryError was not raised during deallocation") + self.fail("the dictionary was not cleared") if __name__ == '__main__': unittest.main() From 2b91da7cd4114e7b264ab4a7b7a1c42b79663a6c Mon Sep 17 00:00:00 2001 From: Timofei Ivankov <128279579+deadlovelll@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:24:02 +0300 Subject: [PATCH 4/6] gh-154044: Fix data race on descriptor __qualname__ in free-threading build (#154691) --- .../2026-07-25-12-44-13.gh-issue-154044.GBa2wP.rst | 2 ++ Objects/descrobject.c | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-07-25-12-44-13.gh-issue-154044.GBa2wP.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-07-25-12-44-13.gh-issue-154044.GBa2wP.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-25-12-44-13.gh-issue-154044.GBa2wP.rst new file mode 100644 index 000000000000000..7338ae0210450a7 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-07-25-12-44-13.gh-issue-154044.GBa2wP.rst @@ -0,0 +1,2 @@ +Fix a data race on a descriptor's ``__qualname__`` cache in the +:term:`free-threaded build`. diff --git a/Objects/descrobject.c b/Objects/descrobject.c index 568d978d27d12f9..8ceef6489881679 100644 --- a/Objects/descrobject.c +++ b/Objects/descrobject.c @@ -621,9 +621,17 @@ static PyObject * descr_get_qualname(PyObject *self, void *Py_UNUSED(ignored)) { PyDescrObject *descr = (PyDescrObject *)self; - if (descr->d_qualname == NULL) - descr->d_qualname = calculate_qualname(descr); - return Py_XNewRef(descr->d_qualname); + PyObject *qualname; + Py_BEGIN_CRITICAL_SECTION(self); + if (descr->d_qualname == NULL) { + PyObject *new_qualname = calculate_qualname(descr); + if (new_qualname != NULL) { + Py_XSETREF(descr->d_qualname, new_qualname); + } + } + qualname = Py_XNewRef(descr->d_qualname); + Py_END_CRITICAL_SECTION(); + return qualname; } static PyObject * From ce5ae29ef9b8ccb32683c922d135339311c36c9d Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 4 Aug 2026 12:43:48 +0300 Subject: [PATCH 5/6] gh-155109: Run tests exhausting the C stack with a limited C stack (GH-155120) 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) --- Lib/test/list_tests.py | 4 +- Lib/test/mapping_tests.py | 2 +- Lib/test/support/__init__.py | 95 ++++++++++++++++--- Lib/test/test_ast/test_ast.py | 3 +- Lib/test/test_compile.py | 3 +- Lib/test/test_dict.py | 2 +- Lib/test/test_dictviews.py | 4 +- Lib/test/test_exception_group.py | 6 +- Lib/test/test_json/test_recursion.py | 2 +- Lib/test/test_pyexpat.py | 2 +- Lib/test/test_typing.py | 6 +- Lib/test/test_xml_etree.py | 2 +- ...8-03-20-15-00.gh-issue-155109.limstack.rst | 3 + 13 files changed, 102 insertions(+), 32 deletions(-) create mode 100644 Misc/NEWS.d/next/Tests/2026-08-03-20-15-00.gh-issue-155109.limstack.rst 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 ae2fb3f5f448e25..e3f348d272a03c6 100644 --- a/Lib/test/mapping_tests.py +++ b/Lib/test/mapping_tests.py @@ -629,7 +629,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 f4c4b4c1acfc182..7898ef5e15b2c40 100644 --- a/Lib/test/support/__init__.py +++ b/Lib/test/support/__init__.py @@ -46,6 +46,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", @@ -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. diff --git a/Lib/test/test_ast/test_ast.py b/Lib/test/test_ast/test_ast.py index 87d63fcd8529336..28ac6c6fcbccc1f 100644 --- a/Lib/test/test_ast/test_ast.py +++ b/Lib/test/test_ast/test_ast.py @@ -1025,7 +1025,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 2c7b1181817cf55..df473d59fff3d8e 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 1e665c86303078c..673987733fc8c4f 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 325b1c91fa5aeef..f79bfa4ae2d3220 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): @@ -549,7 +549,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): @@ -557,7 +557,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 54dfa95ce8bff17..9869f0e88448cf1 100644 --- a/Lib/test/test_pyexpat.py +++ b/Lib/test/test_pyexpat.py @@ -904,7 +904,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 53c8c9fac694654..619d0cb2fe541a6 100644 --- a/Lib/test/test_typing.py +++ b/Lib/test/test_typing.py @@ -53,8 +53,8 @@ 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 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 38bc681a267b951..0c944516ae115f1 100644 --- a/Lib/test/test_xml_etree.py +++ b/Lib/test/test_xml_etree.py @@ -3246,7 +3246,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. From ef0affbf7c1cb8bbc136b65cc0f02985f8c568b5 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Tue, 4 Aug 2026 13:03:05 +0300 Subject: [PATCH 6/6] gh-152548: Report a subprocess of runInSubprocess() dying after the test (GH-155162) The subprocess writes the result of the test before exiting, so a crash during interpreter finalization left the result intact and the test was reported as passed. Check the exit code too, after replaying the outcomes, so that a failure of the test itself is still reported as a failure. Co-authored-by: Claude Opus 5 (1M context) --- Lib/test/_isolated_sample.py | 32 ++++++++++++++++++++++++++++++++ Lib/test/support/isolation.py | 25 ++++++++++++++++++++++--- Lib/test/test_support.py | 25 +++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/Lib/test/_isolated_sample.py b/Lib/test/_isolated_sample.py index 360a27a2b081173..c89f7145e7328d3 100644 --- a/Lib/test/_isolated_sample.py +++ b/Lib/test/_isolated_sample.py @@ -5,6 +5,8 @@ a subprocess. Several of these tests fail, error or are skipped on purpose. """ +import atexit +import os import time import unittest from test.support import isolation @@ -109,3 +111,33 @@ class BrokenSubclassSample(SubclassingSample): @classmethod def setUpClass(cls): pass + + +# The exit code the samples below die with, after their tests have run. +EXIT_CODE = 3 + + +def _die_at_exit(): + atexit.register(os._exit, EXIT_CODE) + + +class MethodExitSample(unittest.TestCase): + + @isolation.runInSubprocess() + def test_passes_then_dies(self): + _die_at_exit() + + @isolation.runInSubprocess() + def test_fails_and_dies(self): + _die_at_exit() + self.fail('the test itself failed') + + +@isolation.runInSubprocess() +class ClassExitSample(unittest.TestCase): + + def test_pass(self): + pass + + def test_dies(self): + _die_at_exit() diff --git a/Lib/test/support/isolation.py b/Lib/test/support/isolation.py index f449bf44034da35..bc2189329c03997 100644 --- a/Lib/test/support/isolation.py +++ b/Lib/test/support/isolation.py @@ -163,6 +163,16 @@ def _raise_fixture_outcome(outcome): raise exc from _remote(outcome['detail']) +def _check_returncode(returncode, output, what): + # The subprocess writes its result before exiting, so a non-zero exit code + # means it died afterwards, during finalization, unnoticed by the result. + if returncode: + exc = _SubprocessTestError( + f'the subprocess exited with code {returncode} ' + f'after running the {what}') + raise exc from _remote(output) + + def _isolate_method(func): @functools.wraps(func) def wrapper(self, /, *args, **kwargs): @@ -180,7 +190,9 @@ def wrapper(self, /, *args, **kwargs): raise exc from _remote(output) # The parent measures this method's own duration (the real cost of the # isolated run, subprocess startup included), so nothing to forward here. + # Replay the outcomes first: a failure of the test itself is more useful. _replay_outcomes(self, payload['outcomes']) + _check_returncode(returncode, output, 'test') return wrapper @@ -219,13 +231,20 @@ def setUpClass(cls): by_id.setdefault(outcome['id'], []).append(outcome) cls._isolated_outcomes = by_id cls._isolated_durations = dict(payload.get('durations', ())) + # Report the crash from tearDownClass(), after replaying the outcomes. + cls._isolated_exit = (returncode, output) def tearDownClass(cls): if runningInSubprocess: orig_tearDownClass(cls) - else: - cls._isolated_outcomes = None - cls._isolated_durations = None + return + cls._isolated_outcomes = None + cls._isolated_durations = None + # Missing if an overriding setUpClass() bypassed the subprocess. + exited = getattr(cls, '_isolated_exit', None) + cls._isolated_exit = None + if exited is not None: + _check_returncode(*exited, 'class') def _callSetUp(self): # In the parent the real test does not run, so neither should setUp(). diff --git a/Lib/test/test_support.py b/Lib/test/test_support.py index 4b9bc245d6f78a8..2317077b30ac388 100644 --- a/Lib/test/test_support.py +++ b/Lib/test/test_support.py @@ -1180,6 +1180,31 @@ def test_subclass_bypassing_setupclass_is_reported(self): self.assertEqual(len(result.errors), 1) self.assertIn('did not run in a subprocess', result.errors[0][1]) + @support.requires_subprocess() + def test_subprocess_dying_after_the_test_is_reported(self): + from test._isolated_sample import EXIT_CODE + result = self._run('MethodExitSample.test_passes_then_dies') + self.assertEqual(result.testsRun, 1) + self.assertEqual(len(result.errors), 1) + self.assertIn(f'exited with code {EXIT_CODE}', result.errors[0][1]) + + @support.requires_subprocess() + def test_subprocess_dying_does_not_hide_the_failure(self): + result = self._run('MethodExitSample.test_fails_and_dies') + self.assertEqual(self._names(result.failures), ['test_fails_and_dies']) + self.assertEqual(result.errors, []) + + @support.requires_subprocess() + def test_class_subprocess_dying_after_the_tests_is_reported(self): + # The tests that ran are still reported, and the crash once, for the class. + from test._isolated_sample import EXIT_CODE + result = self._run('ClassExitSample') + self.assertEqual(result.testsRun, 2) + self.assertEqual(result.failures, []) + self.assertEqual(len(result.errors), 1) + self.assertIn('tearDownClass', str(result.errors[0][0])) + self.assertIn(f'exited with code {EXIT_CODE}', result.errors[0][1]) + def test_skipped_without_subprocess_support(self): # On a platform without subprocess support the test is skipped in the # parent, before any subprocess is spawned.