From d45e42d901b14167d1a2ee3c88bb5c6625eb7d81 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Tue, 23 Jun 2026 11:05:29 +0800 Subject: [PATCH] gh-151722: Defer GC tracking of frozendict.fromkeys() result until fully built frozendict.fromkeys() built its result with PyIter_Next() on an already GC-tracked object, so a half-built frozendict was reachable from another thread (using the gc module) and could be observed mutating mid-construction in the free threading build. Untrack the result while it is being filled and re-track it once fully built. --- .../test_free_threading/test_frozendict.py | 69 +++++++++++++++++++ ...-06-23-09-10-00.gh-issue-151722.Kq7mB3.rst | 3 + Objects/dictobject.c | 10 +++ 3 files changed, 82 insertions(+) create mode 100644 Lib/test/test_free_threading/test_frozendict.py create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-06-23-09-10-00.gh-issue-151722.Kq7mB3.rst diff --git a/Lib/test/test_free_threading/test_frozendict.py b/Lib/test/test_free_threading/test_frozendict.py new file mode 100644 index 000000000000000..581b2527619803c --- /dev/null +++ b/Lib/test/test_free_threading/test_frozendict.py @@ -0,0 +1,69 @@ +import gc +import unittest +from threading import Event, Thread + +from test.support import threading_helper + + +@threading_helper.requires_working_threading() +class TestFrozenDict(unittest.TestCase): + def test_racing_reads_during_fromkeys(self): + # gh-151722: frozendict.fromkeys() builds its result from a generic + # iterable in a fill loop; the result must not be GC-tracked until it + # is fully populated, otherwise a half-built instance is observable + # from other threads. Reading it with len()/repr()/hash() must not + # race with the fill-time table and ma_used writes. + NUM_KEYS = 5000 + NUM_ROUNDS = 20 + SENTINEL = "test_racing_reads_during_fromkeys_0" + + empty = frozendict() + latest = [empty] # main -> reader handoff, never empty + done = Event() + errors = [] + + def find_half_built(): + for obj in gc.get_objects(): + if (isinstance(obj, frozendict) + and obj is not empty + and 0 < len(obj) < NUM_KEYS + and SENTINEL in obj): + return obj + return None + + class EvilIter: + def __iter__(self): + yield SENTINEL + for i in range(1, NUM_KEYS): + if (i & 0x3FF) == 0 and latest[0] is empty: + obj = find_half_built() + if obj is not None: + latest[0] = obj # leak the half-built object + yield f"k{i}" + + def reader(): + while not done.is_set(): + fd = latest[0] + try: + len(fd) + repr(fd) + hash(fd) + except Exception as exc: + errors.append(exc) + + readers = [Thread(target=reader) for _ in range(5)] + for t in readers: + t.start() + try: + for _ in range(NUM_ROUNDS): + latest[0] = empty + frozendict.fromkeys(EvilIter(), 0) + finally: + done.set() + for t in readers: + t.join() + self.assertEqual(errors, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-06-23-09-10-00.gh-issue-151722.Kq7mB3.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-23-09-10-00.gh-issue-151722.Kq7mB3.rst new file mode 100644 index 000000000000000..75020ead4258e92 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-06-23-09-10-00.gh-issue-151722.Kq7mB3.rst @@ -0,0 +1,3 @@ +Defer GC tracking of a :class:`frozendict` created by ``frozendict.fromkeys()`` +until the end of construction, so a half-built instance is no longer observable +from another thread in the free threading build. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 9210398ee551de1..b0b3abcedc40446 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -3519,6 +3519,13 @@ dict_iter_exit:; Py_END_CRITICAL_SECTION(); } else if (PyFrozenDict_Check(d)) { + /* gh-151722: Keep the frozendict untracked while it is being filled, + so a half-built object is never reachable from another thread + (using the gc module). */ + int was_tracked = _PyObject_GC_IS_TRACKED(d); + if (was_tracked) { + _PyObject_GC_UNTRACK(d); + } while ((key = PyIter_Next(it)) != NULL) { // setitem_take2_lock_held consumes a reference to key status = setitem_take2_lock_held((PyDictObject *)d, @@ -3528,6 +3535,9 @@ dict_iter_exit:; goto Fail; } } + if (was_tracked) { + _PyObject_GC_TRACK(d); + } } else { while ((key = PyIter_Next(it)) != NULL) {