Skip to content

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

Closed

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

Linked PRs

Activity

  1. deadlovelll commented on Jul 21, 2026

    @deadlovelll
    Contributor

    hey @devdanzin, i can work on this issue

  2. added a commit that references this issue on Jul 25, 2026
  3. added a commit that references this issue on Aug 4, 2026
  4. added a commit that references this issue on Aug 14, 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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions