diff --git a/Lib/test/test_free_threading/test_dict.py b/Lib/test/test_free_threading/test_dict.py new file mode 100644 index 00000000000..4a812275143 --- /dev/null +++ b/Lib/test/test_free_threading/test_dict.py @@ -0,0 +1,360 @@ +import gc +import time +import unittest +import weakref + +from ast import Or +from functools import partial +from threading import Barrier, Thread +from unittest import TestCase + +try: + import _testcapi +except ImportError: + _testcapi = None + +from test.support import threading_helper + + +@threading_helper.requires_working_threading() +class TestDict(TestCase): + def test_racing_creation_shared_keys(self): + """Verify that creating dictionaries is thread safe when we + have a type with shared keys""" + class C(int): + pass + + self.racing_creation(C) + + def test_racing_creation_no_shared_keys(self): + """Verify that creating dictionaries is thread safe when we + have a type with an ordinary dict""" + self.racing_creation(Or) + + def test_racing_creation_inline_values_invalid(self): + """Verify that re-creating a dict after we have invalid inline values + is thread safe""" + class C: + pass + + def make_obj(): + a = C() + # Make object, make inline values invalid, and then delete dict + a.__dict__ = {} + del a.__dict__ + return a + + self.racing_creation(make_obj) + + def test_racing_creation_nonmanaged_dict(self): + """Verify that explicit creation of an unmanaged dict is thread safe + outside of the normal attribute setting code path""" + def make_obj(): + def f(): pass + return f + + def set(func, name, val): + # Force creation of the dict via PyObject_GenericGetDict + func.__dict__[name] = val + + self.racing_creation(make_obj, set) + + def racing_creation(self, cls, set=setattr): + objects = [] + processed = [] + + OBJECT_COUNT = 100 + THREAD_COUNT = 10 + CUR = 0 + + for i in range(OBJECT_COUNT): + objects.append(cls()) + + def writer_func(name): + last = -1 + while True: + if CUR == last: + time.sleep(0.001) + continue + elif CUR == OBJECT_COUNT: + break + + obj = objects[CUR] + set(obj, name, name) + last = CUR + processed.append(name) + + writers = [] + for x in range(THREAD_COUNT): + writer = Thread(target=partial(writer_func, f"a{x:02}")) + writers.append(writer) + writer.start() + + for i in range(OBJECT_COUNT): + CUR = i + while len(processed) != THREAD_COUNT: + time.sleep(0.001) + processed.clear() + + CUR = OBJECT_COUNT + + for writer in writers: + writer.join() + + for obj_idx, obj in enumerate(objects): + assert ( + len(obj.__dict__) == THREAD_COUNT + ), f"{len(obj.__dict__)} {obj.__dict__!r} {obj_idx}" + for i in range(THREAD_COUNT): + assert f"a{i:02}" in obj.__dict__, f"a{i:02} missing at {obj_idx}" + + def test_racing_set_dict(self): + """Races assigning to __dict__ should be thread safe""" + + def f(): pass + l = [] + THREAD_COUNT = 10 + class MyDict(dict): pass + + def writer_func(l): + for i in range(1000): + d = MyDict() + l.append(weakref.ref(d)) + f.__dict__ = d + + lists = [] + writers = [] + for x in range(THREAD_COUNT): + thread_list = [] + lists.append(thread_list) + writer = Thread(target=partial(writer_func, thread_list)) + writers.append(writer) + + for writer in writers: + writer.start() + + for writer in writers: + writer.join() + + f.__dict__ = {} + gc.collect() + + for thread_list in lists: + for ref in thread_list: + self.assertIsNone(ref()) + + def test_racing_get_set_dict(self): + """Races getting and setting a dict should be thread safe""" + THREAD_COUNT = 10 + barrier = Barrier(THREAD_COUNT) + def work(d): + barrier.wait() + for _ in range(1000): + d[10] = 0 + d.get(10, None) + _ = d[10] + + d = {} + worker_threads = [] + for ii in range(THREAD_COUNT): + worker_threads.append(Thread(target=work, args=[d])) + for t in worker_threads: + t.start() + for t in worker_threads: + t.join() + + + def test_racing_set_object_dict(self): + """Races assigning to __dict__ should be thread safe""" + class C: pass + class MyDict(dict): pass + for cyclic in (False, True): + f = C() + f.__dict__ = {"foo": 42} + THREAD_COUNT = 10 + + def writer_func(l): + for i in range(1000): + if cyclic: + other_d = {} + d = MyDict({"foo": 100}) + if cyclic: + d["x"] = other_d + other_d["bar"] = d + l.append(weakref.ref(d)) + f.__dict__ = d + + def reader_func(): + for i in range(1000): + f.foo + + lists = [] + readers = [] + writers = [] + for x in range(THREAD_COUNT): + thread_list = [] + lists.append(thread_list) + writer = Thread(target=partial(writer_func, thread_list)) + writers.append(writer) + + for x in range(THREAD_COUNT): + reader = Thread(target=partial(reader_func)) + readers.append(reader) + + for writer in writers: + writer.start() + for reader in readers: + reader.start() + + for writer in writers: + writer.join() + + for reader in readers: + reader.join() + + f.__dict__ = {} + gc.collect() + gc.collect() + + count = 0 + ids = set() + for thread_list in lists: + for i, ref in enumerate(thread_list): + if ref() is None: + continue + count += 1 + ids.add(id(ref())) + count += 1 + + self.assertEqual(count, 0) + + def test_racing_object_get_set_dict(self): + e = Exception() + + def writer(): + for i in range(10000): + e.__dict__ = {1:2} + + def reader(): + for i in range(10000): + e.__dict__ + + t1 = Thread(target=writer) + t2 = Thread(target=reader) + + with threading_helper.start_threads([t1, t2]): + pass + + @unittest.skipIf(_testcapi is None, "requires _testcapi") + def test_racing_watch_unwatch_dict(self): + # gh-148393: race between PyDict_Watch / PyDict_Unwatch + # and concurrent dict mutation reading _ma_watcher_tag. + wid = _testcapi.add_dict_watcher(0) + try: + d = {} + ITERS = 1000 + + def writer(): + for i in range(ITERS): + d[i] = i + del d[i] + + def watcher(): + for _ in range(ITERS): + _testcapi.watch_dict(wid, d) + _testcapi.unwatch_dict(wid, d) + + threading_helper.run_concurrently([writer, watcher]) + finally: + _testcapi.clear_dict_watcher(wid) + + def test_racing_split_dict_clear_and_lookup(self): + class C: + pass + + keys = [f"a{i}" for i in range(16)] + + def make_split_nonembedded(): + inst = C() + for key in keys: + setattr(inst, key, keys.index(key)) + # dict.copy() of a split instance dict yields a split table + # with non-embedded values + return inst.__dict__.copy() + + d = make_split_nonembedded() + + def clearer(): + for _ in range(1000): + d.clear() + d.update(make_split_nonembedded()) + + def reader(): + for _ in range(1000): + for k in keys: + d.get(k) + + threading_helper.run_concurrently([clearer, reader, reader]) + + def test_racing_embedded_values_clear_and_lookup(self): + class C: + pass + + obj = C() + def writer(): + for _ in range(1000): + obj.x = 1 + obj.y = 2 + obj.z = 3 + obj.__dict__.clear() + + def reader(): + for _ in range(1000): + obj.__dict__.get('x') + + threading_helper.run_concurrently([writer, reader, reader]) + + def test_racing_dict_update_and_method_lookup(self): + # gh-144295: test race between dict modifications and method lookups. + # Uses BytesIO because the race requires a type without Py_TPFLAGS_INLINE_VALUES + # for the _PyDict_GetMethodStackRef code path. + import io + obj = io.BytesIO() + + def writer(): + for _ in range(10000): + obj.x = 1 + del obj.x + + def reader(): + for _ in range(10000): + obj.getvalue() + + t1 = Thread(target=writer) + t2 = Thread(target=reader) + + with threading_helper.start_threads([t1, t2]): + pass + + def test_getstate_race_with_shared_keys(self): + box = [None] + enter = Barrier(2) + leave = Barrier(2) + + def reader(): + for _ in range(1000): + enter.wait() + box[0].__getstate__() + leave.wait() + + def writer(): + for i in range(1000): + box[0] = type(f"C{i}", (), {})() + enter.wait() + setattr(box[0], str(i), 1) + leave.wait() + + threading_helper.run_concurrently([reader, writer]) + +if __name__ == "__main__": + unittest.main() diff --git a/crates/vm/src/dict_inner.rs b/crates/vm/src/dict_inner.rs index 6388f3ed383..947bbb91af7 100644 --- a/crates/vm/src/dict_inner.rs +++ b/crates/vm/src/dict_inner.rs @@ -627,50 +627,30 @@ impl Dict { where K: DictKey + ?Sized, { - let (stored_index, _removed) = loop { - let (entry_index, index_index) = self.lookup(vm, key, hash, None)?; - let mut inner = self.write(); - if let Some(index) = entry_index.index() { - // Update existing key - if let Some(entry) = inner.entries.get_mut(index) { - let Some(entry) = entry.as_mut() else { - // The dict was changed since we did lookup. Let's try again. - // this is very rare to happen - // (and seems only happen with very high freq gc, and about one time in 10000 iters) - // but still possible - continue; - }; - #[expect( - clippy::redundant_else, - reason = "Keeping the empty `else` block here for documentation" - )] - if entry.index == index_index { - let removed = core::mem::replace(&mut entry.value, value); - // defer dec RC - break (index, Some(removed)); - } else { - // stuff shifted around, let's try again - } - } else { - // The dict was changed since we did lookup. Let's try again. - } - } else { - // New key - validate slot is still what lookup found - if inner.indices.get(index_index) != Some(&entry_index) { - // Dict was resized since lookup, retry - continue; + let mut value = Some(value); + let (stored_index, _removed) = self.with_slot_mut_prepared( + vm, + key, + hash, + Some(|| key.to_pyobject(vm)), + |inner, (entry_index, index_index), prepared| { + if let Some(index) = entry_index.index() { + let entry = inner.entries[index].as_mut().unwrap(); + let removed = core::mem::replace(&mut entry.value, value.take().unwrap()); + return (index, Some(removed)); } + let key = prepared.take().unwrap(); self.invalidate_keys_version(); let stored = inner.unchecked_push( index_index, hash, - key.to_pyobject(vm), - value, + key, + value.take().unwrap(), entry_index, ); - break (stored, None); - } - }; + (stored, None) + }, + )?; Ok(stored_index) } @@ -962,24 +942,26 @@ impl Dict { hash: HashValue, value: T, ) -> PyResult<()> { - let _removed = loop { - let lookup = self.lookup(vm, key, hash, None)?; - let (entry, index_index) = lookup; - if entry.index().is_some() { - match self.pop_inner(lookup) { - ControlFlow::Break(Some(entry)) => break Some(entry), - _ => continue, + // Borrow the value so a deletion leaves it to be dropped unlocked. + let mut value = Some(value); + let _removed = self.with_slot_mut_prepared( + vm, + key, + hash, + Some(|| key.to_owned()), + |inner, (entry, index_index), prepared| { + if let Some(index) = entry.index() { + self.invalidate_keys_version(); + inner.indices[index_index] = IndexEntry::DUMMY; + inner.used -= 1; + return inner.entries[index].take(); } - } - - let mut inner = self.write(); - if inner.indices.get(index_index) != Some(&entry) { - continue; - } - self.invalidate_keys_version(); - inner.unchecked_push(index_index, hash, key.to_owned(), value, entry); - break None; - }; + let key = prepared.take().unwrap(); + self.invalidate_keys_version(); + inner.unchecked_push(index_index, hash, key, value.take().unwrap(), entry); + None + }, + )?; Ok(()) } @@ -996,36 +978,10 @@ impl Dict { K: DictKey + ?Sized, F: FnOnce() -> T, { - let mut default = Some(default); - loop { - let (index_entry, index_index) = self.lookup(vm, key, hash, None)?; - if let Some(index) = index_entry.index() { - let inner = self.read(); - if let Some(entry) = inner.get_entry_checked(index, index_index) { - return Ok(entry.value.clone()); - } - continue; - } - let mut inner = self.write(); - if inner.indices.get(index_index) != Some(&index_entry) { - continue; - } - let value = default - .take() - .expect("default must only be computed on insertion")(); - self.invalidate_keys_version(); - inner.unchecked_push( - index_index, - hash, - key.to_pyobject(vm), - value.clone(), - index_entry, - ); - return Ok(value); - } + self.setdefault_entry(vm, key, hash, default) + .map(|(_, value)| value) } - #[allow(dead_code)] pub(crate) fn setdefault_entry( &self, vm: &VirtualMachine, @@ -1037,29 +993,27 @@ impl Dict { K: DictKey + ?Sized, F: FnOnce() -> T, { - let mut default = Some(default); - loop { - let (index_entry, index_index) = self.lookup(vm, key, hash, None)?; - if let Some(index) = index_entry.index() { - let inner = self.read(); - if let Some(entry) = inner.get_entry_checked(index, index_index) { - return Ok((entry.key.clone(), entry.value.clone())); + self.with_slot_mut_prepared( + vm, + key, + hash, + Some(|| { + // Preserve lazy factory-before-key ordering, outside the guard. + let value = default(); + (key.to_pyobject(vm), value) + }), + |inner, (index_entry, index_index), prepared| { + if let Some(index) = index_entry.index() { + let entry = inner.entries[index].as_ref().unwrap(); + return (entry.key.clone(), entry.value.clone()); } - continue; - } - let mut inner = self.write(); - if inner.indices.get(index_index) != Some(&index_entry) { - continue; - } - let value = default - .take() - .expect("default must only be computed on insertion")(); - let key_obj = key.to_pyobject(vm); - let ret = (key_obj.clone(), value.clone()); - self.invalidate_keys_version(); - inner.unchecked_push(index_index, hash, key_obj, value, index_entry); - return Ok(ret); - } + let (key, value) = prepared.take().unwrap(); + let ret = (key.clone(), value.clone()); + self.invalidate_keys_version(); + inner.unchecked_push(index_index, hash, key, value, index_entry); + ret + }, + ) } pub(crate) fn len(&self) -> usize { @@ -1400,13 +1354,61 @@ impl Dict { hash_value: HashValue, update: impl FnOnce(&mut DictInner, EntryIndex) -> R, ) -> PyResult> { + let mut update = Some(update); + self.with_slot_mut(vm, key, hash_value, |inner, (entry, _)| { + entry + .index() + .map(|index| update.take().unwrap()(inner, index)) + }) + } + + // Probe and commit under one guard, including when the key is absent. + // The callback must not run Python, allocate Python objects, or drop owned + // Python references (including unused captures) while the guard is held. + fn with_slot_mut( + &self, + vm: &VirtualMachine, + key: &K, + hash_value: HashValue, + update: impl FnOnce(&mut DictInner, LookupResult) -> R, + ) -> PyResult { + self.with_slot_mut_prepared(vm, key, hash_value, None:: ()>, |inner, slot, _| { + update(inner, slot) + }) + } + + // Preparation may run Python, but only after absence was established and + // the guard released. Retain that probe's witnesses so an unchanged route + // does not repeat equality. Unused preparation/captures stay outside guards. + fn with_slot_mut_prepared P, R>( + &self, + vm: &VirtualMachine, + key: &K, + hash_value: HashValue, + mut prepare: Option, + update: impl FnOnce(&mut DictInner, LookupResult, &mut Option

) -> R, + ) -> PyResult { struct ProbeWitness { index_index: IndexIndex, entry_index: IndexEntry, key: Option<(HashValue, PyObjectRef)>, } + impl ProbeWitness { + fn matches(&self, inner: &DictInner) -> bool { + inner.indices.get(self.index_index) == Some(&self.entry_index) + && self.key.as_ref().is_none_or(|(hash, key)| { + inner + .get_entry_checked(self.entry_index.index().unwrap(), self.index_index) + .is_some_and(|entry| entry.hash == *hash && entry.key.is(key)) + }) + } + } + + let mut prepared = None; + let mut pending_vacancy: Option<(LookupResult, IndexIndex)> = None; let mut idxs = None; + let mut free_slot = None; let mut prefix = Vec::::new(); let mut compared: Option<(EntryIndex, IndexIndex, PyObjectRef, bool)> = None; 'lookup: loop { @@ -1421,6 +1423,24 @@ impl Dict { drop(inner); prefix.clear(); idxs = None; + free_slot = None; + pending_vacancy = None; + continue; + } + if let Some((slot, terminal_free)) = pending_vacancy.take() { + // The terminal FREE matters even when insertion chose an earlier + // DUMMY. Every preceding occupied/DUMMY bucket is witnessed. + if inner.indices.get(terminal_free) == Some(&IndexEntry::FREE) + && prefix.iter().all(|witness| witness.matches(&inner)) + { + let result = update(&mut inner, slot, &mut prepared); + drop(inner); + return Ok(result); + } + drop(inner); + prefix.clear(); + idxs = None; + free_slot = None; continue; } let matched = if let Some((entry_index, index_index, candidate, equal)) = &comparison { @@ -1432,36 +1452,35 @@ impl Dict { // A false comparison can survive while another previously // probed bucket changes (including after nested compaction). // Revalidate the whole prefix before continuing past it. - let prefix_valid = *equal - || prefix.iter().all(|witness| { - inner.indices.get(witness.index_index) == Some(&witness.entry_index) - && witness.key.as_ref().is_none_or(|(hash, key)| { - inner - .get_entry_checked( - witness.entry_index.index().unwrap(), - witness.index_index, - ) - .is_some_and(|entry| entry.hash == *hash && entry.key.is(key)) - }) - }); + let prefix_valid = *equal || prefix.iter().all(|witness| witness.matches(&inner)); if !valid || !prefix_valid { drop(inner); prefix.clear(); idxs = None; + free_slot = None; continue; } - equal.then_some(*entry_index) + equal.then_some((inner.indices[*index_index], *index_index)) } else { None }; - let entry_index = match matched { - Some(index) => index, + let mut terminal_free = None; + let slot = match matched { + Some(slot) => slot, None => loop { let index_index = probes.next(); let index_entry = inner.indices[index_index]; match index_entry { - IndexEntry::FREE => return Ok(None), + IndexEntry::FREE => { + terminal_free = Some(index_index); + break free_slot.map_or((IndexEntry::FREE, index_index), |index| { + (IndexEntry::DUMMY, index) + }); + } IndexEntry::DUMMY => { + if free_slot.is_none() { + free_slot = Some(index_index); + } prefix.push(ProbeWitness { index_index, entry_index: index_entry, @@ -1474,7 +1493,7 @@ impl Dict { let entry_index = index_entry.index().unwrap(); let entry = inner.entries[entry_index].as_ref().unwrap(); if key.key_is(&entry.key) { - break entry_index; + break (index_entry, index_index); } prefix.push(ProbeWitness { index_index, @@ -1491,9 +1510,16 @@ impl Dict { } }, }; - let result = update(&mut inner, entry_index); + if slot.0.index().is_none() && prepare.is_some() { + pending_vacancy = Some((slot, terminal_free.unwrap())); + drop(inner); + drop(comparison); + prepared = Some(prepare.take().unwrap()()); + continue; + } + let result = update(&mut inner, slot, &mut prepared); drop(inner); - return Ok(Some(result)); + return Ok(result); } } @@ -2000,6 +2026,252 @@ mod tests { key.key_hash(vm).unwrap() } + fn assert_table_invariants(dict: &Dict) { + let inner = dict.read(); + assert_eq!(inner.entries.iter().flatten().count(), inner.used); + assert_eq!( + inner + .indices + .iter() + .filter(|index| index.index().is_some()) + .count(), + inner.used + ); + assert_eq!( + inner + .indices + .iter() + .filter(|index| **index != IndexEntry::FREE) + .count(), + inner.filled + ); + for (index, entry) in inner.entries.iter().enumerate() { + let Some(entry) = entry else { continue }; + assert_eq!(inner.indices[entry.index].index(), Some(index)); + let mut probes = GenIndexes::new(entry.hash, (inner.indices.len() - 1) as i64); + let mut reached = false; + // After the finite perturb prefix, probing visits the entire table. + for _ in 0..inner.indices.len() + 64 { + let bucket = probes.next(); + assert_ne!(inner.indices[bucket], IndexEntry::FREE); + if bucket == entry.index { + reached = true; + break; + } + } + assert!(reached, "live entry must be reachable before a free bucket"); + } + for (bucket, index) in inner.indices.iter().enumerate() { + if let Some(index) = index.index() { + assert_eq!(inner.entries[index].as_ref().unwrap().index, bucket); + } + } + } + + #[test] + fn insertion_paths_preserve_table_invariants() { + Interpreter::without_stdlib(Default::default()).enter(|vm| { + let dict = Dict::default(); + let initial_buckets = dict.read().indices.len(); + for key in 0..16usize { + dict.insert(vm, &key, hash_key(vm, &key), key).unwrap(); + assert_table_invariants(&dict); + } + assert!(dict.read().indices.len() > initial_buckets); + let key: PyObjectRef = vm.ctx.new_int(3).into(); + let hash = hash_key(vm, &3usize); + dict.delete_or_insert(vm, &key, hash, 30).unwrap(); + assert_table_invariants(&dict); + let filled = dict.read().filled; + dict.delete_or_insert(vm, &key, hash, 30).unwrap(); + assert_eq!(dict.read().filled, filled); + assert_table_invariants(&dict); + dict.insert(vm, &3usize, hash, 31).unwrap(); + assert_eq!(dict.get(vm, &3usize, hash).unwrap(), Some(31)); + assert_eq!(dict.setdefault(vm, &3usize, hash, || 32).unwrap(), 31); + let (_, value) = dict + .setdefault_entry(vm, &17usize, hash_key(vm, &17usize), || 17) + .unwrap(); + assert_eq!(value, 17); + assert_eq!(dict.len(), 17); + assert_table_invariants(&dict); + }); + } + + #[test] + fn insertion_reprobes_after_native_key_preparation() { + struct PreparingKey<'a> { + dict: &'a Dict, + conversions: core::cell::Cell, + } + impl DictKey for PreparingKey<'_> { + type Owned = usize; + fn _to_owned(&self, vm: &VirtualMachine) -> usize { + self.conversions.set(self.conversions.get() + 1); + assert_eq!(self.conversions.get(), 1); + for key in 0..16usize { + self.dict.insert(vm, &key, hash_key(vm, &key), key).unwrap(); + } + 64 + } + fn key_hash(&self, vm: &VirtualMachine) -> PyResult { + 64usize.key_hash(vm) + } + fn key_is(&self, _other: &PyObject) -> bool { + false + } + fn key_eq(&self, vm: &VirtualMachine, other: &PyObject) -> PyResult { + 64usize.key_eq(vm, other) + } + fn key_as_isize(&self, _vm: &VirtualMachine) -> PyResult { + Ok(64) + } + } + Interpreter::without_stdlib(Default::default()).enter(|vm| { + let dict = Dict::default(); + let initial_buckets = dict.read().indices.len(); + let key = PreparingKey { + dict: &dict, + conversions: core::cell::Cell::new(0), + }; + dict.insert(vm, &key, hash_key(vm, &key), 64).unwrap(); + assert_eq!(key.conversions.get(), 1); + assert!(dict.read().indices.len() > initial_buckets); + assert_eq!(dict.len(), 17); + assert_eq!( + dict.get(vm, &64usize, hash_key(vm, &64usize)).unwrap(), + Some(64) + ); + assert_table_invariants(&dict); + }); + } + + #[test] + fn setdefault_reprobes_after_native_factory_inserts_winner() { + Interpreter::without_stdlib(Default::default()).enter(|vm| { + let dict = Dict::default(); + let calls = core::cell::Cell::new(0); + let hash = hash_key(vm, &64usize); + let (key, value) = dict + .setdefault_entry(vm, &64usize, hash, || { + calls.set(calls.get() + 1); + for key in 0..16usize { + dict.insert(vm, &key, hash_key(vm, &key), key).unwrap(); + } + dict.insert(vm, &64usize, hash, 77).unwrap(); + 99 + }) + .unwrap(); + assert_eq!(calls.get(), 1); + assert_eq!(value, 77); + assert!(64usize.key_eq(vm, &key).unwrap()); + assert_eq!(dict.get(vm, &64usize, hash).unwrap(), Some(77)); + assert_eq!(dict.len(), 17); + assert_table_invariants(&dict); + }); + } + + #[test] + fn insertion_preparation_keeps_unchanged_comparison() { + struct CountedKey { + key: PyObjectRef, + comparisons: core::cell::Cell, + preparations: core::cell::Cell, + } + impl DictKey for CountedKey { + type Owned = PyObjectRef; + fn _to_owned(&self, _vm: &VirtualMachine) -> PyObjectRef { + self.preparations.set(self.preparations.get() + 1); + self.key.clone() + } + fn key_hash(&self, vm: &VirtualMachine) -> PyResult { + self.key.key_hash(vm) + } + fn key_is(&self, other: &PyObject) -> bool { + self.key.is(other) + } + fn key_eq(&self, vm: &VirtualMachine, other: &PyObject) -> PyResult { + self.comparisons.set(self.comparisons.get() + 1); + self.key.key_eq(vm, other) + } + fn key_as_isize(&self, vm: &VirtualMachine) -> PyResult { + self.key.key_as_isize(vm) + } + } + Interpreter::without_stdlib(Default::default()).enter(|vm| { + // Exact native integers with equal hashes, without Python callbacks. + for use_default in [false, true] { + let dict = Dict::default(); + let first: PyObjectRef = vm.ctx.new_int(0).into(); + let key = CountedKey { + key: vm.ctx.new_int(hash::MODULUS).into(), + comparisons: core::cell::Cell::new(0), + preparations: core::cell::Cell::new(0), + }; + let hash = hash_key(vm, &key); + assert_eq!(hash_key(vm, &*first), hash); + dict.insert(vm, &*first, hash, 1).unwrap(); + if use_default { + assert_eq!(dict.setdefault(vm, &key, hash, || 2).unwrap(), 2); + } else { + dict.insert(vm, &key, hash, 2).unwrap(); + } + assert_eq!(key.comparisons.get(), 1); + assert_eq!(key.preparations.get(), 1); + assert_eq!(dict.len(), 2); + assert_table_invariants(&dict); + } + }); + } + + #[test] + fn insertion_preparation_revalidates_same_size_vacancy() { + Interpreter::without_stdlib(Default::default()).enter(|vm| { + let dict = Dict::default(); + let initial_buckets = dict.read().indices.len(); + let hash = hash_key(vm, &8usize); + let calls = core::cell::Cell::new(0); + // A winner occupies the saved terminal FREE without resizing. + assert_eq!( + dict.setdefault(vm, &8usize, hash, || { + calls.set(calls.get() + 1); + dict.insert(vm, &8usize, hash, 77).unwrap(); + 99 + }) + .unwrap(), + 77 + ); + assert_eq!(calls.get(), 1); + assert_eq!(dict.len(), 1); + assert_eq!(dict.read().indices.len(), initial_buckets); + assert_table_invariants(&dict); + + let dict = Dict::default(); + dict.insert(vm, &0usize, hash_key(vm, &0usize), 0).unwrap(); + dict.delete(vm, &0usize, hash_key(vm, &0usize)).unwrap(); + assert_eq!(dict.read().indices[0], IndexEntry::DUMMY); + // Preparation consumes the saved first DUMMY, but not the terminal + // FREE at bucket 1. Both keys must survive the resulting re-probe. + assert_eq!( + dict.setdefault(vm, &8usize, hash, || { + dict.insert(vm, &16usize, hash_key(vm, &16usize), 16) + .unwrap(); + 8 + }) + .unwrap(), + 8 + ); + assert_eq!(dict.read().indices.len(), initial_buckets); + assert_eq!( + dict.get(vm, &16usize, hash_key(vm, &16usize)).unwrap(), + Some(16) + ); + assert_eq!(dict.get(vm, &8usize, hash).unwrap(), Some(8)); + assert_eq!(dict.len(), 2); + assert_table_invariants(&dict); + }); + } + #[test] fn move_to_end_preserves_values_and_invalidates_layout() { Interpreter::without_stdlib(Default::default()).enter(|vm| {