Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 20 additions & 14 deletions Lib/test/test_xml_etree.py
Original file line number Diff line number Diff line change
Expand Up @@ -2816,15 +2816,15 @@ def test_remove_with_clear_assume_missing(self):

def test_remove_with_clear_assume_existing(self):
# gh-126033: Check that a concurrent clear() for an assumed-to-be
# existing element does not make the interpreter crash.
# existing element raises RuntimeError but does not crash.
self.do_test_remove_with_clear(raises=False)

def do_test_remove_with_clear(self, *, raises):

# Until the discrepency between "del root[:]" and "root.clear()" is
# resolved, we need to keep two tests. Previously, using "del root[:]"
# did not crash with the reproducer of gh-126033 while "root.clear()"
# did.
# 'del root[:]' mutates the children list in-place, while
# 'root.clear()' replaces self._children with a new list. When
# raises=False (element "found"), the in-place mutation is detected
# by list.remove and raises RuntimeError, whereas root.clear() is
# invisible to list.remove (the old list is unchanged).

class E(ET.Element):
"""Local class to be able to mock E.__eq__ for introspection."""
Expand All @@ -2839,16 +2839,23 @@ def __eq__(self, o):
root.clear()
return not raises

if raises:
get_checker_context = lambda: self.assertRaises(ValueError)
else:
get_checker_context = nullcontext

self.assertIs(E.__eq__, object.__eq__)

get_checker_context = nullcontext
for Z, side_effect in [(X, 'del root[:]'), (Y, 'root.clear()')]:
self.enterContext(self.subTest(side_effect=side_effect))

# X uses 'del root[:]' which mutates the list in-place; this is
# detected by list.remove when raises=False (element "found").
# Y uses 'root.clear()' which swaps out self._children; the old
# list that list.remove is iterating is untouched, so no error.
if raises:
get_checker_context = lambda: self.assertRaises(ValueError)
elif Z is X:
get_checker_context = lambda: self.assertRaises(RuntimeError)
else:
get_checker_context = nullcontext

# test removing R() from [U()]
for R, U, description in [
(E, Z, "remove missing E() from [Z()]"),
Expand Down Expand Up @@ -2883,7 +2890,6 @@ def __eq__(self, o):
g.assert_not_called()

# Test removing root[1] (of type R) from [U(), R()].
is_special = is_python_implementation() and raises and Z is Y
if is_python_implementation() and raises and Z is Y:
# In pure Python, using root.clear() sets the children
# list to [] without calling list.clear().
Expand Down Expand Up @@ -2913,7 +2919,7 @@ def test_remove_with_mutate_root_assume_missing(self):

def test_remove_with_mutate_root_assume_existing(self):
# gh-126033: Check that a concurrent mutation for an assumed-to-be
# existing element does not make the interpreter crash.
# existing element raises RuntimeError.
self.do_test_remove_with_mutate_root(raises=False)

def do_test_remove_with_mutate_root(self, *, raises):
Expand All @@ -2927,7 +2933,7 @@ def __eq__(self, o):
if raises:
get_checker_context = lambda: self.assertRaises(ValueError)
else:
get_checker_context = nullcontext
get_checker_context = lambda: self.assertRaises(RuntimeError)

# test removing R() from [U(), V()]
cases = self.cases_for_remove_missing_with_mutations(E, Z)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
:meth:`list.remove` now raises :exc:`RuntimeError` if the list is mutated
during the ``__eq__`` rich comparison and a matching element was found. This
prevents silent data corruption where the wrong element could be removed from
a list under concurrent mutation.
5 changes: 5 additions & 0 deletions Objects/listobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -3390,6 +3390,11 @@ list_remove_impl(PyListObject *self, PyObject *value)
int cmp = PyObject_RichCompareBool(obj, value, Py_EQ);
Py_DECREF(obj);
if (cmp > 0) {
if (i >= Py_SIZE(self) || self->ob_item[i] != obj) {
PyErr_SetString(PyExc_RuntimeError,
"list mutated during remove");
return NULL;
}
if (list_ass_slice_lock_held(self, i, i+1, NULL) == 0)
Py_RETURN_NONE;
return NULL;
Expand Down
Loading