Repository navigation
Concurrent mutation of cached slot names can crash default object state collection #160440
Description
The default state collector reads the user-accessible cached __slotnames__
list using Py_NewRef(PyList_GET_ITEM(slotnames, i)). In a free-threaded
build, concurrent resizing can invalidate the index, and same-size replacement
can release a name before its reference is acquired. This affects direct
object.__getstate__() and the default object.__reduce_ex__() path used by
pickle protocols 2 and above.
Expected behaviour is valid collected state or the existing size-change
RuntimeError, without a native crash or invalid reference access. Attribute
lookup should retain its existing callback order.
Standalone reproducer
Save the code in Reproducer code below as reproduce_slotnames.py.
It uses only the standard library and rejects runs with the GIL enabled.
From the directory containing that file, run a free-threaded CPython interpreter:
python3.14t -X gil=0 reproduce_slotnames.py --mode resize --operation reduce --protocol 5 --calls 100000For an ASan-enabled free-threaded CPython build, use that build's executable:
ASAN_OPTIONS=detect_leaks=0 ./python -X gil=0 reproduce_slotnames.py --mode resize --operation reduce --protocol 5 --calls 100000Use --mode replace for same-size replacement of non-interned names, or
--operation state for direct __getstate__(). Normal completion prints
valid-result, expected-size-change-error and concurrent-mutation counts.
The race is scheduling-dependent: a clean run does not establish its absence.
Observed reproductions
The author supplied crash logs for the default resize/reduce/protocol-5
workload, with 100,000 requested calls and the GIL disabled:
- Native ARM64, CPython 3.16.0a1+dev, upstream base
d3ada45, GCC 11.5.0,
free-threaded ASan build: segmentation fault. The stack includes
object_getstate_default,PyObject_GetOptionalAttrandPyObject_Hash. - Native ARM64, downstream CPython 3.14.8 free-threaded build,
revisioncb5a736, Clang 21.1.0: segmentation fault in the reproducer's
__reduce_ex__()call. This establishes a reproduction on that downstream
build; an unmodified upstream 3.14 build has not been tested. - An earlier x86-64 Python 3.14t run under QEMU on ARM64 also crashed.
Two earlier native ARM runs completed without a crash; their exact build
details and commands were not retained in the supplied report.
Native 3.16 ASan excerpt
Paths have been reduced to repository-relative paths or filenames. Process
IDs, raw addresses and unrelated frames are omitted. ASan reports SEGV;
this excerpt is not a classified heap-use-after-free diagnostic.
Fatal Python error: Segmentation fault
Stack (most recent call first):
File "reproduce_slotnames.py", line 79 in main
File "reproduce_slotnames.py", line 120 in <module>
AddressSanitizer:DEADLYSIGNAL
ERROR: AddressSanitizer: SEGV on unknown address [address omitted]
The signal is caused by a READ memory access.
Selected frames from the supplied ASan trace:
PyObject_Hash Objects/object.c:1162
_PyObject_HashDictKey Include/internal/pycore_object.h:846
PyDict_GetItemRef Objects/dictobject.c:2624
_PyObject_GenericGetAttrWithDict Objects/object.c:1980
PyObject_GetOptionalAttr Objects/object.c:1406
object_getstate_default Objects/typeobject.c:7893
object_getstate Objects/typeobject.c:7963
reduce_newobj Objects/typeobject.c:8198
_common_reduce Objects/typeobject.c:8241
object___reduce_ex___impl Objects/typeobject.c:8306
AddressSanitizer can not provide additional info.
ABORTING
Native downstream 3.14.8 excerpt
Fatal Python error: Segmentation fault
Stack (most recent call first):
File "reproduce_slotnames.py", line 79 in main
File "reproduce_slotnames.py", line 120 in <module>
Selected symbolised C frames:
python3.14t: _Py_DumpStack+0x34
[unsymbolised frames omitted]
python3.14t: PyEval_EvalCode+0xc8
[unsymbolised frames omitted]
python3.14t: Py_RunMain+0xb4c
[remaining frames omitted]
Segmentation fault (core dumped)
The proposed fix uses PyList_GetItemRef() and preserves the existing
size-change error and ordered attribute lookup. The exact three-file patch
passed the relevant suites on free-threaded ASan and PGO/LTO builds, with
1,271 tests and 58 skips per build. Retained direct ASan stress passed
1.2 million calls on the fixed build. Those earlier checks used the original
stress program; the newly packaged standalone script has not yet been rerun
by the worker on the fixed interpreter.
Alternative considered
We considered making the slot-name cache immutable. We chose to preserve its
current representation for this fix: enforcing immutability would change
existing behaviour and could expose compatibility edge cases in real-world
code. We have not measured a benefit that would justify that additional
stability risk. The proposed change therefore addresses unsafe reference
acquisition while retaining the existing cache and lookup behaviour.
This is a scope decision for this fix, not a conclusion that an immutable
cache would never be worthwhile.
Fork CI
CPython GitHub Actions run 39960172407 tested
commit fede7f7d6d26ec8e261b8fc570c3c5f877cadef9 on pr/slotnames-state-access.
All 44 executed jobs succeeded. The two skipped jobs were
CIFuzz and the removed-HTML-ID documentation check.
Successful jobs cover Linux x86-64/ARM64, Windows x64/Win32/ARM64,
macOS Intel/Apple Silicon, Android ARM64/x86-64, iOS, Emscripten and WASI.
Free-threaded jobs passed where configured, including Linux, Windows,
macOS and iOS. ASan, UBSan, MSan and both TSan configurations succeeded.
These are job-level results; individual test skips have not been inventoried.
AI tools assisted with investigation and preparation.
Reproducer code
reproduce_slotnames.py (standard library only)
#!/usr/bin/env python3
"""Exercise concurrent mutation of CPython's cached default-state slot names.
Run with a free-threaded CPython interpreter and the GIL disabled. A clean
run is finite stress evidence; it does not prove that the race is absent.
"""
import argparse
import faulthandler
import json
import sys
import threading
import time
class Name(str):
"""A fresh, non-interned name whose lifetime depends on owned references."""
class Sample:
pass
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--mode", choices=("resize", "replace"), default="resize")
parser.add_argument("--operation", choices=("state", "reduce"), default="reduce")
parser.add_argument("--protocol", choices=range(2, 6), type=int, default=5)
parser.add_argument("--calls", type=int, default=100_000)
args = parser.parse_args()
if args.calls <= 0:
parser.error("--calls must be positive")
gil_enabled = getattr(sys, "_is_gil_enabled", lambda: True)()
if gil_enabled:
parser.error("requires a free-threaded CPython interpreter with the GIL disabled")
faulthandler.enable()
obj = Sample()
obj.value = 42
obj.other = 99
Sample.__slotnames__ = [Name("value")]
names = Sample.__slotnames__
stopped = threading.Event()
started = threading.Event()
mutations = 0
writer_errors = []
def writer():
nonlocal mutations
try:
while not stopped.is_set():
names[:] = [Name("value")]
if args.mode == "resize":
names.clear()
else:
names[:] = [Name("other")]
mutations += 1
started.set()
except BaseException as exc:
writer_errors.append(exc)
started.set()
print(json.dumps({"event": "start", "python_version": list(sys.version_info[:3]),
"implementation": sys.implementation.name, "gil_enabled": gil_enabled,
"arguments": vars(args)}), flush=True)
thread = threading.Thread(target=writer, daemon=True)
thread.start()
successes = size_change_errors = 0
begin = time.monotonic()
try:
if not started.wait(10):
raise RuntimeError("writer did not start within 10 seconds")
initial_mutations = mutations
for _ in range(args.calls):
try:
if args.operation == "state":
state = obj.__getstate__()
else:
reduced = obj.__reduce_ex__(args.protocol)
if reduced[1] != (Sample,) or reduced[3:] != (None, None):
raise AssertionError(f"unexpected reduction: {reduced!r}")
state = reduced[2]
except RuntimeError as exc:
if (args.mode != "resize" or
str(exc) != "__slotnames__ changed size during iteration"):
raise
size_change_errors += 1
continue
if type(state) is tuple:
if len(state) != 2:
raise AssertionError(f"unexpected state tuple: {state!r}")
dictionary, slots = state
if slots not in ({"value": 42}, {"other": 99}):
raise AssertionError(f"unexpected slot state: {slots!r}")
else:
if args.mode == "replace":
raise AssertionError(f"missing slot state: {state!r}")
dictionary = state
if dictionary is not obj.__dict__ or dictionary != {"value": 42, "other": 99}:
raise AssertionError(f"unexpected dictionary state: {dictionary!r}")
successes += 1
concurrent_mutations = mutations - initial_mutations
finally:
stopped.set()
thread.join(10)
if thread.is_alive():
raise RuntimeError("writer did not stop within 10 seconds")
if writer_errors:
raise writer_errors[0]
if concurrent_mutations <= 0:
raise RuntimeError("inconclusive: no writer progress observed during reader calls")
print(json.dumps({"event": "complete", "successes": successes,
"size_change_errors": size_change_errors,
"concurrent_mutations": concurrent_mutations,
"elapsed_seconds": time.monotonic() - begin}), flush=True)
if __name__ == "__main__":
main()
Activity
pythongh-160440: Add news for safe cached slot-name access