Following the free-threading support in #269/#271: the C `ObjectProxy.__wrapped__` setter updates `self->wrapped` with `Py_XSETREF` and no critical section, so two threads assigning `__wrapped__` on the **same** proxy double-free the old value. ### Site (current `develop`, `src/wrapt/_wrappers.c`) ```c static int WraptObjectProxy_set_wrapped(WraptObjectProxyObject *self, PyObject *value) { ... Py_INCREF(value); Py_XSETREF(self->wrapped, value); // 2977 — no critical section ``` `Py_XSETREF(self->wrapped, value)` is `tmp = self->wrapped; self->wrapped = value; Py_XDECREF(tmp)`. Two threads running the `__wrapped__` setter on one shared proxy can both read the same `tmp` (the old wrapped) and both `Py_XDECREF` it — the old object is released twice → double-free / use-after-free. The `WraptObjectProxy_inplace_*` operators (`proxy += x`, …) update `self->wrapped` the same way and are very likely the same bug via a different entry (I reproduced the `__wrapped__` setter). This is live as shipped: `develop` declares `{Py_mod_gil, Py_MOD_GIL_NOT_USED}` (`_wrappers.c:4970`), so on a free-threaded interpreter the GIL stays off and no override is needed. In pure Python `x.attr = y` from two threads is a lost update, not a crash — the C proxy turns it into a memory-safety fault under free-threading. ### Reproducer `python3.14.0rc1t`, wrapt 2.3.0 built from source (C `_wrappers`). 8 threads assign `shared.__wrapped__ = object()` on one shared proxy. 10 rounds per arm: | arm | result | |---|---| | multi-setter, GIL off (default on 3.14t) | **10/10 SIGSEGV** | | control — single setter | clean 10/10 | | control — each thread sets its *own* proxy | clean 10/10 | | control — multi-setter, GIL on (`PYTHON_GIL=1`) | clean 10/10 | The two controls isolate it: a second concurrent setter on the *same* proxy is required, and the GIL-on control staying clean makes it a free-threading fault. The fresh `object()` values are uniquely held, so the double-decref actually frees them. <details><summary>ft_wrapt.py</summary> ```python """wrapt 2.3.0 C ObjectProxy — C4: unprotected Py_XSETREF(self->wrapped) (double-free). _wrappers.c:2977 WraptObjectProxy_set_wrapped (the __wrapped__ setter): Py_INCREF(value); Py_XSETREF(self->wrapped, value); // no critical section Py_XSETREF expands to: tmp = self->wrapped; self->wrapped = value; Py_XDECREF(tmp). Two threads setting `proxy.__wrapped__` on the SAME shared proxy both read the same `tmp` (the old wrapped) and both Py_XDECREF it → the old object is released twice → double-free / use-after-free. Distinct from the container-argument classes: the raced state is the extension's OWN instance field. Live-by-default: wrapt declares {Py_mod_gil, Py_MOD_GIL_NOT_USED}. """ import os, sys, threading, time from wrapt._wrappers import ObjectProxy # the C proxy N = int(os.environ.get("N", 8)) SECONDS = float(os.environ.get("SECONDS", 5)) MUTATE = os.environ.get("MUTATE", "1") == "1" # 0 => single setter (no second writer) DECOY = os.environ.get("DECOY", "0") == "1" # each thread sets its OWN proxy def main(): print(f"py={sys.version.split()[0]} gil={sys._is_gil_enabled()} setters={N if MUTATE else 1} decoy={DECOY}", flush=True) shared = ObjectProxy(object()) stop = threading.Event() nset = N if MUTATE else 1 barrier = threading.Barrier(nset + 1) done = [0]*nset def setter(t): own = ObjectProxy(object()) if DECOY else None barrier.wait() target = own if DECOY else shared i = 0 while not stop.is_set(): target.__wrapped__ = object() # fresh, uniquely-held → double-decref frees it i += 1 done[t] = i threads = [threading.Thread(target=setter, args=(t,)) for t in range(nset)] for th in threads: th.start() barrier.wait() time.sleep(SECONDS) stop.set() for th in threads: th.join() print(f"clean: {sum(done)} sets survived", flush=True) return 0 if __name__ == "__main__": raise SystemExit(main()) ``` </details> ### A possible fix Wrap the swap in `Py_BEGIN_CRITICAL_SECTION(self)` (the same mitigation used elsewhere in the free-threading migration), around `Py_XSETREF(self->wrapped, value)` in the `__wrapped__` setter and in the `inplace_*` operators. ### Not claimed No severity — it needs the caller's own code to assign `__wrapped__` on a shared proxy from two threads. Reported so it can be fixed, not rated.