Description
FrameLocalsProxy.items() traverses frame->f_extra_locals with PyDict_Next() and passes its borrowed key and value to PyTuple_Pack(). The sibling keys() and values() paths use the same unlocked traversal before appending a borrowed entry. Another thread using the proxy can delete or replace an extra local after acquisition but before promotion, invalidating both the entry lifetime and dictionary traversal state.
Observed Behavior
Six readers called items(), keys(), and values() while a writer filled and removed 256 extra-local keys on one suspended frame. The free-threaded ASan build crashed in PyTuple_Pack() from framelocalsproxy_items() at Objects/frameobject.c:656. Independent keys and values modes also crashed in PyDict_Next(). The same executable completed 233,067 operations in six seconds with the GIL enabled.
Affected Version
CPython 3.14.7 at commit 823f0323ee6ec1402088b73bce1a38473cac36dc, tested with a free-threaded ASan build.
Reproduction
ASAN_OPTIONS=abort_on_error=1:detect_leaks=0 PYTHON_GIL=0 python3.14 poc/reproduce.py
Set CONCURDEP_OPERATIONS=keys or CONCURDEP_OPERATIONS=values to isolate those paths. Setting PYTHON_GIL=1 provides the GIL-enabled control.
PoC Source Code
poc/reproduce.py
#!/usr/bin/env python3
"""Race FrameLocalsProxy.items with writes to the same extra-locals dict."""
import os
import threading
import time
DURATION = float(os.environ.get("CONCURDEP_DURATION", "10"))
READERS = int(os.environ.get("CONCURDEP_READERS", "6"))
KEYS = int(os.environ.get("CONCURDEP_KEYS", "256"))
OPERATIONS = tuple(os.environ.get("CONCURDEP_OPERATIONS", "items,keys,values").split(","))
def suspended():
local = 1
yield local
generator = suspended()
next(generator)
proxy = generator.gi_frame.f_locals
stop = threading.Event()
counts = [0] * (READERS + 1)
class Value:
__slots__ = ("payload",)
def __init__(self, payload):
self.payload = payload
def mutate():
count = 0
while not stop.is_set():
for index in range(KEYS):
proxy[f"extra-{index}"] = Value(count)
for index in range(KEYS):
try:
del proxy[f"extra-{index}"]
except KeyError:
pass
count += 1
counts[0] = count
def read(slot):
count = 0
while not stop.is_set():
getattr(proxy, OPERATIONS[count % len(OPERATIONS)])()
count += 1
counts[slot] = count
threads = [threading.Thread(target=mutate)]
threads.extend(threading.Thread(target=read, args=(i,)) for i in range(1, READERS + 1))
for thread in threads:
thread.start()
time.sleep(DURATION)
stop.set()
for thread in threads:
thread.join()
print(f"operations={sum(counts)} counts={counts}")
Description
FrameLocalsProxy.items()traversesframe->f_extra_localswithPyDict_Next()and passes its borrowed key and value toPyTuple_Pack(). The siblingkeys()andvalues()paths use the same unlocked traversal before appending a borrowed entry. Another thread using the proxy can delete or replace an extra local after acquisition but before promotion, invalidating both the entry lifetime and dictionary traversal state.Observed Behavior
Six readers called
items(),keys(), andvalues()while a writer filled and removed 256 extra-local keys on one suspended frame. The free-threaded ASan build crashed inPyTuple_Pack()fromframelocalsproxy_items()atObjects/frameobject.c:656. Independentkeysandvaluesmodes also crashed inPyDict_Next(). The same executable completed 233,067 operations in six seconds with the GIL enabled.Affected Version
CPython 3.14.7 at commit
823f0323ee6ec1402088b73bce1a38473cac36dc, tested with a free-threaded ASan build.Reproduction
Set
CONCURDEP_OPERATIONS=keysorCONCURDEP_OPERATIONS=valuesto isolate those paths. SettingPYTHON_GIL=1provides the GIL-enabled control.PoC Source Code
poc/reproduce.py