diff --git a/Lib/test/test_dict_mappingproxy.py b/Lib/test/test_dict_mappingproxy.py new file mode 100644 index 000000000000000..c2abf03f19e0d2c --- /dev/null +++ b/Lib/test/test_dict_mappingproxy.py @@ -0,0 +1,34 @@ +"""Tests for dict() / update / unpacking of types.MappingProxyType. + +gh-157217: merging from a mappingproxy wrapping a dict should take the +locked dict-to-dict path instead of iterating the proxy unlocked. +""" + +import collections +import types +import unittest + + +class MappingProxyDictMergeTests(unittest.TestCase): + def test_update_from_mappingproxy_dict(self): + d = {} + d.update(types.MappingProxyType({1: 1, 2: 2, 3: 3})) + self.assertEqual(d, {1: 1, 2: 2, 3: 3}) + + def test_dict_constructor_from_mappingproxy(self): + view = types.MappingProxyType({'a': 1, 'b': 2}) + self.assertEqual(dict(view), {'a': 1, 'b': 2}) + self.assertEqual({**view}, {'a': 1, 'b': 2}) + dest = {'z': 0} + dest.update(view) + self.assertEqual(dest, {'z': 0, 'a': 1, 'b': 2}) + + def test_dict_constructor_from_mappingproxy_userdict(self): + self.assertEqual( + dict(types.MappingProxyType(collections.UserDict(x=1))), + {'x': 1}, + ) + + +if __name__ == '__main__': + unittest.main() diff --git a/Lib/test/test_free_threading/test_type.py b/Lib/test/test_free_threading/test_type.py index a55c4815a038376..7bcd7425f46536d 100644 --- a/Lib/test/test_free_threading/test_type.py +++ b/Lib/test/test_free_threading/test_type.py @@ -5,7 +5,7 @@ from threading import Barrier, Thread from unittest import TestCase import sys -from test.support import import_helper, threading_helper +from test.support import import_helper, threading_helper, Py_GIL_DISABLED _testinternalcapi = import_helper.import_module("_testinternalcapi") @@ -401,6 +401,46 @@ class B(A): with threading_helper.start_threads(threads): pass + @unittest.skipUnless(Py_GIL_DISABLED, + "race only occurs on the free-threaded build") + def test_dir_racing_class_dict_insert(self): + # gh-157217: dir() iterated a mappingproxy of the class dict without + # holding that dict's critical section. A concurrent insert into the + # class dict (for example a lazy __annotations_cache__) then raised + # RuntimeError: dictionary changed size during iteration. + errors = [] + + class C: + x: int + + for i in range(200): + setattr(C, f'attr_{i}', i) + + def reader(): + barrier.wait() + for _ in range(400): + try: + dir(C) + dict(vars(C)) + {**vars(C)} + except RuntimeError as exc: + errors.append(exc) + + def writer(): + barrier.wait() + # First access stores __annotations_cache__ on the class. + C.__annotations__ + for i in range(200): + setattr(C, f'extra_{i}', i) + + n_readers = 4 + barrier = threading.Barrier(n_readers + 1) + threads = [Thread(target=reader) for _ in range(n_readers)] + threads.append(Thread(target=writer)) + with threading_helper.start_threads(threads): + pass + self.assertEqual(errors, []) + if __name__ == "__main__": unittest.main() diff --git a/Misc/NEWS.d/next/Core_and_Builtins/2026-09-10-16-20-00.gh-issue-157217.dirlock.rst b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-10-16-20-00.gh-issue-157217.dirlock.rst new file mode 100644 index 000000000000000..0a6be4dad5f6887 --- /dev/null +++ b/Misc/NEWS.d/next/Core_and_Builtins/2026-09-10-16-20-00.gh-issue-157217.dirlock.rst @@ -0,0 +1,4 @@ +Fix :func:`dir` raising :exc:`RuntimeError` on the free-threaded build +when another thread concurrently inserts into a class ``__dict__``. +``dict.update()`` and related merges now take the locked dict-to-dict path +when the source is a :class:`types.MappingProxyType` wrapping a dict. diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 357e714de9a6c0e..ef389e05082fcfe 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -4305,11 +4305,25 @@ dict_merge(PyObject *a, PyObject *b, int override, PyObject **dupkey) PyDictObject *mp = _PyAnyDict_CAST(a); + /* Mapping proxies (including type.__dict__) wrap a real dict. Unwrap + * so we take the locked dict-to-dict path instead of iterating the + * proxy without holding the underlying dict's critical section. + * Layout must match mappingproxyobject in descrobject.c. See gh-157217. + */ + typedef struct { + PyObject_HEAD + PyObject *mapping; + } mappingproxyobject; + PyObject *source = b; + if (Py_IS_TYPE(b, &PyDictProxy_Type)) { + source = ((mappingproxyobject *)b)->mapping; + } + int res = 0; - if (PyAnyDict_Check(b) && (Py_TYPE(b)->tp_iter == dict_iter)) { - PyDictObject *other = (PyDictObject*)b; + if (PyAnyDict_Check(source) && (Py_TYPE(source)->tp_iter == dict_iter)) { + PyDictObject *other = (PyDictObject*)source; int res; - Py_BEGIN_CRITICAL_SECTION2(a, b); + Py_BEGIN_CRITICAL_SECTION2(a, source); assert(can_modify_dict(mp)); res = dict_dict_merge((PyDictObject *)a, other, override, dupkey); ASSERT_CONSISTENT(a);