Description
invoke_gc_callback() reads a callable from the public gc.callbacks list with the borrowed-reference macro PyList_GET_ITEM() and increments it in the next statement without a list critical section. Another thread can clear or replace that element in between and release its last reference. The collector then passes a stale callback pointer to PyObject_Vectorcall(); the collector-only gcstate->collecting flag does not protect list mutation by ordinary threads.
Observed Behavior
Three collectors ran while three mutators repeatedly appended a fresh callback, cleared the list, and churned allocations. The free-threaded ASan/assert build crashed in under one second while constructing call arguments below invoke_gc_callback() at Python/gc_free_threading.c:1913. With the GIL enabled, the same binary completed 1,239 collections and 83,914 list mutations in five seconds without a sanitizer failure.
Affected Version
CPython 3.14.7 at commit 823f0323ee6ec1402088b73bce1a38473cac36dc, tested with a free-threaded ASan/assert build.
Reproduction
ASAN_OPTIONS=abort_on_error=1:detect_leaks=0 PYTHON_GIL=0 CONCURDEP_DURATION=20 python3.14 poc/reproduce.py
Setting PYTHON_GIL=1 CONCURDEP_DURATION=5 provides the GIL-enabled control.
PoC Source Code
poc/reproduce.py
import gc
import os
import threading
import time
duration = float(os.environ.get("CONCURDEP_DURATION", "20"))
stop = threading.Event()
collections = [0]
mutations = [0]
class Callback:
__slots__ = ("padding",)
def __init__(self):
self.padding = bytearray(4096)
def __call__(self, phase, info):
return None
def collect():
while not stop.is_set():
gc.collect()
collections[0] += 1
def mutate():
callbacks = gc.callbacks
while not stop.is_set():
callbacks.append(Callback())
callbacks.clear()
# Encourage immediate reuse of the just-freed callback allocation.
[bytearray(4096) for _ in range(8)]
mutations[0] += 1
threads = [threading.Thread(target=collect) for _ in range(3)]
threads += [threading.Thread(target=mutate) for _ in range(3)]
for thread in threads:
thread.start()
time.sleep(duration)
stop.set()
for thread in threads:
thread.join()
print("collections", collections[0], "mutations", mutations[0])
Description
invoke_gc_callback()reads a callable from the publicgc.callbackslist with the borrowed-reference macroPyList_GET_ITEM()and increments it in the next statement without a list critical section. Another thread can clear or replace that element in between and release its last reference. The collector then passes a stale callback pointer toPyObject_Vectorcall(); the collector-onlygcstate->collectingflag does not protect list mutation by ordinary threads.Observed Behavior
Three collectors ran while three mutators repeatedly appended a fresh callback, cleared the list, and churned allocations. The free-threaded ASan/assert build crashed in under one second while constructing call arguments below
invoke_gc_callback()atPython/gc_free_threading.c:1913. With the GIL enabled, the same binary completed 1,239 collections and 83,914 list mutations in five seconds without a sanitizer failure.Affected Version
CPython 3.14.7 at commit
823f0323ee6ec1402088b73bce1a38473cac36dc, tested with a free-threaded ASan/assert build.Reproduction
Setting
PYTHON_GIL=1 CONCURDEP_DURATION=5provides the GIL-enabled control.PoC Source Code
poc/reproduce.py