From 247d38669d0abc815d13817c47b6bcb58bb127b8 Mon Sep 17 00:00:00 2001 From: Thomas Kowalski Date: Tue, 2 Jun 2026 16:26:37 +0200 Subject: [PATCH 1/3] misc: add news entry --- .../next/Library/2026-06-02-14-22-05.gh-issue-150791.aQ7rNp.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2026-06-02-14-22-05.gh-issue-150791.aQ7rNp.rst diff --git a/Misc/NEWS.d/next/Library/2026-06-02-14-22-05.gh-issue-150791.aQ7rNp.rst b/Misc/NEWS.d/next/Library/2026-06-02-14-22-05.gh-issue-150791.aQ7rNp.rst new file mode 100644 index 00000000000000..b69c3d221bf530 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-02-14-22-05.gh-issue-150791.aQ7rNp.rst @@ -0,0 +1 @@ +Fix a data race in :func:`itertools.groupby` on free-threaded builds where concurrent calls to :func:`next` could corrupt the iterator's internal state. From 54fc409769da00a42522628bda1c7521414a7a3b Mon Sep 17 00:00:00 2001 From: Thomas Kowalski Date: Tue, 2 Jun 2026 16:27:13 +0200 Subject: [PATCH 2/3] test: add regression test --- Lib/test/test_itertools.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/Lib/test/test_itertools.py b/Lib/test/test_itertools.py index cf579d4da4e0df..1c3cc96ac48a64 100644 --- a/Lib/test/test_itertools.py +++ b/Lib/test/test_itertools.py @@ -786,6 +786,43 @@ def keyfunc(element): items = list(grouper_iter) self.assertEqual(len(items), 1) + @threading_helper.requires_working_threading() + def test_groupby_concurrent_next_does_not_crash(self): + # regression test for gh-150791 + # Concurrent next calls on a shared groupby object should + # not race / corrupt state. + class K: + __slots__ = ("v",) + def __init__(self, v): + self.v = v + def __eq__(self, other): + if not isinstance(other, K): + return NotImplemented + return self.v == other.v + def __hash__(self): + return hash(self.v) + + keys = [K(i) for i in range(5_000)] + g = itertools.groupby(keys) + errors = [] + + def consume(): + try: + while True: + _, _ = next(g) + except StopIteration: + pass + except Exception as e: + errors.append(e) + + threads = [threading.Thread(target=consume) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + self.assertEqual(errors, []) # must pass with ThreadSanitizer + def test_filter(self): self.assertEqual(list(filter(isEven, range(6))), [0,2,4]) self.assertEqual(list(filter(None, [0,1,0,2,0])), [1,2]) From ba8c0cd0f064bc4bbe294a6a34faa5117d2e37e9 Mon Sep 17 00:00:00 2001 From: Thomas Kowalski Date: Tue, 2 Jun 2026 16:27:59 +0200 Subject: [PATCH 3/3] fix: add critical section for grouper_next --- Modules/itertoolsmodule.c | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/Modules/itertoolsmodule.c b/Modules/itertoolsmodule.c index 68ac810eaad237..a21466a892c593 100644 --- a/Modules/itertoolsmodule.c +++ b/Modules/itertoolsmodule.c @@ -529,7 +529,7 @@ groupby_step(groupbyobject *gbo) } static PyObject * -groupby_next(PyObject *op) +groupby_next_lock_held(PyObject *op) { PyObject *grouper; groupbyobject *gbo = groupbyobject_CAST(op); @@ -574,6 +574,16 @@ groupby_next(PyObject *op) return _PyTuple_FromPairSteal(Py_NewRef(gbo->currkey), grouper); } +static PyObject * +groupby_next(PyObject *op) +{ + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(op); + result = groupby_next_lock_held(op); + Py_END_CRITICAL_SECTION() + return result; +} + static PyType_Slot groupby_slots[] = { {Py_tp_dealloc, groupby_dealloc}, {Py_tp_getattro, PyObject_GenericGetAttr}, @@ -659,7 +669,7 @@ _grouper_traverse(PyObject *op, visitproc visit, void *arg) } static PyObject * -_grouper_next(PyObject *op) +_grouper_next_lock_held(PyObject *op) { _grouperobject *igo = _grouperobject_CAST(op); groupbyobject *gbo = groupbyobject_CAST(igo->parent); @@ -695,6 +705,16 @@ _grouper_next(PyObject *op) return r; } +static PyObject * +_grouper_next(PyObject *op) +{ + PyObject *result; + Py_BEGIN_CRITICAL_SECTION(_grouperobject_CAST(op)->parent); + result = _grouper_next_lock_held(op); + Py_END_CRITICAL_SECTION() + return result; +} + static PyType_Slot _grouper_slots[] = { {Py_tp_dealloc, _grouper_dealloc}, {Py_tp_getattro, PyObject_GenericGetAttr},