On a free-threaded build (3.14t), tree.assert_same_structure can dereference a freed PyObject* when the compared structures are dicts that another thread mutates concurrently. It reproduces as SIGSEGV / SIGTRAP; under the GIL it is clean.
Site
tree/tree.cc:552 on current master:
while (PyDict_Next(o1, &pos, &key, nullptr)) { // key is a borrowed reference
if (PyDict_GetItem(o2, key) == nullptr) { // runs key.__hash__ / __eq__
o1 and o2 are the two caller-supplied structures. key is borrowed from o1, and PyDict_GetItem(o2, key) calls key's __hash__ and __eq__. If those are Python (e.g. a str subclass), the lookup leaves C while holding the borrowed key. Another thread mutating o1 at that moment can drop key's last reference and free it. Two documented hazards stack: PyDict_Next is itself unsafe against concurrent mutation, and the key it returns is borrowed.
The same borrowed-PyDict_GetItem shape is at tree.cc:283 (with a comment already noting the borrow); I have not exercised that path.
Exposure
dm-tree is built with pybind11, which declares the module Py_MOD_GIL_NOT_USED by default on a free-threaded build. So on a stock free-threaded interpreter import tree leaves the GIL disabled - no PYTHON_GIL=0 override is needed to reach this - while the module has not been made free-threading-safe.
Measured
python3.14.0rc1t, 8 comparer threads calling assert_same_structure(a, b) while 3 mutator threads replace keys in a; keys are a str subclass so __hash__ is real Python. 4000 rounds, 10 runs per arm:
| arm |
result |
| free-threaded |
10/10 crash (SIGSEGV 4, SIGTRAP 6) |
| control - no mutator thread |
clean 10/10 |
| control - mutator touches a decoy dict, not the compared one |
clean 10/10 |
| same script, GIL build (3.14) |
clean 10/10 |
Both controls matter: without the first, concurrent comparison alone could be blamed; without the second, generic thread pressure could be.
Reproducer (ft_dmtree.py)
"""dm-tree `tree.cc:552` — borrowed key from PyDict_Next used across arbitrary Python.
while (PyDict_Next(o1, &pos, &key, nullptr)) { /* key is borrowed */
if (PyDict_GetItem(o2, key) == nullptr) { /* runs __hash__/__eq__ */
`o1` and `o2` are the two structures the caller passed to
`tree.assert_same_structure`. Under Py_GIL_DISABLED another thread mutating `o1`
can free the borrowed `key` while the lookup in `o2` is running user code.
Two hazards stacked: PyDict_Next itself is documented as unsafe against
concurrent mutation, and the key it hands back is borrowed.
Keys here are a str subclass whose __hash__ is real Python, so the lookup in `o2`
genuinely leaves C.
"""
import gc
import sys
import threading
import tree
N_CMP, N_MUT, ROUNDS, SIZE = 8, 3, 4000, 32
class Key(str):
"""A str subclass: __hash__ goes through Python, so PyDict_GetItem leaves C."""
__slots__ = ()
def __hash__(self):
return str.__hash__(self)
def main():
print(f"py={sys.version.split()[0]}{sys.abiflags} "
f"gil={getattr(sys, '_is_gil_enabled', lambda: True)()}", flush=True)
a = {Key(f"k{i}"): i for i in range(SIZE)}
b = {Key(f"k{i}"): i for i in range(SIZE)}
stop = threading.Event()
barrier = threading.Barrier(N_CMP + N_MUT + 1)
done = [0] * N_CMP
def comparer(t):
barrier.wait()
n = 0
for _ in range(ROUNDS):
try:
tree.assert_same_structure(a, b)
except Exception:
pass
n += 1
done[t] = n
def mutator():
barrier.wait()
i = 0
while not stop.is_set():
k = Key(f"k{i % SIZE}")
a[k] = i # replace: the old Key object loses its last ref
i += 1
cs = [threading.Thread(target=comparer, args=(t,)) for t in range(N_CMP)]
ms = [threading.Thread(target=mutator) for _ in range(N_MUT)]
gc.disable()
for t in cs + ms:
t.start()
barrier.wait()
for t in cs:
t.join()
stop.set()
for t in ms:
t.join()
gc.enable()
print(f"clean: {sum(done)} comparisons", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
The two controls are one-line variants: control A removes the mutator threads; control B points the mutators at a separate decoy dict instead of a.
A possible fix
Matching what CPython added for exactly this: take a strong reference to the key for the duration of the lookup - PyDict_GetItemRef / Py_NewRef(key) around the PyDict_GetItem, or wrap the walk in a critical section on o1.
Not claimed
No severity - whether an application shares a structure across threads while calling assert_same_structure is application-specific. No exploitability - the observed faults are consistent with the mechanism and I did not build a primitive.
On a free-threaded build (
3.14t),tree.assert_same_structurecan dereference a freedPyObject*when the compared structures are dicts that another thread mutates concurrently. It reproduces as SIGSEGV / SIGTRAP; under the GIL it is clean.Site
tree/tree.cc:552on currentmaster:o1ando2are the two caller-supplied structures.keyis borrowed fromo1, andPyDict_GetItem(o2, key)callskey's__hash__and__eq__. If those are Python (e.g. astrsubclass), the lookup leaves C while holding the borrowedkey. Another thread mutatingo1at that moment can dropkey's last reference and free it. Two documented hazards stack:PyDict_Nextis itself unsafe against concurrent mutation, and the key it returns is borrowed.The same borrowed-
PyDict_GetItemshape is attree.cc:283(with a comment already noting the borrow); I have not exercised that path.Exposure
dm-tree is built with pybind11, which declares the module
Py_MOD_GIL_NOT_USEDby default on a free-threaded build. So on a stock free-threaded interpreterimport treeleaves the GIL disabled - noPYTHON_GIL=0override is needed to reach this - while the module has not been made free-threading-safe.Measured
python3.14.0rc1t, 8 comparer threads callingassert_same_structure(a, b)while 3 mutator threads replace keys ina; keys are astrsubclass so__hash__is real Python. 4000 rounds, 10 runs per arm:Both controls matter: without the first, concurrent comparison alone could be blamed; without the second, generic thread pressure could be.
Reproducer (ft_dmtree.py)
The two controls are one-line variants: control A removes the mutator threads; control B points the mutators at a separate decoy dict instead of
a.A possible fix
Matching what CPython added for exactly this: take a strong reference to the key for the duration of the lookup -
PyDict_GetItemRef/Py_NewRef(key)around thePyDict_GetItem, or wrap the walk in a critical section ono1.Not claimed
No severity - whether an application shares a structure across threads while calling
assert_same_structureis application-specific. No exploitability - the observed faults are consistent with the mechanism and I did not build a primitive.