Skip to content

gh-160573: Drop the set once when a shared iterator is exhausted - #160619

Open
lumenyr wants to merge 1 commit into
python:mainfrom
lumenyr:gh-160573-setiter-release
Open

lumenyr wants to merge 1 commit into
python:mainfrom
lumenyr:gh-160573-setiter-release

Conversation

@lumenyr

@lumenyr lumenyr commented Oct 24, 2026 •

Copy link
Copy Markdown

A set iterator shared between threads over-releases its set when it is exhausted.

setiter_iternext() reads si->si_set with no lock held, returns early only when it is already NULL, and clears the field plus Py_DECREF(so) after Py_END_CRITICAL_SECTION(). Threads that arrive on the exhausted branch together all pass the NULL check, so each of them drops the iterator's single reference.

Measured on a free-threaded debug build (--disable-gil --with-assertions, gcc 15.2), with eight threads exhausting one iterator over a 300,000-slot set:

  • before: sys.getrefcount(s) goes 1002 to 995, seven extra decrefs, then validate_gc_objects() aborts at finalization with "refcount is too small", exit 134.
  • with this branch: 1002 to 1002, exit 0.

ThreadSanitizer reported the race on the unpatched tree and is clean with the change, on the same reproducer.

The change holds the iterator's own critical section across the size check, the table walk, the clear, si_pos, si_used and len, and stores NULL with FT_ATOMIC_STORE_PTR_RELAXED, which is what the lock-free fast path reads. The iterator's reference is dropped once, after the lock has been released. On a build with the GIL the critical section compiles away, so behaviour there is unchanged.

I kept the table walk inside the set's critical section as it is rather than moving to the _Py_XGetRef plus exchange shape discussed in gh-154130, because gh-124397 asks for the minimal change that stops the crash and this keeps the single release easy to see. I am happy to rework it to the other shape if that is the direction you want for the iterator family.

New test in Lib/test/test_free_threading/test_set.py: TestSetIteratorExhaustion. It aborts with _Py_NegativeRefcount on unpatched main and passes here. test_set (644 tests) and test_free_threading.test_set (8 tests) are green on the free-threaded debug build.

NEWS entry added.

setiter_iternext() read si->si_set, checked it for NULL, walked the table
under the set's critical section and then cleared si->si_set and decrefed
the set after Py_END_CRITICAL_SECTION(). Threads that reach the exhausted
branch together all pass the NULL check, so every one of them drops the
iterator's single reference and the set is over-released.

On a free-threaded debug build the damage shows up at finalization as
"refcount is too small" in validate_gc_objects(); when the set is still
alive it shows up earlier as a negative refcount the next time
sys.getrefcount() reads it.

Hold the iterator's own critical section across the size check, the walk,
the clear and the counter updates, and store NULL with the same atomic
store the lock-free fast path uses for its read. The iterator's reference
is then dropped exactly once, after the lock has been released.

Add a regression test in test_free_threading: eight threads race to
exhaust one iterator over a set with a large dummy tail, and the set's
refcount must stay unchanged. Without the fix it aborts with
_Py_NegativeRefcount.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant