From f01610e588059831a7a9d02cd1794a1724a55b31 Mon Sep 17 00:00:00 2001 From: Kailash Nelson <37966146+KingLizard1020@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:52:05 -0600 Subject: [PATCH 1/5] gh-157217: Add mappingproxy accessor for dict_merge --- Include/internal/pycore_descrobject.h | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Include/internal/pycore_descrobject.h b/Include/internal/pycore_descrobject.h index 6143f82176a1f2f..1ca6162c22a390b 100644 --- a/Include/internal/pycore_descrobject.h +++ b/Include/internal/pycore_descrobject.h @@ -24,6 +24,21 @@ extern PyTypeObject _PyMethodWrapper_Type; extern void *_PyMember_GetOffset(PyObject *, PyMemberDef *); +/* Return a borrowed reference to the mapping wrapped by a mappingproxy. + * The struct layout matches mappingproxyobject in Objects/descrobject.c. + */ +static inline PyObject * +_PyDictProxy_GetMapping(PyObject *op) +{ + typedef struct { + PyObject_HEAD + PyObject *mapping; + } _PyMappingProxyObject; + assert(op != NULL); + assert(PyObject_TypeCheck(op, &PyDictProxy_Type)); + return ((_PyMappingProxyObject *)op)->mapping; +} + #ifdef __cplusplus } #endif From 784eeb1955745b1d5ad9351cca30b6d83fbd58dd Mon Sep 17 00:00:00 2001 From: Kailash Nelson <37966146+KingLizard1020@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:52:08 -0600 Subject: [PATCH 2/5] gh-157217: Add NEWS blurb for mappingproxy dict_merge fix --- .../2026-09-10-16-20-00.gh-issue-157217.dirlock.rst | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 Misc/NEWS.d/next/Core_and_Builtins/2026-09-10-16-20-00.gh-issue-157217.dirlock.rst 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. From 60be5a2e4941ae7854b5ff3a39b34b2d462aaa36 Mon Sep 17 00:00:00 2001 From: Kailash Nelson <37966146+KingLizard1020@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:52:09 -0600 Subject: [PATCH 3/5] gh-157217: Add mappingproxy dict() and update tests --- Lib/test/test_dict_mappingproxy.py | 34 ++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 Lib/test/test_dict_mappingproxy.py 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() From ba3b9cde9205892a2a35a140f128fc14f42839a7 Mon Sep 17 00:00:00 2001 From: Kailash Nelson <37966146+KingLizard1020@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:55:56 -0600 Subject: [PATCH 4/5] gh-157217: Add free-threading dir() race test --- Lib/test/test_free_threading/test_type.py | 42 ++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) 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() From 6a30fd499ff6e41e41a30b181d12274f4c17032f Mon Sep 17 00:00:00 2001 From: Kailash Nelson <37966146+KingLizard1020@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:14:47 +0000 Subject: [PATCH 5/5] gh-157217: Unwrap mappingproxy in dict_merge --- Objects/dictobject.c | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Objects/dictobject.c b/Objects/dictobject.c index 357e714de9a6c0e..c31db624ecf57b5 100644 --- a/Objects/dictobject.c +++ b/Objects/dictobject.c @@ -121,6 +121,7 @@ As a consequence of this, split keys have a maximum size of 16. #include "pycore_ceval.h" // _PyEval_GetBuiltin() #include "pycore_code.h" // stats #include "pycore_critical_section.h" // Py_BEGIN_CRITICAL_SECTION, Py_END_CRITICAL_SECTION +#include "pycore_descrobject.h" // _PyDictProxy_GetMapping() #include "pycore_dict.h" // export _PyDict_SizeOf() #include "pycore_freelist.h" // _PyFreeListState_GET() #include "pycore_gc.h" // _PyObject_GC_IS_TRACKED() @@ -4305,11 +4306,21 @@ 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. + * See gh-157217. + */ + PyObject *source = b; + if (PyObject_TypeCheck(b, &PyDictProxy_Type)) { + source = _PyDictProxy_GetMapping(b); + } + 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);