diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 9dd3d68d5dad13..5054bf78fb7289 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -148,6 +148,91 @@ def read_set(): for t in threads: t.join() + def test_length_hint_used_race(self): + s = set(range(2000)) + it = iter(s) + + NUM_LOOPS = 50_000 + barrier = Barrier(2) + + def reader(): + barrier.wait() + for _ in range(NUM_LOOPS): + it.__length_hint__() + + def writer(): + barrier.wait() + i = 0 + for _ in range(NUM_LOOPS): + s.add(i) + s.discard(i - 1) + i += 1 + + t1 = Thread(target=reader) + t2 = Thread(target=writer) + t1.start(); t2.start() + t1.join(); t2.join() + + def test_length_hint_exhaust_race(self): + NUM_LOOPS = 10_000 + INNER_HINTS = 20 + barrier = Barrier(2) + box = {"it": None} + + def exhauster(): + for _ in range(NUM_LOOPS): + s = set(range(256)) + box["it"] = iter(s) + barrier.wait() # start together + try: + while True: + next(box["it"]) + except StopIteration: + pass + barrier.wait() # end iteration + + def reader(): + for _ in range(NUM_LOOPS): + barrier.wait() + it = box["it"] + for _ in range(INNER_HINTS): + it.__length_hint__() + barrier.wait() + + t1 = Thread(target=reader) + t2 = Thread(target=exhauster) + t1.start(); t2.start() + t1.join(); t2.join() + + def test_iternext_concurrent_exhaust_race(self): + NUM_LOOPS = 20_000 + barrier = Barrier(3) + box = {"it": None} + + def advancer(): + for _ in range(NUM_LOOPS): + barrier.wait() + it = box["it"] + while True: + try: + next(it) + except StopIteration: + break + barrier.wait() + + def producer(): + for _ in range(NUM_LOOPS): + s = set(range(64)) + box["it"] = iter(s) + barrier.wait() + barrier.wait() + + t1 = Thread(target=advancer) + t2 = Thread(target=advancer) + t3 = Thread(target=producer) + t1.start(); t2.start(); t3.start() + t1.join(); t2.join(); t3.join() + @threading_helper.requires_working_threading() class SmallSetTest(RaceTestBase, unittest.TestCase): diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst new file mode 100644 index 00000000000000..d5d67ad1e8dbb3 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst @@ -0,0 +1 @@ +Fix a data race in ``set_iterator.__length_hint__`` under ``Py_GIL_DISABLED``. diff --git a/Objects/setobject.c b/Objects/setobject.c index 5d4d1812282eed..29125a5b2a1f26 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1056,8 +1056,23 @@ setiter_len(PyObject *op, PyObject *Py_UNUSED(ignored)) { setiterobject *si = (setiterobject*)op; Py_ssize_t len = 0; - if (si->si_set != NULL && si->si_used == si->si_set->used) +#ifdef Py_GIL_DISABLED + PySetObject *so = si->si_set; + if (so != NULL) { + Py_BEGIN_CRITICAL_SECTION(so); + Py_ssize_t pos = FT_ATOMIC_LOAD_SSIZE_RELAXED(si->si_pos); + if (pos >= 0 && + si->si_used == FT_ATOMIC_LOAD_SSIZE_RELAXED(so->used)) + { + len = si->len; + } + Py_END_CRITICAL_SECTION(); + } +#else + if (si->si_set != NULL && si->si_used == si->si_set->used) { len = si->len; + } +#endif return PyLong_FromSsize_t(len); } @@ -1096,6 +1111,7 @@ static PyObject *setiter_iternext(PyObject *self) Py_ssize_t i, mask; setentry *entry; PySetObject *so = si->si_set; + int exhausted = 0; if (so == NULL) return NULL; @@ -1111,24 +1127,59 @@ static PyObject *setiter_iternext(PyObject *self) } Py_BEGIN_CRITICAL_SECTION(so); +#ifdef Py_GIL_DISABLED + /* si_pos may be read outside the lock; keep it atomic in FT builds */ + i = FT_ATOMIC_LOAD_SSIZE_RELAXED(si->si_pos); + if (i < 0) { + /* iterator already exhausted */ + goto done; + } +#else i = si->si_pos; - assert(i>=0); - entry = so->table; - mask = so->mask; - while (i <= mask && (entry[i].key == NULL || entry[i].key == dummy)) { - i++; + if (i < 0) { + /* iterator already exhausted */ + exhausted = 1; } - if (i <= mask) { - key = Py_NewRef(entry[i].key); +#endif + + if (!exhausted) { + assert(i >= 0); + entry = so->table; + mask = so->mask; + while (i <= mask && (entry[i].key == NULL || entry[i].key == dummy)) { + i++; + } + if (i <= mask) { + key = Py_NewRef(entry[i].key); +#ifdef Py_GIL_DISABLED + FT_ATOMIC_STORE_SSIZE_RELAXED(si->si_pos, i + 1); +#else + si->si_pos = i + 1; +#endif + si->len--; + } + else { +#ifdef Py_GIL_DISABLED + /* free-threaded: keep si_set; just mark exhausted */ + FT_ATOMIC_STORE_SSIZE_RELAXED(si->si_pos, -1); + si->len = 0; +#else + si->si_set = NULL; +#endif + } } + +#ifdef Py_GIL_DISABLED +done: +#endif Py_END_CRITICAL_SECTION(); - si->si_pos = i+1; + if (key == NULL) { - si->si_set = NULL; +#ifndef Py_GIL_DISABLED Py_DECREF(so); +#endif return NULL; } - si->len--; return key; }