Skip to content

FrameLocalsProxy views can use freed entries during mutation #157098

Description

@Nievesjyl

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}")

Activity

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions