Skip to content

Concurrent mutation of cached slot names can crash default object state collection #160440

Description

@SonicField

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 100000

For 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 100000

Use --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_GetOptionalAttr and PyObject_Hash.
  • Native ARM64, downstream CPython 3.14.8 free-threaded build,
    revision cb5a736, 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()

Linked PRs

Activity

  1. added a commit that references this issue on Oct 23, 2026
  2. added
    interpreter-core(Objects, Python, Grammar, and Parser dirs)
    type-crashA hard crash of the interpreter, possibly with a core dump
    on Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    interpreter-core(Objects, Python, Grammar, and Parser dirs)type-crashA hard crash of the interpreter, possibly with a core dump

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions