diff --git a/Lib/test/test_free_threading/test_iteration.py b/Lib/test/test_free_threading/test_iteration.py index 44d3e9ccfdd14e0..699d4014c499f71 100644 --- a/Lib/test/test_free_threading/test_iteration.py +++ b/Lib/test/test_free_threading/test_iteration.py @@ -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 @@ -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) diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-08-24-12-00-00.gh-issue-156310.pSqIter.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-24-12-00-00.gh-issue-156310.pSqIter.rst new file mode 100644 index 000000000000000..17486f079f7bfbf --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-08-24-12-00-00.gh-issue-156310.pSqIter.rst @@ -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. diff --git a/Objects/iterobject.c b/Objects/iterobject.c index e323987601d5d4c..7987f3eb2d5bf45 100644 --- a/Objects/iterobject.c +++ b/Objects/iterobject.c @@ -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 * @@ -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; } @@ -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) @@ -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); } @@ -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); } @@ -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; }