From a7f833208cdf3bbdee003cf40613a1d9b1de685a Mon Sep 17 00:00:00 2001 From: Sivakorn Lerttripinyo Date: Sat, 3 Oct 2026 19:55:10 +0200 Subject: [PATCH 1/5] gh-158600: Fix lost updates in concurrent set.intersection_update() Calculate the intersection from a copy while holding the target set's critical section. This prevents concurrent intersection_update() calls from overwriting one another's results. Add free-threading tests for one-operand and multi-operand calls, with __iand__() as a control. --- Lib/test/test_free_threading/test_set.py | 92 ++++++++++++++++++++++++ Objects/setobject.c | 22 ++++-- 2 files changed, 108 insertions(+), 6 deletions(-) diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 9dd3d68d5dad135..8264824ba176aa0 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -148,6 +148,98 @@ def read_set(): for t in threads: t.join() + def test_intersection_update_concurrent(self): + """Test one-operand intersection updates of one shared set.""" + NUM_ITERS = 10 + BLOCK_SIZE = self.SET_SIZE * 100 + + sources = [ + set(range(4 * BLOCK_SIZE)), + set(range(3 * BLOCK_SIZE)), + set(range(2 * BLOCK_SIZE)), + set(range(BLOCK_SIZE)), + ] + expected = set(range(BLOCK_SIZE)) + + for _ in range(NUM_ITERS): + target = set(range(5 * BLOCK_SIZE)) + barrier = Barrier(len(sources), timeout=2) + + def intersect(source): + barrier.wait() + target.intersection_update(source) + + threads = [Thread(target=intersect, args=(source,)) + for source in sources] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(target, expected) + + def test_intersection_update_multiple_concurrent(self): + """Test multi-operand intersection updates of one shared set.""" + NUM_ITERS = 10 + BLOCK_SIZE = self.SET_SIZE * 100 + + evens = set(range(0, 4 * BLOCK_SIZE, 2)) + below_three_blocks = set(range(3 * BLOCK_SIZE)) + multiples_of_three = set(range(0, 2 * BLOCK_SIZE, 3)) + below_one_block = set(range(BLOCK_SIZE)) + expected = set(range(0, BLOCK_SIZE, 6)) + + for _ in range(NUM_ITERS): + target = set(range(5 * BLOCK_SIZE)) + barrier = Barrier(2, timeout=2) + + def intersect(first, second): + barrier.wait() + target.intersection_update(first, second) + + threads = [ + Thread(target=intersect, + args=(evens, below_three_blocks)), + Thread(target=intersect, + args=(multiples_of_three, below_one_block)), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(target, expected) + + def test_iand_concurrent(self): + """Test concurrent &= operations on one shared set.""" + NUM_ITERS = 10 + BLOCK_SIZE = self.SET_SIZE * 100 + + sources = [ + set(range(4 * BLOCK_SIZE)), + set(range(3 * BLOCK_SIZE)), + set(range(2 * BLOCK_SIZE)), + set(range(BLOCK_SIZE)), + ] + expected = set(range(BLOCK_SIZE)) + + for _ in range(NUM_ITERS): + target = set(range(5 * BLOCK_SIZE)) + barrier = Barrier(len(sources), timeout=2) + + def intersect(source): + barrier.wait() + target.__iand__(source) + + threads = [Thread(target=intersect, args=(source,)) + for source in sources] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(target, expected) + @threading_helper.requires_working_threading() class SmallSetTest(RaceTestBase, unittest.TestCase): diff --git a/Objects/setobject.c b/Objects/setobject.c index 8fdd1eb26118c0a..89109818847df31 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1861,15 +1861,25 @@ set_intersection_update_multi_impl(PySetObject *so, PyObject * const *others, Py_ssize_t others_length) /*[clinic end generated code: output=d768b5584675b48d input=782e422fc370e4fc]*/ { - PyObject *tmp; + PyObject *copy; + PyObject *result = NULL; - tmp = set_intersection_multi_impl(so, others, others_length); - if (tmp == NULL) - return NULL; Py_BEGIN_CRITICAL_SECTION(so); - set_swap_bodies(so, (PySetObject *)tmp); + copy = set_copy_untracked_lock_held(so); + if (copy != NULL) { + result = set_intersection_multi_impl((PySetObject *)copy, + others, others_length); + Py_DECREF(copy); + if (result != NULL) { + set_swap_bodies(so, (PySetObject *)result); + } + } Py_END_CRITICAL_SECTION(); - Py_DECREF(tmp); + + if (result == NULL) { + return NULL; + } + Py_DECREF(result); Py_RETURN_NONE; } From a644af9c572a0a6474a7b68030e98cde1ea243c7 Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:48:44 +0000 Subject: [PATCH 2/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-10-03-18-48-41.gh-issue-158600.RZt2lC.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-10-03-18-48-41.gh-issue-158600.RZt2lC.rst diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-03-18-48-41.gh-issue-158600.RZt2lC.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-03-18-48-41.gh-issue-158600.RZt2lC.rst new file mode 100644 index 000000000000000..e8d58f88deb2d79 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-03-18-48-41.gh-issue-158600.RZt2lC.rst @@ -0,0 +1 @@ +Fix a race condition in :meth:`set.intersection_update` on free-threaded builds that could cause concurrent updates to the same set to be lost. From 19264badb962c9fa65cc55942b133b10618a3706 Mon Sep 17 00:00:00 2001 From: Sivakorn Lerttripinyo Date: Sun, 4 Oct 2026 09:29:41 +0200 Subject: [PATCH 3/5] gh-158600: Avoid copying in set.intersection_update() Calculate all intersections before swapping the final result into the target. Keep the calculation and swap within the same critical section so concurrent updates are not lost. This keeps the existing unchanged-on-error behavior while removing the O(len(self)) initial copy. --- Objects/setobject.c | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/Objects/setobject.c b/Objects/setobject.c index 89109818847df31..a5bfde055599629 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1861,20 +1861,36 @@ set_intersection_update_multi_impl(PySetObject *so, PyObject * const *others, Py_ssize_t others_length) /*[clinic end generated code: output=d768b5584675b48d input=782e422fc370e4fc]*/ { - PyObject *copy; - PyObject *result = NULL; + Py_ssize_t i; + PyObject *other; + PyObject *result; - Py_BEGIN_CRITICAL_SECTION(so); - copy = set_copy_untracked_lock_held(so); - if (copy != NULL) { - result = set_intersection_multi_impl((PySetObject *)copy, - others, others_length); - Py_DECREF(copy); + if (others_length == 0) { + Py_RETURN_NONE; + } + + other = others[0]; + Py_BEGIN_CRITICAL_SECTION2(so, other); + result = set_intersection(so, other); + if (result != NULL) { + for (i = 1; i < others_length; i++) { + PyObject *newresult; + + other = others[i]; + Py_BEGIN_CRITICAL_SECTION(other); + newresult = set_intersection((PySetObject *)result, other); + Py_END_CRITICAL_SECTION(); + if (newresult == NULL) { + Py_CLEAR(result); + break; + } + Py_SETREF(result, newresult); + } if (result != NULL) { set_swap_bodies(so, (PySetObject *)result); } } - Py_END_CRITICAL_SECTION(); + Py_END_CRITICAL_SECTION2(); if (result == NULL) { return NULL; From 0e11b6f8e48a6d888a96291de3fe1f7f34b71c44 Mon Sep 17 00:00:00 2001 From: Sivakorn Lerttripinyo Date: Mon, 5 Oct 2026 19:40:43 +0200 Subject: [PATCH 4/5] gh-158600: Preserve concurrent removals for several operands in set.intersection_update() --- Lib/test/test_free_threading/test_set.py | 43 ++++++++++++++++++++++++ Objects/setobject.c | 14 +++++++- 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 8264824ba176aa0..8c10600836b7e82 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -210,6 +210,49 @@ def intersect(first, second): self.assertEqual(target, expected) + def test_intersection_update_suspended_lock(self): + """Test an update while a later operand's lock is held.""" + NUM_ITERS = 200 + BLOCK_SIZE = self.SET_SIZE * 1_000 + HOLD_WORK = 10_000 + + initial = set(range(BLOCK_SIZE)) + blocked_operand = set(initial) + evens = set(range(0, BLOCK_SIZE, 2)) + threes = set(range(0, BLOCK_SIZE, 3)) + expected = set(range(0, BLOCK_SIZE, 6)) + + class SlowEmpty: + def __iter__(self): + for _ in range(HOLD_WORK): + pass + return iter(()) + + # Repeat because the lock suspension depends on thread scheduling. + for _ in range(NUM_ITERS): + target = set(initial) + + def hold_operand(): + blocked_operand.intersection(SlowEmpty()) + + def first_update(): + target.intersection_update(evens, blocked_operand) + + def second_update(): + target.intersection_update(threes) + + threads = [ + Thread(target=hold_operand), + Thread(target=first_update), + Thread(target=second_update), + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(target, expected) + def test_iand_concurrent(self): """Test concurrent &= operations on one shared set.""" NUM_ITERS = 10 diff --git a/Objects/setobject.c b/Objects/setobject.c index a5bfde055599629..3e8b35e042e15f1 100644 --- a/Objects/setobject.c +++ b/Objects/setobject.c @@ -1887,7 +1887,19 @@ set_intersection_update_multi_impl(PySetObject *so, PyObject * const *others, Py_SETREF(result, newresult); } if (result != NULL) { - set_swap_bodies(so, (PySetObject *)result); + if (others_length == 1) { + set_swap_bodies(so, (PySetObject *)result); + } + else { + /* A later operand's critical section may have suspended + so's lock. Preserve concurrent removals by intersecting + the result with so's current contents. */ + PyObject *updated = set_intersection_update(so, result); + if (updated == NULL) { + Py_CLEAR(result); + } + Py_XDECREF(updated); + } } } Py_END_CRITICAL_SECTION2(); From 2c1bb2a137683c6e21668e3905e211f28a5337e3 Mon Sep 17 00:00:00 2001 From: Sivakorn Lerttripinyo Date: Mon, 5 Oct 2026 23:02:12 +0200 Subject: [PATCH 5/5] add a test to test several operands on 4 threads --- Lib/test/test_free_threading/test_set.py | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/Lib/test/test_free_threading/test_set.py b/Lib/test/test_free_threading/test_set.py index 8c10600836b7e82..c3e11dbbb8e1d7f 100644 --- a/Lib/test/test_free_threading/test_set.py +++ b/Lib/test/test_free_threading/test_set.py @@ -210,6 +210,52 @@ def intersect(first, second): self.assertEqual(target, expected) + def test_intersection_update_three_operands_concurrent(self): + """Test three-operand intersection updates of one shared set.""" + NUM_ITERS = 10 + BLOCK_SIZE = self.SET_SIZE * 100 + + updates = [ + ( + set(range(0, 10 * BLOCK_SIZE, 2)), + set(range(0, 9 * BLOCK_SIZE, 3)), + set(range(0, 8 * BLOCK_SIZE, 5)), + ), + ( + set(range(0, 6 * BLOCK_SIZE, 5)), + set(range(0, 5 * BLOCK_SIZE, 2)), + set(range(0, 4 * BLOCK_SIZE)), + ), + ( + set(range(0, 3 * BLOCK_SIZE, 3)), + set(range(0, 2 * BLOCK_SIZE, 5)), + set(range(0, BLOCK_SIZE)), + ), + ( + set(range(0, 9 * BLOCK_SIZE, 2)), + set(range(0, 8 * BLOCK_SIZE, 3)), + set(range(0, 7 * BLOCK_SIZE)), + ), + ] + expected = set(range(0, BLOCK_SIZE, 30)) + + for _ in range(NUM_ITERS): + target = set(range(10 * BLOCK_SIZE)) + barrier = Barrier(len(updates), timeout=2) + + def intersect(first, second, third): + barrier.wait() + target.intersection_update(first, second, third) + + threads = [Thread(target=intersect, args=operands) + for operands in updates] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + self.assertEqual(target, expected) + def test_intersection_update_suspended_lock(self): """Test an update while a later operand's lock is held.""" NUM_ITERS = 200