Description
The generic PyDict_MergeFromSeq2() path locks the destination but not the source sequence. For an exact list pair, PySequence_Fast() returns the same mutable list. merge_from_seq2_lock_held() then loads fast[0] and fast[1] as borrowed references with PySequence_Fast_GET_ITEM() before separately incrementing them. Concurrent replacement can destroy either object between its load and increment, after which the stale key or value is passed to dictionary insertion.
Observed Behavior
Eight threads repeatedly updated dictionaries from one shared two-element list while another thread replaced its key and value. The free-threaded ASan/assert build crashed in under one second in PyObject_Hash() from merge_from_seq2_lock_held() at Objects/dictobject.c:3848. With the GIL enabled, the same binary completed more than 1.45 million merges 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 os
import threading
import time
duration = float(os.environ.get("CONCURDEP_DURATION", "20"))
stop = threading.Event()
pair = [("seed" * 64), bytearray(4096)]
counts = [0] * 9
def mutate():
i = 0
while not stop.is_set():
pair[0] = ("key" * 64) + str(i)
pair[1] = bytearray(4096)
i += 1
counts[0] = i
def merge(slot):
target = {}
while not stop.is_set():
target.update([pair])
target.clear()
counts[slot] += 1
threads = [threading.Thread(target=mutate)]
threads += [threading.Thread(target=merge, args=(i,)) for i in range(1, 9)]
for thread in threads:
thread.start()
time.sleep(duration)
stop.set()
for thread in threads:
thread.join()
print("counts", counts)
Description
The generic
PyDict_MergeFromSeq2()path locks the destination but not the source sequence. For an exact list pair,PySequence_Fast()returns the same mutable list.merge_from_seq2_lock_held()then loadsfast[0]andfast[1]as borrowed references withPySequence_Fast_GET_ITEM()before separately incrementing them. Concurrent replacement can destroy either object between its load and increment, after which the stale key or value is passed to dictionary insertion.Observed Behavior
Eight threads repeatedly updated dictionaries from one shared two-element list while another thread replaced its key and value. The free-threaded ASan/assert build crashed in under one second in
PyObject_Hash()frommerge_from_seq2_lock_held()atObjects/dictobject.c:3848. With the GIL enabled, the same binary completed more than 1.45 million merges 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