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 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/_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/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/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_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_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): 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() 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_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. 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/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/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. 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 *