Description
For a non-exact dictionary, _json iterates the list returned by items() with unchecked list access and increments each borrowed tuple without holding the list's critical section. Another thread can replace or remove that tuple between acquisition and ownership promotion.
Observed Behavior
On a free-threaded ASan build, racing JSON encoding with mutation of a custom mapping's shared items list produces a null SEGV after encoder_listencode_dict() passes stale key or value data to nested encoding.
Affected Version
CPython 3.14.7 at commit 823f0323ee6ec1402088b73bce1a38473cac36dc, built with --disable-gil --with-address-sanitizer and run with PYTHON_GIL=0.
Reproduction
Run the stress reproducer; the schedule is probabilistic:
ASAN_OPTIONS=detect_leaks=0:halt_on_error=1 PYTHON_GIL=0 python3.14 poc/reproduce.py --seconds 10
PoC Source Code
poc/reproduce.py:
#!/usr/bin/env python3
"""Race JSON encoding against a shared custom-mapping items list."""
import argparse
import json
import threading
import time
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--seconds", type=float, default=10.0)
args = parser.parse_args()
shared_items = [(str(index), [index]) for index in range(512)]
class SharedMapping(dict):
def items(self):
return shared_items
mapping = SharedMapping(seed=1)
stop = threading.Event()
counts = [0, 0]
def encode() -> None:
while not stop.is_set():
try:
json.dumps(mapping, check_circular=False)
except (RuntimeError, ValueError, IndexError):
pass
counts[0] += 1
def mutate() -> None:
generation = 0
while not stop.is_set():
index = generation % 512
shared_items[index] = (str(index), [generation])
if generation % 4 == 0:
shared_items.pop()
shared_items.append(("tail", [generation + 1]))
generation += 1
counts[1] += 1
threads = [threading.Thread(target=encode), threading.Thread(target=mutate)]
for thread in threads:
thread.start()
time.sleep(args.seconds)
stop.set()
for thread in threads:
thread.join()
print({"encode": counts[0], "mutate": counts[1]})
if __name__ == "__main__":
main()
Description
For a non-exact dictionary,
_jsoniterates the list returned byitems()with unchecked list access and increments each borrowed tuple without holding the list's critical section. Another thread can replace or remove that tuple between acquisition and ownership promotion.Observed Behavior
On a free-threaded ASan build, racing JSON encoding with mutation of a custom mapping's shared items list produces a null SEGV after
encoder_listencode_dict()passes stale key or value data to nested encoding.Affected Version
CPython 3.14.7 at commit
823f0323ee6ec1402088b73bce1a38473cac36dc, built with--disable-gil --with-address-sanitizerand run withPYTHON_GIL=0.Reproduction
Run the stress reproducer; the schedule is probabilistic:
PoC Source Code
poc/reproduce.py: