GeezerVison is showing github.com — itertoolsmodule: free-threading use after free bug in counting slow mode · Issue #153981 · python/cpython
itertoolsmodule: free-threading use after free bug in counting slow mode #153981DescriptionBug report
Bug description:
https://github.com/python/cpython/blob/main/Modules/itertoolsmodule.c#L3663
During next we swap out the pointer for lz->long_cnt inside count_nextlong. This can cause a use after free bug as shown with a new test. Running this is TSAN mode exposes this reliably.
ThreadSanitizer can not provide additional info.
SUMMARY: ThreadSanitizer: SEGV object.c:766 in PyObject_Repr
==83915==ABORTING
[1] 83915 abort PYTHON_GIL=0 ./python.exe -m unittest -v
class TestCountConcurrent(unittest.TestCase):
@staticmethod
def _spin_next(it, n=2000):
for _ in range(n):
next(it)
@staticmethod
def _spin_repr(it, n=2000):
for _ in range(n):
repr(it)
@threading_helper.reap_threads
def test_repr_racing_next_fast_mode(self):
for _ in range(10):
it = count()
workers = [self._spin_next] * 2 + [self._spin_repr] * 4
threading_helper.run_concurrently(workers, args=(it,))
@threading_helper.reap_threads
def test_repr_racing_next_slow_mode(self):
for _ in range(10):
# Large count to trigger "slow mode"
it = count(10**18, 2)
workers = [self._spin_next] * 2 + [self._spin_repr] * 4
threading_helper.run_concurrently(workers, args=(it,))
To fix I believe we need to hold a critical section reference in repr to fetching a reference to long_cnt
CPython versions tested on:
CPython main branch
Operating systems tested on:
macOS
Linked PRs
Activitydevdanzin commented on Jul 19, 2026 Confirming this independently. fusil --tsan (a ThreadSanitizer fuzzer) surfaced the same slow-mode use-after-free, which I'd reported as a follow-up on the fast-mode issue (gh-153908) just before being pointed at this issue and your PR.
The mechanism matches: in slow (big-int) mode count_repr borrows lz->long_cnt and repr()s it (the if (lz->long_cnt == NULL) mode check + PyUnicode_FromFormat("%R", …, lz->long_cnt)) with no synchronization, while count_nextlong does lz->long_cnt = stepped_up under Py_BEGIN_CRITICAL_SECTION(lz) and returns the old value — which the next() caller then DECREFs, so count_repr can repr() a freed object. TSan flags it as a data race on lz->long_cnt (count_nextlong vs count_repr), and when the free wins it's a SEGV in PyObject_Repr (matching your SEGV object.c:766). Reproduced deterministically on a free-threaded --with-thread-sanitizer build of current main; fast-mode count() is clean.
Repro:
import itertools, threading
NT = 8
for _ in range(3000):
it = itertools.count(10**18, 2) # big-int -> slow (long_cnt) mode
bar = threading.Barrier(NT)
def work(advance, it=it, bar=bar):
bar.wait()
for _ in range(400):
next(it) if advance else repr(it)
ts = [threading.Thread(target=work, args=(i % 2 == 0,)) for i in range(NT)]
for t in ts: t.start()
for t in ts: t.join()
PR #153983 looks like the correct fix: snapshotting long_cnt/long_step with Py_XNewRef inside Py_BEGIN_CRITICAL_SECTION(lz) and formatting from the locals addresses both halves — the data race (the reads are now under the same critical section count_nextlong writes under) and the use-after-free (the strong reference keeps long_cnt alive across the repr()). A bare atomic load of the pointer would have closed the race but not the UAF, so the strong-ref snapshot is the key part.
(Found by fusil --tsan, a ThreadSanitizer fuzzer; comment + reproducer by Claude Code, reviewed by hand.)
MetadataMetadataDevelopmentNo branches or pull requestsIssue actions
|