Description
For protocols 0–2, _pickle.fix_imports() retrieves borrowed values from the public _compat_pickle.REVERSE_NAME_MAPPING and REVERSE_IMPORT_MAPPING dictionaries and validates or promotes them without locking. Concurrent replacement can destroy the selected tuple, string, or tuple fields before use.
Observed Behavior
On a free-threaded build, replacing reverse-name tuples or reverse-import strings makes both reproducer modes terminate with SIGSEGV, normally within a fraction of a second. Both compatibility-GIL controls complete normally.
Affected Version
CPython 3.14.7 at commit 823f0323ee6ec1402088b73bce1a38473cac36dc, using the free-threaded build with PYTHON_GIL=0.
Reproduction
Run both modes:
PYTHON_GIL=0 python3.14t poc/reproduce.py 10 name
PYTHON_GIL=0 python3.14t poc/reproduce.py 10 import
PoC Source Code
poc/reproduce.py:
#!/usr/bin/env python3
"""Race protocol-2 global remapping with public compatibility-table updates."""
import _compat_pickle
import pickle
import queue
import sys
import threading
import time
MODE = sys.argv[2] if len(sys.argv) > 2 else "name"
if MODE == "name":
KEY = ("builtins", "range")
TABLE = _compat_pickle.REVERSE_NAME_MAPPING
TARGET = range
elif MODE == "import":
KEY = "queue"
TABLE = _compat_pickle.REVERSE_IMPORT_MAPPING
TARGET = queue.Queue
else:
raise ValueError("mode must be 'name' or 'import'")
ORIGINAL = TABLE[KEY]
STOP = threading.Event()
FAILURES = []
def mutate():
i = 0
while not STOP.is_set():
# The replacement tuple is intentionally made fresh so the table's
# previous value is reclaimed immediately when no reader owns it.
if MODE == "name":
TABLE[KEY] = ("__builtin__" + str(i), "xrange" + str(i))
else:
TABLE[KEY] = "Queue" + str(i)
i += 1
def pickle_worker(deadline):
count = 0
try:
while time.monotonic() < deadline and not STOP.is_set():
pickle.dumps(TARGET, protocol=2)
count += 1
except BaseException as exc:
FAILURES.append((type(exc).__name__, repr(exc), count))
STOP.set()
def main() -> bool:
seconds = float(sys.argv[1]) if len(sys.argv) > 1 else 20.0
deadline = time.monotonic() + seconds
threads = [threading.Thread(target=mutate)]
threads += [threading.Thread(target=pickle_worker, args=(deadline,)) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads[1:]:
thread.join()
STOP.set()
for thread in threads[:1]:
thread.join()
TABLE[KEY] = ORIGINAL
print("mode=", MODE, "gil=", sys._is_gil_enabled(), "failures=", FAILURES)
return bool(FAILURES)
raise SystemExit(main())
Description
For protocols 0–2,
_pickle.fix_imports()retrieves borrowed values from the public_compat_pickle.REVERSE_NAME_MAPPINGandREVERSE_IMPORT_MAPPINGdictionaries and validates or promotes them without locking. Concurrent replacement can destroy the selected tuple, string, or tuple fields before use.Observed Behavior
On a free-threaded build, replacing reverse-name tuples or reverse-import strings makes both reproducer modes terminate with SIGSEGV, normally within a fraction of a second. Both compatibility-GIL controls complete normally.
Affected Version
CPython 3.14.7 at commit
823f0323ee6ec1402088b73bce1a38473cac36dc, using the free-threaded build withPYTHON_GIL=0.Reproduction
Run both modes:
PoC Source Code
poc/reproduce.py: