Skip to content

GC can call a freed callback during concurrent gc.callbacks mutation #157100

Description

@Nievesjyl

Description

invoke_gc_callback() reads a callable from the public gc.callbacks list with the borrowed-reference macro PyList_GET_ITEM() and increments it in the next statement without a list critical section. Another thread can clear or replace that element in between and release its last reference. The collector then passes a stale callback pointer to PyObject_Vectorcall(); the collector-only gcstate->collecting flag does not protect list mutation by ordinary threads.

Observed Behavior

Three collectors ran while three mutators repeatedly appended a fresh callback, cleared the list, and churned allocations. The free-threaded ASan/assert build crashed in under one second while constructing call arguments below invoke_gc_callback() at Python/gc_free_threading.c:1913. With the GIL enabled, the same binary completed 1,239 collections and 83,914 list mutations in five seconds without a sanitizer failure.

Affected Version

CPython 3.14.7 at commit 823f0323ee6ec1402088b73bce1a38473cac36dc, tested with a free-threaded ASan/assert build.

Reproduction

ASAN_OPTIONS=abort_on_error=1:detect_leaks=0 PYTHON_GIL=0 CONCURDEP_DURATION=20 python3.14 poc/reproduce.py

Setting PYTHON_GIL=1 CONCURDEP_DURATION=5 provides the GIL-enabled control.

PoC Source Code

poc/reproduce.py

import gc
import os
import threading
import time


duration = float(os.environ.get("CONCURDEP_DURATION", "20"))
stop = threading.Event()
collections = [0]
mutations = [0]


class Callback:
    __slots__ = ("padding",)

    def __init__(self):
        self.padding = bytearray(4096)

    def __call__(self, phase, info):
        return None


def collect():
    while not stop.is_set():
        gc.collect()
        collections[0] += 1


def mutate():
    callbacks = gc.callbacks
    while not stop.is_set():
        callbacks.append(Callback())
        callbacks.clear()
        # Encourage immediate reuse of the just-freed callback allocation.
        [bytearray(4096) for _ in range(8)]
        mutations[0] += 1


threads = [threading.Thread(target=collect) for _ in range(3)]
threads += [threading.Thread(target=mutate) for _ in range(3)]
for thread in threads:
    thread.start()
time.sleep(duration)
stop.set()
for thread in threads:
    thread.join()
print("collections", collections[0], "mutations", mutations[0])

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