Skip to content

Commit 10b2d12

Browse files
gh-157429: Make test_detach_materialized_dict_no_memory deterministic (GH-157444)
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) <noreply@anthropic.com>
1 parent 869a505 commit 10b2d12

2 files changed

Lines changed: 56 additions & 21 deletions

File tree

‎Lib/test/test_class.py‎

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import unittest
44
from test import support
5-
from test.support import cpython_only, import_helper, isolation
5+
from test.support import cpython_only, import_helper
66

77
testmeths = [
88

@@ -1014,8 +1014,9 @@ class C:
10141014
C.a = X()
10151015

10161016
@support.nomemtest
1017-
@isolation.runInSubprocess()
10181017
def test_detach_materialized_dict_no_memory(self):
1018+
import _testcapi
1019+
10191020
class A:
10201021
def __init__(self):
10211022
self.a = 1
@@ -1024,28 +1025,27 @@ def __init__(self):
10241025
# The failing allocation should be the one which detaches the
10251026
# dictionary from the object, but other allocations can happen
10261027
# first, so try to fail every one of the first allocations.
1027-
raised = False
1028+
# Drop the last reference from C, so that nothing else (such as
1029+
# GC) runs between arming the failure and the deallocation.
1030+
seen = []
10281031
for n in range(20):
1029-
a = A()
1030-
d = a.__dict__
1031-
try:
1032-
with support.catch_unraisable_exception() as ex:
1033-
with support.inject_memory_error_cm(n, n + 1):
1034-
del a
1035-
exc_type = ex.unraisable and ex.unraisable.exc_type
1036-
except MemoryError:
1037-
# The failing allocation was not in the deallocation code.
1038-
continue
1039-
if exc_type is not MemoryError:
1040-
continue
1041-
raised = True
1042-
if "a" not in d:
1043-
# The dictionary was cleared, as expected.
1032+
lst = [A()]
1033+
d = lst[0].__dict__
1034+
with support.catch_unraisable_exception() as ex:
1035+
_testcapi.call_with_nomemory(n, n + 1, lst.clear)
1036+
if ex.unraisable is None:
1037+
continue
1038+
exc_type = ex.unraisable.exc_type
1039+
err_msg = ex.unraisable.err_msg
1040+
seen.append((n, exc_type, err_msg))
1041+
if (exc_type is MemoryError and err_msg ==
1042+
'Exception ignored while clearing an object managed dict'):
1043+
# The dictionary should have been cleared.
1044+
self.assertNotIn("a", d)
10441045
break
10451046
else:
1046-
if not raised:
1047-
self.fail("MemoryError was not raised during deallocation")
1048-
self.fail("the dictionary was not cleared")
1047+
self.fail("MemoryError was not raised while detaching "
1048+
f"the dictionary: {seen}")
10491049

10501050
class DefinitionOrderTests(unittest.TestCase):
10511051
# PEP 520: Preserving Class Attribute Definition Order

‎Modules/_testcapi/mem.c‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,39 @@ remove_mem_hooks(PyObject *self, PyObject *Py_UNUSED(ignored))
210210
Py_RETURN_NONE;
211211
}
212212

213+
static PyObject *
214+
call_with_nomemory(PyObject *self, PyObject *args)
215+
{
216+
/* Call func(*args) with memory allocation failing as in set_nomemory().
217+
* No bytecode is executed between arming the failure and the call. */
218+
Py_ssize_t nargs = PyTuple_GET_SIZE(args);
219+
if (nargs < 3) {
220+
PyErr_SetString(PyExc_TypeError,
221+
"call_with_nomemory() requires at least 3 arguments");
222+
return NULL;
223+
}
224+
int start = PyLong_AsInt(PyTuple_GET_ITEM(args, 0));
225+
if (start == -1 && PyErr_Occurred()) {
226+
return NULL;
227+
}
228+
int stop = PyLong_AsInt(PyTuple_GET_ITEM(args, 1));
229+
if (stop == -1 && PyErr_Occurred()) {
230+
return NULL;
231+
}
232+
PyObject *func = PyTuple_GET_ITEM(args, 2);
233+
/* PyObject_Call() with a prebuilt tuple does not allocate,
234+
* unlike PyObject_Vectorcall() for a callee without vectorcall. */
235+
PyObject *callargs = PyTuple_GetSlice(args, 3, nargs);
236+
if (callargs == NULL) {
237+
return NULL;
238+
}
239+
fm_set_nomemory(start, stop);
240+
PyObject *res = PyObject_Call(func, callargs, NULL);
241+
fm_remove_hooks();
242+
Py_DECREF(callargs);
243+
return res;
244+
}
245+
213246
static PyObject *
214247
test_setallocators(PyMemAllocatorDomain domain)
215248
{
@@ -966,6 +999,8 @@ static PyMethodDef test_methods[] = {
966999
PyDoc_STR("Remove memory hooks.")},
9671000
{"set_nomemory", set_nomemory, METH_VARARGS,
9681001
PyDoc_STR("set_nomemory(start:int, stop:int = 0)")},
1002+
{"call_with_nomemory", call_with_nomemory, METH_VARARGS,
1003+
PyDoc_STR("call_with_nomemory(start:int, stop:int, func, /, *args)")},
9691004
{"test_pymem_alloc0", test_pymem_alloc0, METH_NOARGS},
9701005
{"test_pymem_setallocators", test_pymem_setallocators, METH_NOARGS},
9711006
{"test_pymem_setrawallocators", test_pymem_setrawallocators, METH_NOARGS},

0 commit comments

Comments
 (0)