From f203efd74665561108afbd47af16c258f1d75e44 Mon Sep 17 00:00:00 2001 From: Serhiy Storchaka Date: Sun, 13 Sep 2026 20:11:27 +0300 Subject: [PATCH] gh-157429: Make test_detach_materialized_dict_no_memory deterministic Add _testcapi.call_with_nomemory() which arms the allocation failure, calls a function and removes the memory hooks without executing any bytecode in between, so that pending work at the eval breaker (GC, deferred frees on free-threading builds) cannot consume the failing allocation. The test now drops the last reference from C with this helper, accepts only the unraisable exception reported while clearing the managed dict, and no longer needs to run in a subprocess. Co-authored-by: Claude Opus 5 (1M context) --- Lib/test/test_class.py | 43 ++++++++++++++++++----------------------- Modules/_testcapi/mem.c | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/Lib/test/test_class.py b/Lib/test/test_class.py index 7bd6d966e8536b7..0ba3956be065a8d 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, isolation +from test.support import cpython_only, import_helper testmeths = [ @@ -1014,7 +1014,6 @@ class C: C.a = X() @support.nomemtest - @isolation.runInSubprocess() def test_detach_materialized_dict_no_memory(self): import _testcapi @@ -1026,31 +1025,27 @@ def __init__(self): # 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 + # Drop the last reference from C, so that nothing else (such as + # GC) runs between arming the failure and the deallocation. + seen = [] for n in range(20): - a = A() - d = a.__dict__ - try: - 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. + lst = [A()] + d = lst[0].__dict__ + with support.catch_unraisable_exception() as ex: + _testcapi.call_with_nomemory(n, n + 1, lst.clear) + if ex.unraisable is None: + continue + exc_type = ex.unraisable.exc_type + err_msg = ex.unraisable.err_msg + seen.append((n, exc_type, err_msg)) + if (exc_type is MemoryError and err_msg == + 'Exception ignored while clearing an object managed dict'): + # The dictionary should have been cleared. + self.assertNotIn("a", d) break else: - if not raised: - self.fail("MemoryError was not raised during deallocation") - self.fail("the dictionary was not cleared") + self.fail("MemoryError was not raised while detaching " + f"the dictionary: {seen}") class DefinitionOrderTests(unittest.TestCase): # PEP 520: Preserving Class Attribute Definition Order diff --git a/Modules/_testcapi/mem.c b/Modules/_testcapi/mem.c index 4ae6a60ff39d157..aeb31e3ab7678e7 100644 --- a/Modules/_testcapi/mem.c +++ b/Modules/_testcapi/mem.c @@ -210,6 +210,39 @@ remove_mem_hooks(PyObject *self, PyObject *Py_UNUSED(ignored)) Py_RETURN_NONE; } +static PyObject * +call_with_nomemory(PyObject *self, PyObject *args) +{ + /* Call func(*args) with memory allocation failing as in set_nomemory(). + * No bytecode is executed between arming the failure and the call. */ + Py_ssize_t nargs = PyTuple_GET_SIZE(args); + if (nargs < 3) { + PyErr_SetString(PyExc_TypeError, + "call_with_nomemory() requires at least 3 arguments"); + return NULL; + } + int start = PyLong_AsInt(PyTuple_GET_ITEM(args, 0)); + if (start == -1 && PyErr_Occurred()) { + return NULL; + } + int stop = PyLong_AsInt(PyTuple_GET_ITEM(args, 1)); + if (stop == -1 && PyErr_Occurred()) { + return NULL; + } + PyObject *func = PyTuple_GET_ITEM(args, 2); + /* PyObject_Call() with a prebuilt tuple does not allocate, + * unlike PyObject_Vectorcall() for a callee without vectorcall. */ + PyObject *callargs = PyTuple_GetSlice(args, 3, nargs); + if (callargs == NULL) { + return NULL; + } + fm_set_nomemory(start, stop); + PyObject *res = PyObject_Call(func, callargs, NULL); + fm_remove_hooks(); + Py_DECREF(callargs); + return res; +} + static PyObject * test_setallocators(PyMemAllocatorDomain domain) { @@ -964,6 +997,8 @@ static PyMethodDef test_methods[] = { PyDoc_STR("Remove memory hooks.")}, {"set_nomemory", set_nomemory, METH_VARARGS, PyDoc_STR("set_nomemory(start:int, stop:int = 0)")}, + {"call_with_nomemory", call_with_nomemory, METH_VARARGS, + PyDoc_STR("call_with_nomemory(start:int, stop:int, func, /, *args)")}, {"test_pymem_alloc0", test_pymem_alloc0, METH_NOARGS}, {"test_pymem_setallocators", test_pymem_setallocators, METH_NOARGS}, {"test_pymem_setrawallocators", test_pymem_setrawallocators, METH_NOARGS},