https://github.com/python/cpython/commit/03cb1adb390661d61ef3c2b96d807547b0d90368
commit: 03cb1adb390661d61ef3c2b96d807547b0d90368
branch: main
author: Dino Viehland <[email protected]>
committer: DinoV <[email protected]>
date: 2026-10-22T15:55:35-07:00
summary:

gh-158198: Fix crash when de-inlining dict while freeing owning object (#160114)

* gh-158198: Fix crash when de-inlining dict while freeing owning object

files:
M Lib/test/test_dict.py
M Lib/test/test_free_threading/test_dict.py
M Objects/dictobject.c

diff --git a/Lib/test/test_dict.py b/Lib/test/test_dict.py
index 9a9e738a7cf76e..ea697a5a920f77 100644
--- a/Lib/test/test_dict.py
+++ b/Lib/test/test_dict.py
@@ -1835,6 +1835,45 @@ def __del__(self):
         d = x.__dict__
         d.clear()
 
+    def test_clear_reentrant_detached_split(self):
+        # gh-158198 follow-up: dict.clear() on a detached split-table dict
+        # must not expose (Py_EMPTY_KEYS, non-NULL ma_values) to a
+        # finalizer.  A reentrant insert must see a consistent empty dict.
+        class MyObj: pass
+        class Reinsert:
+            def __del__(self):
+                d['reentrant'] = 1
+
+        x = MyObj()
+        x.a = Reinsert()
+        x.b = 'other'
+
+        d = x.__dict__
+        # Detach: the old dict becomes a heap-allocated split table
+        # (ma_values non-NULL, embedded == 0).
+        x.__dict__ = {}
+        d.clear()
+        self.assertEqual(d, {'reentrant': 1})
+
+    def test_clear_reentrant_detached_split_update(self):
+        # Same as above but with a reentrant update large enough to force
+        # a resize.  On the buggy code this frees the values array that
+        # clear() is still iterating over (use-after-free / segfault).
+        class MyObj: pass
+        other = {f'k{i}': i for i in range(20)}
+        class Reinsert:
+            def __del__(self):
+                d.update(other)
+
+        x = MyObj()
+        x.a = Reinsert()
+        x.b = 'other'
+
+        d = x.__dict__
+        x.__dict__ = {}
+        d.clear()
+        self.assertEqual(d, other)
+
 
 class CAPITest(unittest.TestCase):
 
diff --git a/Lib/test/test_free_threading/test_dict.py 
b/Lib/test/test_free_threading/test_dict.py
index 12cea4b98a1724..a888637fda43c8 100644
--- a/Lib/test/test_free_threading/test_dict.py
+++ b/Lib/test/test_free_threading/test_dict.py
@@ -5,7 +5,7 @@
 
 from ast import Or
 from functools import partial
-from threading import Barrier, Thread
+from threading import Barrier, Event, Thread
 from unittest import TestCase
 
 try:
@@ -437,6 +437,45 @@ def writer():
 
         threading_helper.run_concurrently([reader, writer])
 
+    def test_racing_dealloc_and_split_dict_resize(self):
+        # gh-158198: an object's inline-values __dict__ escapes to another
+        # thread, which converts it to a combined table (by adding a non-str
+        # key) while the owning thread drops the last reference to the object.
+        NATTRS = 20
+
+        class C:
+            def __init__(self):
+                for i in range(NATTRS):
+                    setattr(self, f"a{i}", i)
+
+        def make_pair():
+            slot = [None]
+            done = Event()
+
+            def owner():
+                try:
+                    for j in range(2000):
+                        o = C()
+                        for i in range(NATTRS):
+                            self.assertEqual(getattr(o, f"a{i}"), i)
+                        slot[0] = o.__dict__
+                        for _ in range(j % 16):
+                            pass
+                        del o
+                finally:
+                    done.set()
+
+            def converter():
+                while not done.is_set():
+                    d = slot[0]
+                    if d is not None:
+                        d[0] = 0
+                        slot[0] = None
+
+            return [owner, converter]
+
+        threading_helper.run_concurrently(make_pair() + make_pair())
+
     def test_racing_dict_update_and_method_lookup_with_inline_values(self):
         # gh-149816: sub-case 108
         # The race below checks that a detached dict is still valid
diff --git a/Objects/dictobject.c b/Objects/dictobject.c
index e7ec0fd7c0e56e..f00cf75f6a762d 100644
--- a/Objects/dictobject.c
+++ b/Objects/dictobject.c
@@ -2276,7 +2276,6 @@ dictresize(PyDictObject *mp,
         UNLOCK_KEYS(oldkeys);
         set_keys(mp, newkeys);
         dictkeys_decref(oldkeys, IS_DICT_SHARED(mp));
-        set_values(mp, NULL);
         if (oldvalues->embedded) {
             assert(oldvalues->embedded == 1);
             assert(oldvalues->valid == 1);
@@ -2285,6 +2284,7 @@ dictresize(PyDictObject *mp,
         else {
             free_values(oldvalues, IS_DICT_SHARED(mp));
         }
+        set_values(mp, NULL);
     }
     else {  // oldkeys is combined.
         if (oldkeys->dk_kind == DICT_KEYS_GENERAL) {
@@ -8024,7 +8024,7 @@ PyObject_ClearManagedDict(PyObject *obj)
             clear_inline_values(_PyObject_InlineValues(obj));
             return;
         }
-        else if (FT_ATOMIC_LOAD_PTR_RELAXED(dict->ma_values) ==
+        if (FT_ATOMIC_LOAD_PTR_ACQUIRE(dict->ma_values) ==
                     _PyObject_InlineValues(obj)) {
             // We have a materialized object which points at the inline
             // values. We need to materialize the keys. Nothing can modify

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to