From 47d75fee96b929343e8713e98e3ce9400b34a15c Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Sat, 31 Jan 2026 10:51:16 +0800 Subject: [PATCH 1/5] Fix data race in setiter_len() under no-gil setiter_len() was reading so->used without atomic access while concurrent mutations update it atomically under Py_GIL_DISABLED. Use an atomic load for so->used to avoid a data race. This preserves the existing semantics of __length_hint__ while making the access thread-safe. Signed-off-by: Yongtao Huang --- Objects/setobject.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Objects/setobject.c b/Objects/setobject.c index 5d4d1812282eed..85001c980b6f29 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1056,7 +1056,8 @@ 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) + PySetObject *so = si->si_set; + if (so != NULL && si->si_used == FT_ATOMIC_LOAD_SSIZE_RELAXED(so->used)) len = si->len; return PyLong_FromSsize_t(len); } From 3e3785cb6cde129bb19b6db8278bae4351f45be9 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Sat, 31 Jan 2026 11:33:41 +0800 Subject: [PATCH 2/5] Add test case --- Lib/test/test_free_threading/test_set.py | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 9dd3d68d5dad13..1d5e09cb4089d4 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -148,6 +148,32 @@ def read_set(): for t in threads: t.join() + def test_iter_length_hint_mutate(self): + s = set(range(2000)) + it = iter(s) + stop = Event() + + def reader(): + while not stop.is_set(): + it.__length_hint__() + + def writer(): + i = 0 + while not stop.is_set(): + s.add(i) + s.discard(i - 1) + i += 1 + + threads = [Thread(target=reader) for _ in range(4)] + threads.append(Thread(target=writer)) + + for t in threads: + t.start() + + stop.set() + + for t in threads: + t.join() @threading_helper.requires_working_threading() class SmallSetTest(RaceTestBase, unittest.TestCase): From 229ced3b1c5be4e1cbeb4db70b7a5dd8645df955 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 03:40:30 +0000 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-01-31-03-40-28.gh-issue-144356.otfq_X.rst 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``. From cdcf88ad474f675f8bc4fbc6956aca71e4667fba Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Sun, 1 Feb 2026 23:25:26 +0800 Subject: [PATCH 4/5] Resovle comments --- Lib/test/test_free_threading/test_set.py | 52 +++++++++++++++++++----- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 1d5e09cb4089d4..5d8abe72d607c2 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -148,32 +148,62 @@ def read_set(): for t in threads: t.join() - def test_iter_length_hint_mutate(self): + def test_length_hint_used_race(self): s = set(range(2000)) it = iter(s) - stop = Event() + + NUM_LOOPS = 50_000 + barrier = Barrier(2) def reader(): - while not stop.is_set(): + barrier.wait() + for _ in range(NUM_LOOPS): it.__length_hint__() def writer(): + barrier.wait() i = 0 - while not stop.is_set(): + for _ in range(NUM_LOOPS): s.add(i) s.discard(i - 1) i += 1 - threads = [Thread(target=reader) for _ in range(4)] - threads.append(Thread(target=writer)) + t1 = Thread(target=reader) + t2 = Thread(target=writer) + t1.start(); t2.start() + t1.join(); t2.join() - for t in threads: - t.start() + def test_length_hint_exhaust_race(self): + NUM_LOOPS = 10_000 + INNER_HINTS = 20 + barrier = Barrier(2) + box = {"it": None} - stop.set() + 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() - for t in threads: - t.join() @threading_helper.requires_working_threading() class SmallSetTest(RaceTestBase, unittest.TestCase): From 21f1478423e09eee0ea7c082d46fe5d6291e2cc3 Mon Sep 17 00:00:00 2001 From: Yongtao Huang Date: Sun, 1 Feb 2026 23:26:08 +0800 Subject: [PATCH 5/5] Update test case: used_race and exhaust_race --- Objects/setobject.c | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/Objects/setobject.c b/Objects/setobject.c index 85001c980b6f29..d0291b98ebfb72 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1056,9 +1056,23 @@ setiter_len(PyObject *op, PyObject *Py_UNUSED(ignored)) { setiterobject *si = (setiterobject*)op; Py_ssize_t len = 0; - PySetObject *so = si->si_set; - if (so != NULL && si->si_used == FT_ATOMIC_LOAD_SSIZE_RELAXED(so->used)) +#ifdef Py_GIL_DISABLED + PyObject *so_obj = FT_ATOMIC_LOAD_PTR_ACQUIRE(si->si_set); + if (so_obj != NULL) { + /* Turn borrowed si->si_set into a strong ref safely. */ + if (_Py_TryIncrefCompare((PyObject **)&si->si_set, so_obj)) { + PySetObject *so = (PySetObject *)so_obj; + if (si->si_used == FT_ATOMIC_LOAD_SSIZE_RELAXED(so->used)) { + len = si->len; + } + Py_DECREF(so_obj); + } + } +#else + if (si->si_set != NULL && si->si_used == si->si_set->used) { len = si->len; + } +#endif return PyLong_FromSsize_t(len); } @@ -1125,7 +1139,11 @@ static PyObject *setiter_iternext(PyObject *self) Py_END_CRITICAL_SECTION(); si->si_pos = i+1; if (key == NULL) { +#ifdef Py_GIL_DISABLED + FT_ATOMIC_STORE_PTR_RELEASE(si->si_set, NULL); +#else si->si_set = NULL; +#endif Py_DECREF(so); return NULL; }