Skip to content

Commit 364cc82

Browse files
committed
gh-154130: Fix double decref of the dict in shared dict iterators
In the free-threaded build, threads advancing the same dict iterator each read `di_dict` as a borrowed reference, so concurrently exhausting it could run `di->di_dict = NULL; Py_DECREF(d)` more than once and free the dict while other threads were still using it. Reversed iterators were also affected: they dropped the reference while holding the dict's critical section. In the free-threaded build, keep the iterator's reference to the dict until the iterator is deallocated (as list and tuple iterators do) and mark exhaustion by setting `di_pos` to -1.
1 parent f65e2a2 commit 364cc82

2 files changed

Lines changed: 86 additions & 11 deletions

File tree

‎Lib/test/test_free_threading/test_dict.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,5 +493,50 @@ def race(iter_times: int,
493493
CHURN_SIZE = 512
494494
race(ITER_TIMES, CHURN_SIZE, APPENDER_THREADS, REPLACER_THREADS)
495495

496+
def test_racing_shared_iterator_exhaustion(self):
497+
# gh-154130: exhausting a dict iterator shared between threads must
498+
# not release the iterator's reference to the dict more than once.
499+
makers = [
500+
lambda d: iter(d),
501+
lambda d: iter(d.values()),
502+
lambda d: iter(d.items()),
503+
lambda d: reversed(d),
504+
lambda d: reversed(d.items()),
505+
]
506+
for make in makers:
507+
cell = [make({i: i for i in range(8)})]
508+
509+
def worker():
510+
for _ in range(5000):
511+
it = cell[0]
512+
try:
513+
next(it)
514+
except StopIteration:
515+
cell[0] = make({i: i for i in range(8)})
516+
except RuntimeError:
517+
# Concurrent next() calls may observe each other's
518+
# updates as a modification; only crashes are bugs.
519+
pass
520+
521+
with self.subTest(make=make):
522+
threading_helper.run_concurrently(worker, nthreads=4)
523+
524+
def test_exhausted_iterator_stays_exhausted(self):
525+
d = {1: 1}
526+
for it in (iter(d), iter(d.items()), reversed(d)):
527+
self.assertEqual(len(list(it)), 1)
528+
d[2] = 2
529+
self.assertRaises(StopIteration, next, it)
530+
self.assertEqual(it.__length_hint__(), 0)
531+
del d[2]
532+
533+
def test_reversed_empty_dict_iterator_not_exhausted(self):
534+
# A reverse iterator over an empty dict starts at position -1 but is
535+
# not exhausted, so a later size change must still be detected.
536+
d = {}
537+
it = reversed(d)
538+
d[1] = 1
539+
self.assertRaises(RuntimeError, next, it)
540+
496541
if __name__ == "__main__":
497542
unittest.main()

‎Objects/dictobject.c‎

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5705,6 +5705,28 @@ static PyMethodDef dictiter_methods[] = {
57055705
{NULL, NULL} /* sentinel */
57065706
};
57075707

5708+
/* Mark the iterator as exhausted. In the free-threaded build an iterator
5709+
may be advanced by several threads at once, each holding a borrowed
5710+
pointer to di_dict, so the iterator keeps its reference to the dict until
5711+
it is deallocated (as list and tuple iterators do) and records exhaustion
5712+
by setting di_pos to DICTITER_EXHAUSTED. This is distinct from -1, the
5713+
starting position of a reverse iterator over an empty dict. */
5714+
#ifdef Py_GIL_DISABLED
5715+
#define DICTITER_EXHAUSTED PY_SSIZE_T_MIN
5716+
#endif
5717+
5718+
static void
5719+
dictiter_set_exhausted(dictiterobject *di, PyDictObject *d)
5720+
{
5721+
#ifdef Py_GIL_DISABLED
5722+
_Py_atomic_store_ssize_relaxed(&di->di_pos, DICTITER_EXHAUSTED);
5723+
_Py_atomic_store_ssize_relaxed(&di->len, 0);
5724+
#else
5725+
di->di_dict = NULL;
5726+
Py_DECREF(d);
5727+
#endif
5728+
}
5729+
57085730
#ifdef Py_GIL_DISABLED
57095731

57105732
static int
@@ -5970,16 +5992,18 @@ dictiter_iternextitem_lock_held(PyDictObject *d, PyObject *self,
59705992
assert (PyAnyDict_Check(d));
59715993
ASSERT_DICT_LOCKED(d);
59725994

5995+
i = FT_ATOMIC_LOAD_SSIZE_RELAXED(di->di_pos);
5996+
if (i < 0) {
5997+
return -1; /* exhausted */
5998+
}
5999+
59736000
if (di->di_used != d->ma_used) {
59746001
PyErr_SetString(PyExc_RuntimeError,
59756002
"dictionary changed size during iteration");
59766003
di->di_used = -1; /* Make this state sticky */
59776004
return -1;
59786005
}
59796006

5980-
i = FT_ATOMIC_LOAD_SSIZE_RELAXED(di->di_pos);
5981-
5982-
assert(i >= 0);
59836007
if (_PyDict_HasSplitTable(d)) {
59846008
if (i >= d->ma_used)
59856009
goto fail;
@@ -6030,8 +6054,7 @@ dictiter_iternextitem_lock_held(PyDictObject *d, PyObject *self,
60306054
return 0;
60316055

60326056
fail:
6033-
di->di_dict = NULL;
6034-
Py_DECREF(d);
6057+
dictiter_set_exhausted(di, d);
60356058
return -1;
60366059
}
60376060

@@ -6075,6 +6098,11 @@ dictiter_iternext_threadsafe(PyDictObject *d, PyObject *self,
60756098

60766099
assert (PyAnyDict_Check(d));
60776100

6101+
i = _Py_atomic_load_ssize_relaxed(&di->di_pos);
6102+
if (i == DICTITER_EXHAUSTED) {
6103+
return -1;
6104+
}
6105+
60786106
if (di->di_used != _Py_atomic_load_ssize_relaxed(&d->ma_used)) {
60796107
PyErr_SetString(PyExc_RuntimeError,
60806108
"dictionary changed size during iteration");
@@ -6084,9 +6112,7 @@ dictiter_iternext_threadsafe(PyDictObject *d, PyObject *self,
60846112

60856113
ensure_shared_on_read(d);
60866114

6087-
i = _Py_atomic_load_ssize_relaxed(&di->di_pos);
60886115
k = _Py_atomic_load_ptr_acquire(&d->ma_keys);
6089-
assert(i >= 0);
60906116
if (_PyDict_HasSplitTable(d)) {
60916117
PyDictValues *values = _Py_atomic_load_ptr_consume(&d->ma_values);
60926118
if (values == NULL) {
@@ -6165,8 +6191,7 @@ dictiter_iternext_threadsafe(PyDictObject *d, PyObject *self,
61656191
"dictionary keys changed during iteration");
61666192

61676193
fail:
6168-
di->di_dict = NULL;
6169-
Py_DECREF(d);
6194+
dictiter_set_exhausted(di, d);
61706195
return -1;
61716196

61726197
try_locked:
@@ -6268,6 +6293,12 @@ dictreviter_iter_lock_held(PyDictObject *d, PyObject *self)
62686293
assert (PyAnyDict_Check(d));
62696294
ASSERT_DICT_LOCKED(d);
62706295

6296+
#ifdef Py_GIL_DISABLED
6297+
if (di->di_pos == DICTITER_EXHAUSTED) {
6298+
return NULL;
6299+
}
6300+
#endif
6301+
62716302
if (di->di_used != d->ma_used) {
62726303
PyErr_SetString(PyExc_RuntimeError,
62736304
"dictionary changed size during iteration");
@@ -6354,8 +6385,7 @@ dictreviter_iter_lock_held(PyDictObject *d, PyObject *self)
63546385
}
63556386

63566387
fail:
6357-
di->di_dict = NULL;
6358-
Py_DECREF(d);
6388+
dictiter_set_exhausted(di, d);
63596389
return NULL;
63606390
}
63616391

0 commit comments

Comments
 (0)