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