From acc66695edb2afc0d3d6feda4b516c2ad07bda86 Mon Sep 17 00:00:00 2001 From: Lonnie Hutchinson Date: Tue, 1 Sep 2026 07:21:28 -0700 Subject: [PATCH 1/3] gh-156544: add UserDict unit tests to create baseline for upcoming change to fix race condition. 1) ensure UserDict item access matches dict 2) test exactly how UserDict.get() and __getitem() interact with its wrapped dict. This commit does not make any functional changes, rather it documents in the form of tests what the existing interaction between UserDict and the dict it wraps is so that a subsequent commit that changes it will clearly show how it this interaction changes. The concern is the changes to make the fix may be considered breaking. Including the changes in behavior in the PR for the fix will make it easier for reviewers to see exactly what effect the change has. --- Lib/test/test_userdict.py | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/Lib/test/test_userdict.py b/Lib/test/test_userdict.py index c60135ca5a12a8f..ee2cfe99db238db 100644 --- a/Lib/test/test_userdict.py +++ b/Lib/test/test_userdict.py @@ -307,6 +307,66 @@ def test_mixed_ior(self): self.assertIs(type(u), UserDictSubclass) self.assertIs(u, u2) + def test_matches_dict(self): + key, value, missing = object(), object(), object() + class Missing: + def __missing__(self, key): + return missing + class _Dict(dict, Missing): pass + class _UserDict(UserDict, Missing): pass + _dict = _Dict({key: value}) + _user_dict = _UserDict({key: value}) + self.assertIs(_dict[key], _user_dict[key]) + self.assertIs(_dict.get(key), _user_dict.get(key)) + self.assertIs(_dict.get(missing), _user_dict.get(missing)) + self.assertIs(_dict[missing], _user_dict[missing]) + + def test_data_delegation(self): + class Dict(dict): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.calls = [] + def __contains__(self, *args, **kwargs): + self.calls.append('__contains__') + return super().__contains__(*args, **kwargs) + def __getitem__(self, *args, **kwargs): + self.calls.append('__getitem__') + return super().__getitem__(*args, **kwargs) + def __missing__(self, *args, **kwargs): + self.calls.append('__missing__') + if hasattr(self.data, '__missing__'): + return super().__missing__(*args, **kwargs) + def get(self, *args, **kwargs): + self.calls.append('get') + return super().get(*args, **kwargs) + class _UserDict(UserDict): + def __init__(self, **kwargs): + super().__init__() + self.data = Dict(**kwargs) + def __missing__(self, key): + return 'missing' + + # get with value + _dict = _UserDict(key='value') + self.assertEqual('value', _dict.get('key')) + self.assertEqual(['__contains__', '__contains__', '__getitem__'], # LH ??? why two contains + _dict.data.calls) + + # get without value + _dict = _UserDict() + self.assertEqual(None, _dict.get('key')) + self.assertEqual(['__contains__'], _dict.data.calls) + + # getitem with value + _dict = _UserDict(key='value') + self.assertEqual('value', _dict['key']) + self.assertEqual(['__contains__', '__getitem__'], _dict.data.calls) + + # getitem without value + _dict = _UserDict() + self.assertEqual('missing', _dict['key']) + self.assertEqual(['__contains__'], _dict.data.calls) + if __name__ == "__main__": unittest.main() From 8c8fc8e18116475f3ca3b2371a9cec2c28d3f6b6 Mon Sep 17 00:00:00 2001 From: Lonnie Hutchinson Date: Mon, 31 Aug 2026 14:23:30 -0700 Subject: [PATCH 2/3] gh-156544: fix free-threading race in UserDict.get() and __getitem__(). When UserDict.__delitem__, pop(), __popitem__(), or clear() are called concurrently with get() or __getitem__ a race can occur in that get() or __getitem__ can see the item as being contained and then they use self.data[key] to return the item. However, if the item is removed after the containment check this subscript access will raise KeyError and get() and __getitem__() will propogate it, resulting in get() not returning default and __getitem() incorrectly skipping the call it should make to __missing__ (if defined). This fix detects this race condition by ignoring the KeyError after containment checks to allow default to be returned or __missing__ to be called. It is implemented in this unusual way that preserves the containment check rather than relying solely on KeyError in order to preserve existing semantics and performance characteristics. Removing the call to __contains__ on self.data would change how UserData interacts with it and could potentially break code that relies on this call. In addition to preserving the current semantics, get() with a sentinel is not used to atomically detect if the item is missing. Changing 'in' and '[key]' to a single call to get() would replace these optimized operators with a more expensive function call. Because exceptions incur overhead only when raised, this implementation preserves existing performance for all cases except when the race actually occurs, but ensures correct functionality when it does. It also preserves the interaction semantics with the wrapped dict. --- Lib/collections/__init__.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Lib/collections/__init__.py b/Lib/collections/__init__.py index 5dbcac19e7a9272..630f1d4620ae11e 100644 --- a/Lib/collections/__init__.py +++ b/Lib/collections/__init__.py @@ -1187,7 +1187,10 @@ def __len__(self): def __getitem__(self, key): if key in self.data: - return self.data[key] + try: + return self.data[key] + except KeyError: + pass if hasattr(self.__class__, "__missing__"): return self.__class__.__missing__(self, key) raise KeyError(key) @@ -1208,7 +1211,10 @@ def __contains__(self, key): def get(self, key, default=None): if key in self: - return self[key] + try: + return self[key] + except KeyError: + pass return default From 21ed00f7cdf7201a7f096de9c264edbf30bc95ee Mon Sep 17 00:00:00 2001 From: "blurb-it[bot]" <43283697+blurb-it[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:48:43 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=9C=F0=9F=A4=96=20Added=20by=20blu?= =?UTF-8?q?rb=5Fit.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../next/Library/2026-09-10-20-48-42.gh-issue-156544.YGR1Ig.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 Misc/NEWS.d/next/Library/2026-09-10-20-48-42.gh-issue-156544.YGR1Ig.rst diff --git a/Misc/NEWS.d/next/Library/2026-09-10-20-48-42.gh-issue-156544.YGR1Ig.rst b/Misc/NEWS.d/next/Library/2026-09-10-20-48-42.gh-issue-156544.YGR1Ig.rst new file mode 100644 index 000000000000000..70bd793d714244f --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-10-20-48-42.gh-issue-156544.YGR1Ig.rst @@ -0,0 +1 @@ +Fix free-threading race in which UserDict.__getitem__ may not call __missing__ and UserDict.get() may not return the default.