Skip to content

Commit 03cb1ad

Browse files
authored
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
1 parent 8bfda55 commit 03cb1ad

3 files changed

Lines changed: 81 additions & 3 deletions

File tree

‎Lib/test/test_dict.py‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1835,6 +1835,45 @@ def __del__(self):
18351835
d = x.__dict__
18361836
d.clear()
18371837

1838+
def test_clear_reentrant_detached_split(self):
1839+
# gh-158198 follow-up: dict.clear() on a detached split-table dict
1840+
# must not expose (Py_EMPTY_KEYS, non-NULL ma_values) to a
1841+
# finalizer. A reentrant insert must see a consistent empty dict.
1842+
class MyObj: pass
1843+
class Reinsert:
1844+
def __del__(self):
1845+
d['reentrant'] = 1
1846+
1847+
x = MyObj()
1848+
x.a = Reinsert()
1849+
x.b = 'other'
1850+
1851+
d = x.__dict__
1852+
# Detach: the old dict becomes a heap-allocated split table
1853+
# (ma_values non-NULL, embedded == 0).
1854+
x.__dict__ = {}
1855+
d.clear()
1856+
self.assertEqual(d, {'reentrant': 1})
1857+
1858+
def test_clear_reentrant_detached_split_update(self):
1859+
# Same as above but with a reentrant update large enough to force
1860+
# a resize. On the buggy code this frees the values array that
1861+
# clear() is still iterating over (use-after-free / segfault).
1862+
class MyObj: pass
1863+
other = {f'k{i}': i for i in range(20)}
1864+
class Reinsert:
1865+
def __del__(self):
1866+
d.update(other)
1867+
1868+
x = MyObj()
1869+
x.a = Reinsert()
1870+
x.b = 'other'
1871+
1872+
d = x.__dict__
1873+
x.__dict__ = {}
1874+
d.clear()
1875+
self.assertEqual(d, other)
1876+
18381877

18391878
class CAPITest(unittest.TestCase):
18401879

‎Lib/test/test_free_threading/test_dict.py‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from ast import Or
77
from functools import partial
8-
from threading import Barrier, Thread
8+
from threading import Barrier, Event, Thread
99
from unittest import TestCase
1010

1111
try:
@@ -437,6 +437,45 @@ def writer():
437437

438438
threading_helper.run_concurrently([reader, writer])
439439

440+
def test_racing_dealloc_and_split_dict_resize(self):
441+
# gh-158198: an object's inline-values __dict__ escapes to another
442+
# thread, which converts it to a combined table (by adding a non-str
443+
# key) while the owning thread drops the last reference to the object.
444+
NATTRS = 20
445+
446+
class C:
447+
def __init__(self):
448+
for i in range(NATTRS):
449+
setattr(self, f"a{i}", i)
450+
451+
def make_pair():
452+
slot = [None]
453+
done = Event()
454+
455+
def owner():
456+
try:
457+
for j in range(2000):
458+
o = C()
459+
for i in range(NATTRS):
460+
self.assertEqual(getattr(o, f"a{i}"), i)
461+
slot[0] = o.__dict__
462+
for _ in range(j % 16):
463+
pass
464+
del o
465+
finally:
466+
done.set()
467+
468+
def converter():
469+
while not done.is_set():
470+
d = slot[0]
471+
if d is not None:
472+
d[0] = 0
473+
slot[0] = None
474+
475+
return [owner, converter]
476+
477+
threading_helper.run_concurrently(make_pair() + make_pair())
478+
440479
def test_racing_dict_update_and_method_lookup_with_inline_values(self):
441480
# gh-149816: sub-case 108
442481
# The race below checks that a detached dict is still valid

‎Objects/dictobject.c‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2276,7 +2276,6 @@ dictresize(PyDictObject *mp,
22762276
UNLOCK_KEYS(oldkeys);
22772277
set_keys(mp, newkeys);
22782278
dictkeys_decref(oldkeys, IS_DICT_SHARED(mp));
2279-
set_values(mp, NULL);
22802279
if (oldvalues->embedded) {
22812280
assert(oldvalues->embedded == 1);
22822281
assert(oldvalues->valid == 1);
@@ -2285,6 +2284,7 @@ dictresize(PyDictObject *mp,
22852284
else {
22862285
free_values(oldvalues, IS_DICT_SHARED(mp));
22872286
}
2287+
set_values(mp, NULL);
22882288
}
22892289
else { // oldkeys is combined.
22902290
if (oldkeys->dk_kind == DICT_KEYS_GENERAL) {
@@ -8024,7 +8024,7 @@ PyObject_ClearManagedDict(PyObject *obj)
80248024
clear_inline_values(_PyObject_InlineValues(obj));
80258025
return;
80268026
}
8027-
else if (FT_ATOMIC_LOAD_PTR_RELAXED(dict->ma_values) ==
8027+
if (FT_ATOMIC_LOAD_PTR_ACQUIRE(dict->ma_values) ==
80288028
_PyObject_InlineValues(obj)) {
80298029
// We have a materialized object which points at the inline
80308030
// values. We need to materialize the keys. Nothing can modify

0 commit comments

Comments
 (0)