diff --git a/Lib/test/test_free_threading/test_collections.py b/Lib/test/test_free_threading/test_collections.py new file mode 100644 index 00000000000000..3a413ccf396d4b --- /dev/null +++ b/Lib/test/test_free_threading/test_collections.py @@ -0,0 +1,29 @@ +import unittest +from collections import deque +from copy import copy +from test.support import threading_helper + +threading_helper.requires_working_threading(module=True) + + +class TestDeque(unittest.TestCase): + def test_copy_race(self): + # gh-144809: Test that deque copy is thread safe. It previously + # could raise a "deque mutated during iteration" error. + d = deque(range(100)) + + def mutate(): + for i in range(1000): + d.append(i) + if len(d) > 200: + d.popleft() + + def copy_loop(): + for _ in range(1000): + copy(d) + + threading_helper.run_concurrently([mutate, copy_loop]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Misc/NEWS.d/next/Library/2026-02-18-00-00-00.gh-issue-144809.nYpEUx.rst b/Misc/NEWS.d/next/Library/2026-02-18-00-00-00.gh-issue-144809.nYpEUx.rst new file mode 100644 index 00000000000000..263bd5c2b8bef1 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-02-18-00-00-00.gh-issue-144809.nYpEUx.rst @@ -0,0 +1 @@ +Make :class:`collections.deque` copy atomic in the free-threaded build. diff --git a/Modules/_collectionsmodule.c b/Modules/_collectionsmodule.c index 45ca63e6d7c77f..5c001715725e49 100644 --- a/Modules/_collectionsmodule.c +++ b/Modules/_collectionsmodule.c @@ -1983,6 +1983,22 @@ dequeiter_next(PyObject *op) // It's safe to access it->deque without holding the per-object lock for it // here; it->deque is only assigned during construction of it. dequeobject *deque = it->deque; + +#ifdef Py_GIL_DISABLED + // gh-144809: When called from deque_copy(), the deque is already + // locked. The two-object critical section below would unlock and + // re-lock the deque between calls, allowing another thread to modify + // it mid-iteration. The one-object critical section avoids this + // because it keeps the deque locked across calls when it's already + // held, due to a fast-path optimization. + if (_PyObject_IsUniquelyReferenced((PyObject *)it)) { + Py_BEGIN_CRITICAL_SECTION(deque); + result = dequeiter_next_lock_held(it, deque); + Py_END_CRITICAL_SECTION(); + return result; + } +#endif + Py_BEGIN_CRITICAL_SECTION2(it, deque); result = dequeiter_next_lock_held(it, deque); Py_END_CRITICAL_SECTION2();