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
55 changes: 55 additions & 0 deletions Lib/test/test_free_threading/test_iteration.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import sys
import threading
import unittest
from test import support
from test.support import threading_helper

# The race conditions these tests were written for only happen every now and
# then, even with the current numbers. To find rare race conditions, bumping
Expand Down Expand Up @@ -112,6 +114,59 @@ def worker():
self.assert_iterator_results(results, list(seq))


class ContendedSeqIterExhaustionTest(unittest.TestCase):
"""Test draining a shared iter() fallback iterator (PySeqIter_Type).

Sequences implementing __getitem__ but not __iter__ iterate through
PySeqIter_Type. Unlike the other tests in this file, this uses a
tiny sequence and many rounds so that many threads reach the racy
exhaustion path simultaneously (see gh-156310, where this
use-after-freed the sequence).
"""

class Seq:
def __init__(self, n):
self.n = n

def __getitem__(self, i):
if i >= self.n:
raise IndexError(i)
return i

def test_shared_iterator_exhaustion(self):
nthreads = 8
nrounds = 20 if support.check_sanitizer(thread=True) else 100
seq = self.Seq(4)
expected = set(range(seq.n))
refcount_before = sys.getrefcount(seq)

def drain(it, barrier, results):
items = []
barrier.wait()
for item in it:
items.append(item)
results.extend(items)

for _ in range(nrounds):
it = iter(seq)
barrier = threading.Barrier(nthreads)
results = []
threads = [
threading.Thread(target=drain, args=(it, barrier, results))
for _ in range(nthreads)
]
with threading_helper.start_threads(threads):
pass
del it
# Threads may see duplicate or missing items, but never
# invented ones.
self.assertEqual(set(results) - expected, set())

# A double-DECREF of the sequence does not always crash; it
# reliably shows up as a sagging reference count.
self.assertEqual(sys.getrefcount(seq), refcount_before)


class ContendedRangeIterationTest(ContendedTupleIterationTest):
def make_testdata(self, n):
return range(n)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Fix a use-after-free of the underlying sequence when a single :func:`iter`
fallback iterator for objects implementing :meth:`~object.__getitem__`
without :meth:`~object.__iter__` (``PySeqIter_Type``) was shared between
threads in the free-threaded build. Such iterators are still not
thread-safe in the sense that concurrent iteration may see duplicate or
missing items, but they no longer corrupt the interpreter state.
39 changes: 28 additions & 11 deletions Objects/iterobject.c
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
#include "pycore_ceval.h" // _PyEval_GetBuiltin()
#include "pycore_genobject.h" // _PyCoro_GetAwaitableIter()
#include "pycore_object.h" // _PyObject_GC_TRACK()
#include "pycore_pyatomic_ft_wrappers.h" // FT_ATOMIC_LOAD_SSIZE_RELAXED()


typedef struct {
PyObject_HEAD
Py_ssize_t it_index;
PyObject *it_seq; /* Set to NULL when iterator is exhausted */
Py_ssize_t it_index; /* -1 when iterator is exhausted */
PyObject *it_seq; /* Set to NULL when iterator is exhausted
(in the default build) */
} seqiterobject;

PyObject *
Expand Down Expand Up @@ -58,26 +60,34 @@ iter_iternext(PyObject *iterator)

assert(PySeqIter_Check(iterator));
it = (seqiterobject *)iterator;
Py_ssize_t index = FT_ATOMIC_LOAD_SSIZE_RELAXED(it->it_index);
if (index < 0)
return NULL;
seq = it->it_seq;
#ifndef Py_GIL_DISABLED
if (seq == NULL)
return NULL;
if (it->it_index == PY_SSIZE_T_MAX) {
#endif
if (index == PY_SSIZE_T_MAX) {
PyErr_SetString(PyExc_OverflowError,
"iter index too large");
return NULL;
}

result = PySequence_GetItem(seq, it->it_index);
result = PySequence_GetItem(seq, index);
if (result != NULL) {
it->it_index++;
FT_ATOMIC_STORE_SSIZE_RELAXED(it->it_index, index + 1);
return result;
}
if (PyErr_ExceptionMatches(PyExc_IndexError) ||
PyErr_ExceptionMatches(PyExc_StopIteration))
{
PyErr_Clear();
FT_ATOMIC_STORE_SSIZE_RELAXED(it->it_index, -1);
#ifndef Py_GIL_DISABLED
it->it_seq = NULL;
Py_DECREF(seq);
#endif
}
return NULL;
}
Expand All @@ -88,7 +98,8 @@ iter_len(PyObject *op, PyObject *Py_UNUSED(ignored))
seqiterobject *it = (seqiterobject*)op;
Py_ssize_t seqsize, len;

if (it->it_seq) {
Py_ssize_t index = FT_ATOMIC_LOAD_SSIZE_RELAXED(it->it_index);
if (index >= 0 && it->it_seq != NULL) {
if (_PyObject_HasLen(it->it_seq)) {
seqsize = PySequence_Size(it->it_seq);
if (seqsize == -1)
Expand All @@ -97,7 +108,7 @@ iter_len(PyObject *op, PyObject *Py_UNUSED(ignored))
else {
Py_RETURN_NOTIMPLEMENTED;
}
len = seqsize - it->it_index;
len = seqsize - index;
if (len >= 0)
return PyLong_FromSsize_t(len);
}
Expand All @@ -116,8 +127,9 @@ iter_reduce(PyObject *op, PyObject *Py_UNUSED(ignored))
* call must be before access of iterator pointers.
* see issue #101765 */

if (it->it_seq != NULL)
return Py_BuildValue("N(O)n", iter, it->it_seq, it->it_index);
Py_ssize_t index = FT_ATOMIC_LOAD_SSIZE_RELAXED(it->it_index);
if (index >= 0 && it->it_seq != NULL)
return Py_BuildValue("N(O)n", iter, it->it_seq, index);
else
return Py_BuildValue("N(())", iter);
}
Expand All @@ -131,10 +143,15 @@ iter_setstate(PyObject *op, PyObject *state)
Py_ssize_t index = PyLong_AsSsize_t(state);
if (index == -1 && PyErr_Occurred())
return NULL;
if (it->it_seq != NULL) {
/* An exhausted iterator keeps its reference to the sequence in the
* free-threaded build, but must not be revived, matching the
* default build where the reference is already gone. See gh-120971. */
if (it->it_seq != NULL
&& FT_ATOMIC_LOAD_SSIZE_RELAXED(it->it_index) >= 0)
{
if (index < 0)
index = 0;
it->it_index = index;
FT_ATOMIC_STORE_SSIZE_RELAXED(it->it_index, index);
}
Py_RETURN_NONE;
}
Expand Down
Loading