Skip to content

Data race + leak: descr_get_qualname lazily caches d_qualname without synchronization (free-threading) #154044

Description

@devdanzin

Bug report

Bug description:

On a free-threaded (--disable-gil) build, reading __qualname__ on the same descriptor from multiple threads is a data race on the lazily-populated cache descr->d_qualname. descr_get_qualname (Objects/descrobject.c) does an unsynchronized check-then-write:

static PyObject *
descr_get_qualname(PyObject *self, void *Py_UNUSED(ignored))
{
    PyDescrObject *descr = (PyDescrObject *)self;
    if (descr->d_qualname == NULL)
        descr->d_qualname = calculate_qualname(descr);   // WRITE, no lock
    return Py_XNewRef(descr->d_qualname);
}

Descriptors (method_descriptor / getset_descriptor / wrapper_descriptor) live on their owning type, so they are shared across all threads. When two threads first read the same descriptor's __qualname__, both observe d_qualname == NULL, both call calculate_qualname, and both store into descr->d_qualname — a write/write data race on the pointer, plus a leak: the store is a plain assignment (the old value is NULL), so the losing thread's freshly-computed str is overwritten and never freed.

This is value-benign (the two computed qualnames are equal, and there is no double-free, so it does not crash), but it is a genuine C11 data race and a small leak on a shared object. It is the same lazy-cache-without-synchronization pattern that gh-125267 fixed for object.__reduce_ex__'s objreduce cache.

Reproducer

import threading

NT = 8

# Builtin C descriptors keep d_qualname == NULL until __qualname__ is first read, so each can be
# raced exactly once. Race __qualname__ across threads on each freshly-untouched descriptor.
descrs = []
for tp in (str, bytes, list, dict, set, int, float, tuple, frozenset, bytearray):
    for name, v in vars(tp).items():
        if type(v).__name__ in ("method_descriptor", "getset_descriptor", "wrapper_descriptor"):
            descrs.append(v)


def worker(descriptor, barrier):
    barrier.wait()
    for _ in range(20):
        _ = descriptor.__qualname__


for _round in range(50):
    for d in descrs:
        bar = threading.Barrier(NT)
        threads = [threading.Thread(target=worker, args=(d, bar)) for _ in range(NT)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
print("done")

Under a --with-thread-sanitizer free-threaded build (PYTHON_GIL=0, TSAN_OPTIONS=…exitcode=66…), this reports WARNING: ThreadSanitizer: data race … in descr_get_qualname (both sides descr_get_qualname, on descr->d_qualname) deterministically. Reproduced on both a debug and a release TSan build.

Suggested fix

Serialize the lazy init — either a per-object critical section:

if (descr->d_qualname == NULL) {
    Py_BEGIN_CRITICAL_SECTION(descr);
    if (descr->d_qualname == NULL)                       // re-check
        descr->d_qualname = calculate_qualname(descr);
    Py_END_CRITICAL_SECTION();
}
return Py_XNewRef(descr->d_qualname);

or a one-shot atomic compare-exchange (compute, CAS(&descr->d_qualname, NULL, new), Py_DECREF the loser). The GIL build is unchanged either way. (gh-125267 took the "initialize eagerly" route for the analogous objreduce cache.)

(Found by fusil --tsan, a ThreadSanitizer fuzzer. Draft and reproducer by Claude Code, minimized and reviewed by hand.)

CPython versions tested on:

CPython main branch

Operating systems tested on:

Linux

Metadata

Metadata

Assignees

No one assigned

    Labels

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions