Bug report
Bug description:
In the free-threaded build, iterating an OrderedDict concurrently with update()/clear() data-races on the ordered-node linked list and od_state, and crashes with a SEGV (use-after-free) in the iterator.
OrderedDict keeps a doubly-linked list of _ODictNodes and an od_state counter separate from the dict.
The reader odictiter_new() (the body of tp_iter / __reversed__), reads the
- list head/tail (
_odict_FIRST/_odict_LAST(od)), - the current node's key (
_odictnode_KEY(node)), od->od_state
without holding the OrderedDict's critical section.
| di->kind = kind; | |
| node = reversed ? _odict_LAST(od) : _odict_FIRST(od); | |
| di->di_current = node ? Py_NewRef(_odictnode_KEY(node)) : NULL; | |
| di->di_size = PyODict_SIZE(od); | |
| di->di_state = od->od_state; | |
| di->di_odict = (PyODictObject*)Py_NewRef(od); |
The same fields are written by _odict_add_new_node and _odict_add_tail (on update()) while holding the OrderedDict lock,
| static void | |
| _odict_add_tail(PyODictObject *od, _ODictNode *node) | |
| { | |
| _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(od); | |
| _odictnode_PREV(node) = _odict_LAST(od); | |
| _odictnode_NEXT(node) = NULL; | |
| if (_odict_LAST(od) == NULL) | |
| _odict_FIRST(od) = node; | |
| else | |
| _odictnode_NEXT(_odict_LAST(od)) = node; | |
| _odict_LAST(od) = node; | |
| od->od_state++; | |
| } | |
| /* adds the node to the end of the list */ | |
| static int | |
| _odict_add_new_node(PyODictObject *od, PyObject *key, Py_hash_t hash) | |
| { | |
| _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(od); | |
| Py_ssize_t i; | |
| _ODictNode *node; | |
| Py_INCREF(key); | |
| i = _odict_get_index(od, key, hash); | |
| if (i < 0) { | |
| if (!PyErr_Occurred()) | |
| PyErr_SetObject(PyExc_KeyError, key); | |
| Py_DECREF(key); | |
| return -1; | |
| } | |
| assert(od->od_fast_nodes != NULL); | |
| if (od->od_fast_nodes[i] != NULL) { | |
| /* We already have a node for the key so there's no need to add one. */ | |
| Py_DECREF(key); | |
| return 0; | |
| } | |
| /* must not be added yet */ | |
| node = (_ODictNode *)PyMem_Malloc(sizeof(_ODictNode)); | |
| if (node == NULL) { | |
| Py_DECREF(key); | |
| PyErr_NoMemory(); | |
| return -1; | |
| } | |
| _odictnode_KEY(node) = key; | |
| _odictnode_HASH(node) = hash; | |
| _odict_add_tail(od, node); | |
| od->od_fast_nodes[i] = node; |
and freed by _odict_clear_nodes (on clear()) โ which clears _odict_FIRST/_odict_LAST, bumps od_state, and deallocates every node:
| static void | |
| _odict_clear_nodes(PyODictObject *od) | |
| { | |
| _ODictNode *node, *next; | |
| PyMem_Free(od->od_fast_nodes); | |
| od->od_fast_nodes = NULL; | |
| od->od_fast_nodes_size = 0; | |
| od->od_resize_sentinel = NULL; | |
| node = _odict_FIRST(od); | |
| _odict_FIRST(od) = NULL; | |
| _odict_LAST(od) = NULL; | |
| while (node != NULL) { | |
| next = _odictnode_NEXT(node); | |
| _odictnode_DEALLOC(node); | |
| node = next; | |
| } | |
| od->od_state++; | |
| } |
Because the reader (odictiter_new) holds no lock, a concurrent clear() can free a node that it is dereferencing in _odictnode_KEY(node) โ data race and use-after-free / SEGV.
Reproducer:
import collections from threading import Thread shared = collections.OrderedDict((i, i) for i in range(100)) def mutator(): for i in range(20000): shared.clear() shared.update(((i, i), (i + 1, i + 1), (i + 2, i + 2))) def iterator(): for _ in range(20000): try: for _ in shared: pass list(reversed(shared)) except Exception: pass if __name__ == "__main__": threads = [Thread(target=mutator)] threads += [Thread(target=iterator) for _ in range(8)] for t in threads: t.start() for t in threads: t.join()
TSAN Report:
==================
WARNING: ThreadSanitizer: data race (pid=2281797)
Read of size 8 at 0x7bf61c3733c0 by thread T2:
#0 odictiter_new /cpython/Objects/odictobject.c:1945:12
#1 odict_iter /cpython/Objects/odictobject.c:1536:12
#2 PyObject_GetIter /cpython/Objects/abstract.c:2825:25
#3 _PyEval_GetIter /cpython/Python/ceval.c:1142:24
#4 _PyEval_EvalFrameDefault /cpython/Python/generated_cases.c.h:6595:38
...
Previous write of size 8 at 0x7bf61c3733c0 by thread T1:
#0 _odict_add_tail /cpython/Objects/odictobject.c
#1 _odict_add_new_node /cpython/Objects/odictobject.c:713:5
#2 _PyODict_SetItem_KnownHash_LockHeld /cpython/Objects/odictobject.c:1627:15
#3 PyODict_SetItem_LockHeld /cpython/Objects/odictobject.c:1647:12
#4 PyODict_SetItem /cpython/Objects/odictobject.c:1655:11
#5 odict_mp_ass_sub /cpython/Objects/odictobject.c:878:16
#6 PyObject_SetItem /cpython/Objects/abstract.c:245:19
#7 mutablemapping_add_pairs /cpython/Objects/odictobject.c:2251:15
#8 mutablemapping_update_arg /cpython/Objects/odictobject.c:2328:11
#9 mutablemapping_update /cpython/Objects/odictobject.c:2349:15
#10 method_vectorcall_VARARGS_KEYWORDS /cpython/Objects/descrobject.c:359:14
#11 _PyObject_VectorcallTstate /cpython/./Include/internal/pycore_call.h:144:11
#12 PyObject_Vectorcall /cpython/Objects/call.c:327:12
#13 _Py_VectorCall_StackRefSteal /cpython/Python/ceval.c:726:11
#14 _PyEval_EvalFrameDefault /cpython/Python/generated_cases.c.h:4362:35
...
SUMMARY: ThreadSanitizer: data race /cpython/Objects/odictobject.c:1945:12 in odictiter_new
==================
...
==================
WARNING: ThreadSanitizer: data race (pid=2282562)
Read of size 8 at 0x7fffb8120540 by thread T2:
#0 odictiter_new /cpython/Objects/odictobject.c:1946:29
#1 odict_reversed /cpython/Objects/odictobject.c:1305:12
#2 cfunction_vectorcall_NOARGS /cpython/Objects/methodobject.c:508:24
#3 _PyObject_VectorcallTstate /cpython/./Include/internal/pycore_call.h:144:11
#4 _PyObject_CallNoArgs /cpython/./Include/internal/pycore_call.h:160:12
#5 reversed_new_impl /cpython/Objects/enumobject.c:379:25
#6 reversed_vectorcall /cpython/Objects/enumobject.c:419:12
#7 _Py_CallBuiltinClass_StackRef /cpython/Python/ceval.c:901:11
#8 _PyEval_EvalFrameDefault /cpython/Python/generated_cases.c.h:2333:35
...
Previous write of size 8 at 0x7fffb8120540 by thread T1:
#0 _odict_add_new_node /cpython/Objects/odictobject.c:711:26
#1 _PyODict_SetItem_KnownHash_LockHeld /cpython/Objects/odictobject.c:1627:15
#2 PyODict_SetItem_LockHeld /cpython/Objects/odictobject.c:1647:12
#3 PyODict_SetItem /cpython/Objects/odictobject.c:1655:11
#4 odict_mp_ass_sub /cpython/Objects/odictobject.c:878:16
#5 PyObject_SetItem /cpython/Objects/abstract.c:245:19
#6 mutablemapping_add_pairs /cpython/Objects/odictobject.c:2251:15
#7 mutablemapping_update_arg /cpython/Objects/odictobject.c:2328:11
#8 mutablemapping_update /cpython/Objects/odictobject.c:2349:15
#9 method_vectorcall_VARARGS_KEYWORDS /cpython/Objects/descrobject.c:359:14
#10 _PyObject_VectorcallTstate /cpython/./Include/internal/pycore_call.h:144:11
#11 PyObject_Vectorcall /cpython/Objects/call.c:327:12
#12 _Py_VectorCall_StackRefSteal /cpython/Python/ceval.c:726:11
#13 _PyEval_EvalFrameDefault /cpython/Python/generated_cases.c.h:4362:35
...
SUMMARY: ThreadSanitizer: data race /cpython/Objects/odictobject.c:1946:29 in odictiter_new
==================
...
==================
WARNING: ThreadSanitizer: data race (pid=2281797)
Atomic write of size 8 at 0x7bf61e0e0190 by thread T3:
#0 _Py_atomic_add_ssize /cpython/./Include/cpython/pyatomic_gcc.h:63:10
#1 Py_INCREF /cpython/./Include/refcount.h:283:9
#2 _Py_NewRef /cpython/./Include/refcount.h:536:5
#3 odictiter_new /cpython/Objects/odictobject.c:1946:29
#4 odict_iter /cpython/Objects/odictobject.c:1536:12
#5 PyObject_GetIter /cpython/Objects/abstract.c:2825:25
#6 _PyEval_GetIter /cpython/Python/ceval.c:1142:24
#7 _PyEval_EvalFrameDefault /cpython/Python/generated_cases.c.h:6595:38
...
Previous write of size 8 at 0x7bf61e0e0190 by thread T1:
#0 _odict_add_tail /cpython/Objects/odictobject.c:671:27
#1 _odict_add_new_node /cpython/Objects/odictobject.c:713:5
#2 _PyODict_SetItem_KnownHash_LockHeld /cpython/Objects/odictobject.c:1627:15
#3 PyODict_SetItem_LockHeld /cpython/Objects/odictobject.c:1647:12
#4 PyODict_SetItem /cpython/Objects/odictobject.c:1655:11
#5 odict_mp_ass_sub /cpython/Objects/odictobject.c:878:16
#6 PyObject_SetItem /cpython/Objects/abstract.c:245:19
#7 mutablemapping_add_pairs /cpython/Objects/odictobject.c:2251:15
#8 mutablemapping_update_arg /cpython/Objects/odictobject.c:2328:11
#9 mutablemapping_update /cpython/Objects/odictobject.c:2349:15
#10 method_vectorcall_VARARGS_KEYWORDS /cpython/Objects/descrobject.c:359:14
#11 _PyObject_VectorcallTstate /cpython/./Include/internal/pycore_call.h:144:11
#12 PyObject_Vectorcall /cpython/Objects/call.c:327:12
#13 _Py_VectorCall_StackRefSteal /cpython/Python/ceval.c:726:11
#14 _PyEval_EvalFrameDefault /cpython/Python/generated_cases.c.h:4362:35
...
SUMMARY: ThreadSanitizer: data race /cpython/./Include/cpython/pyatomic_gcc.h:63:10 in _Py_atomic_add_ssize
==================
ThreadSanitizer:DEADLYSIGNAL
==2281797==ERROR: ThreadSanitizer: SEGV on unknown address 0x000000000059 (pc 0x000000000059 bp 0x7bf615ffd840 sp 0x7bf615ffd818 T2281801)
==2281797==Hint: pc points to the zero page.
==2281797==The signal is caused by a READ memory access.
==2281797==Hint: address points to the zero page.
ThreadSanitizer:DEADLYSIGNAL
ThreadSanitizer: nested bug in the same thread, aborting.
CPython versions tested on:
CPython main branch
Operating systems tested on:
Linux