From 90003eecaa95bd689907a007e29db315e6499d4b Mon Sep 17 00:00:00 2001 From: Rain Date: Fri, 22 May 2026 14:36:26 -0700 Subject: [PATCH 1/2] [spr] initial version Created using spr 1.3.6-beta.1 --- crates/iddqd/src/bi_hash_map/imp.rs | 33 +++-- crates/iddqd/src/id_hash_map/imp.rs | 18 ++- crates/iddqd/src/id_ord_map/imp.rs | 18 ++- crates/iddqd/src/support/item_set.rs | 4 +- crates/iddqd/src/tri_hash_map/imp.rs | 39 +++--- crates/iddqd/tests/integration/bi_hash_map.rs | 31 +++-- crates/iddqd/tests/integration/id_hash_map.rs | 24 ++-- crates/iddqd/tests/integration/id_ord_map.rs | 28 ++-- .../iddqd/tests/integration/panic_safety.rs | 82 ++++++----- .../iddqd/tests/integration/pathological.rs | 131 +++++++++++++++++- .../iddqd/tests/integration/tri_hash_map.rs | 35 +++-- 11 files changed, 335 insertions(+), 108 deletions(-) diff --git a/crates/iddqd/src/bi_hash_map/imp.rs b/crates/iddqd/src/bi_hash_map/imp.rs index 604eefb..2b07239 100644 --- a/crates/iddqd/src/bi_hash_map/imp.rs +++ b/crates/iddqd/src/bi_hash_map/imp.rs @@ -707,9 +707,12 @@ impl BiHashMap { /// # } /// ``` pub fn clear(&mut self) { - self.items.clear(); + // Clear the internal indexes before dropping items. This way, if a user + // `Drop` panics during `self.items.clear()`, the tables cannot retain + // indexes pointing to removed item slots. self.tables.k1_to_item.clear(); self.tables.k2_to_item.clear(); + self.items.clear(); } /// Reserves capacity for at least `additional` more elements to be inserted @@ -1910,8 +1913,13 @@ impl BiHashMap { { let hash_state = self.tables.state.clone(); let (_, mut dormant_items) = DormantMutRef::new(&mut self.items); + let mut removed_item = None; self.tables.k1_to_item.retain(|index| { + // Drop the previously removed item only after the primary table's + // retain machinery has had a chance to unlink its entry. + drop(removed_item.take()); + let (item, dormant_items) = { // SAFETY: All uses of `items` ended in the previous iteration. let items = unsafe { dormant_items.reborrow() }; @@ -1948,11 +1956,6 @@ impl BiHashMap { if retain { true } else { - // SAFETY: The original items is no longer used after the first - // block above, and item + dormant_item have been dropped after - // being used above. - let items = unsafe { dormant_items.awaken() }; - items.remove(index); let k2_entry = self .tables .k2_to_item @@ -1964,17 +1967,25 @@ impl BiHashMap { entry.remove(); } Err(_) => { - // This happening means there's an inconsistency between - // the maps. - panic!( - "inconsistency between k1_to_item and k2_to_item" - ); + self.tables.k2_to_item.remove_by_index(index); } } + // SAFETY: The original items is no longer used after the first + // block above, and item + dormant_item have been dropped after + // being used above. + let items = unsafe { dormant_items.awaken() }; + removed_item = Some( + items + .remove(index) + .expect("all indexes are present in self.items"), + ); + false } }); + + drop(removed_item); } fn find1<'a, Q>(&'a self, k: &Q) -> Option<&'a T> diff --git a/crates/iddqd/src/id_hash_map/imp.rs b/crates/iddqd/src/id_hash_map/imp.rs index 010622f..711301b 100644 --- a/crates/iddqd/src/id_hash_map/imp.rs +++ b/crates/iddqd/src/id_hash_map/imp.rs @@ -595,8 +595,11 @@ impl IdHashMap { /// # } /// ``` pub fn clear(&mut self) { - self.items.clear(); + // Clear the internal index before dropping items. This way, if a user + // `Drop` panics during `self.items.clear()`, `key_to_item` cannot retain + // indexes pointing to removed item slots. self.tables.key_to_item.clear(); + self.items.clear(); } /// Reserves capacity for at least `additional` more elements to be inserted @@ -1296,8 +1299,13 @@ impl IdHashMap { { let hash_state = self.tables.state.clone(); let (_, mut dormant_items) = DormantMutRef::new(&mut self.items); + let mut removed_item = None; self.tables.key_to_item.retain(|index| { + // Drop the previously removed item only after the primary table's + // retain machinery has had a chance to unlink its entry. + drop(removed_item.take()); + let (item, dormant_items) = { // SAFETY: All uses of `items` ended in the previous iteration. let items = unsafe { dormant_items.reborrow() }; @@ -1334,10 +1342,16 @@ impl IdHashMap { // SAFETY: The original items is no longer used after the first // block above, and item + dormant item have been used above. let items = unsafe { dormant_items.awaken() }; - items.remove(index); + removed_item = Some( + items + .remove(index) + .expect("all indexes are present in self.items"), + ); false } }); + + drop(removed_item); } fn find_index<'a, Q>(&'a self, k: &Q) -> Option diff --git a/crates/iddqd/src/id_ord_map/imp.rs b/crates/iddqd/src/id_ord_map/imp.rs index 627baf5..981542a 100644 --- a/crates/iddqd/src/id_ord_map/imp.rs +++ b/crates/iddqd/src/id_ord_map/imp.rs @@ -348,8 +348,11 @@ impl IdOrdMap { /// assert_eq!(map.len(), 0); /// ``` pub fn clear(&mut self) { - self.items.clear(); + // Clear the internal index before dropping items. This way, if a user + // `Drop` panics during `self.items.clear()`, `key_to_item` cannot retain + // indexes pointing to removed item slots. self.tables.key_to_item.clear(); + self.items.clear(); } /// Reserves capacity for at least `additional` more elements to be inserted @@ -1312,8 +1315,13 @@ impl IdOrdMap { { let hash_state = self.tables.state().clone(); let (_, mut dormant_items) = DormantMutRef::new(&mut self.items); + let mut removed_item = None; self.tables.key_to_item.retain(|index| { + // Drop the previously removed item only after the primary table's + // retain machinery has had a chance to unlink its entry. + drop(removed_item.take()); + let (item, dormant_items) = { // SAFETY: All uses of `items` ended in the previous iteration. let items = unsafe { dormant_items.reborrow() }; @@ -1351,10 +1359,16 @@ impl IdOrdMap { // block above, and item + dormant_item have been dropped after // being used above. let items = unsafe { dormant_items.awaken() }; - items.remove(index); + removed_item = Some( + items + .remove(index) + .expect("all indexes are present in self.items"), + ); false } }); + + drop(removed_item); } fn find<'a, Q>(&'a self, k: &Q) -> Option<&'a T> diff --git a/crates/iddqd/src/support/item_set.rs b/crates/iddqd/src/support/item_set.rs index 0be3c34..63a1b3b 100644 --- a/crates/iddqd/src/support/item_set.rs +++ b/crates/iddqd/src/support/item_set.rs @@ -541,9 +541,11 @@ impl ItemSet { /// [`Vec::clear`]. Any prior [`try_reserve`](Self::try_reserve) /// reservation survives a `clear`. pub(crate) fn clear(&mut self) { - self.items.clear(); + // Publish the post-clear metadata before dropping items, so a user + // `Drop` panic cannot leave len/free_head describing the old slots. self.free_head = ItemIndex::SENTINEL; self.len = 0; + self.items.clear(); } /// This method assumes that value has the same ID. It also asserts diff --git a/crates/iddqd/src/tri_hash_map/imp.rs b/crates/iddqd/src/tri_hash_map/imp.rs index eb0116a..f05ef1e 100644 --- a/crates/iddqd/src/tri_hash_map/imp.rs +++ b/crates/iddqd/src/tri_hash_map/imp.rs @@ -797,10 +797,13 @@ impl TriHashMap { /// # } /// ``` pub fn clear(&mut self) { - self.items.clear(); + // Clear the internal indexes before dropping items. This way, if a user + // `Drop` panics during `self.items.clear()`, the tables cannot retain + // indexes pointing to removed item slots. self.tables.k1_to_item.clear(); self.tables.k2_to_item.clear(); self.tables.k3_to_item.clear(); + self.items.clear(); } /// Reserves capacity for at least `additional` more elements to be inserted @@ -2562,8 +2565,13 @@ impl TriHashMap { { let hash_state = self.tables.state.clone(); let (_, mut dormant_items) = DormantMutRef::new(&mut self.items); + let mut removed_item = None; self.tables.k1_to_item.retain(|index| { + // Drop the previously removed item only after the primary table's + // retain machinery has had a chance to unlink its entry. + drop(removed_item.take()); + let (item, dormant_items) = { // SAFETY: All uses of `items` ended in the previous iteration. let items = unsafe { dormant_items.reborrow() }; @@ -2610,11 +2618,6 @@ impl TriHashMap { if retain { true } else { - // SAFETY: The original items is no longer used after the first - // block above, and item + dormant_item have been dropped after - // being used above. - let items = unsafe { dormant_items.awaken() }; - items.remove(index); let k2_entry = self .tables .k2_to_item @@ -2628,28 +2631,32 @@ impl TriHashMap { map3_index == index }); - let mut is_inconsistent = false; if let Ok(k2_entry) = k2_entry { k2_entry.remove(); } else { - is_inconsistent = true; + self.tables.k2_to_item.remove_by_index(index); } if let Ok(k3_entry) = k3_entry { k3_entry.remove(); } else { - is_inconsistent = true; - } - if is_inconsistent { - // This happening means there's an inconsistency among - // the maps. - panic!( - "inconsistency among k1_to_item, k2_to_item, k3_to_item" - ); + self.tables.k3_to_item.remove_by_index(index); } + // SAFETY: The original items is no longer used after the first + // block above, and item + dormant_item have been dropped after + // being used above. + let items = unsafe { dormant_items.awaken() }; + removed_item = Some( + items + .remove(index) + .expect("all indexes are present in self.items"), + ); + false } }); + + drop(removed_item); } fn find1<'a, Q>(&'a self, k: &Q) -> Option<&'a T> diff --git a/crates/iddqd/tests/integration/bi_hash_map.rs b/crates/iddqd/tests/integration/bi_hash_map.rs index 4ab169d..c39a775 100644 --- a/crates/iddqd/tests/integration/bi_hash_map.rs +++ b/crates/iddqd/tests/integration/bi_hash_map.rs @@ -1237,13 +1237,20 @@ impl BiHashItem for PanickyHashItem { bi_upcast!(); } +#[cfg(feature = "default-hasher")] +impl Drop for PanickyHashItem { + fn drop(&mut self) { + crate::panic_safety::observe_panicky_call("item-drop"); + } +} + #[cfg(feature = "default-hasher")] mod proptest_panic_safety { use super::*; use crate::panic_safety::{ PanicSafety, PanickyOp, PanickySearchKey, - assert_panic_fired_as_expected, assert_post_op_invariants, run_armed, - sorted_keys, + assert_panic_fired_as_expected, assert_post_op_invariants, + drop_unarmed, run_armed, sorted_keys, }; // Keys are kept in a small range so hits and misses both happen @@ -1289,7 +1296,8 @@ mod proptest_panic_safety { /// `remove1; remove2; insert_unique;` as sequential commits, /// so a mid-sequence panic can leave the map in a different /// (but still valid) state. - /// * `RetainModulo` loops over per-step atomic mutations. + /// * `RetainModulo` and `Clear` loop over per-step atomic item + /// destruction. /// * `Extend` calls `HashTable::reserve` up front, which on a /// tombstone-heavy map drops into hashbrown's /// `rehash_in_place` — documented as not panic-safe under a @@ -1305,25 +1313,28 @@ mod proptest_panic_safety { | PanickyAction::ContainsKey1(_) | PanickyAction::ContainsKey2(_) => PanicSafety::Atomic, PanickyAction::RetainModulo(_, _, _) - | PanickyAction::Extend(_) => PanicSafety::StepAtomic, - PanickyAction::Clear => PanicSafety::Atomic, + | PanickyAction::Extend(_) + | PanickyAction::Clear => PanicSafety::StepAtomic, } } fn run(self, map: &mut BiHashMap) { match self { PanickyAction::InsertUnique(key1, key2) => { - let _ = map.insert_unique(PanickyHashItem { key1, key2 }); + drop_unarmed( + map.insert_unique(PanickyHashItem { key1, key2 }), + ); } PanickyAction::InsertOverwrite(key1, key2) => { - let _ = - map.insert_overwrite(PanickyHashItem { key1, key2 }); + drop_unarmed( + map.insert_overwrite(PanickyHashItem { key1, key2 }), + ); } PanickyAction::Remove1(key1) => { - let _ = map.remove1(&PanickySearchKey(key1)); + drop_unarmed(map.remove1(&PanickySearchKey(key1))); } PanickyAction::Remove2(key2) => { - let _ = map.remove2(&PanickySearchKey(key2)); + drop_unarmed(map.remove2(&PanickySearchKey(key2))); } PanickyAction::Get1(key1) => { let _ = map.get1(&PanickySearchKey(key1)); diff --git a/crates/iddqd/tests/integration/id_hash_map.rs b/crates/iddqd/tests/integration/id_hash_map.rs index 13340c9..5d58147 100644 --- a/crates/iddqd/tests/integration/id_hash_map.rs +++ b/crates/iddqd/tests/integration/id_hash_map.rs @@ -907,13 +907,20 @@ impl IdHashItem for PanickyHashItem { id_upcast!(); } +#[cfg(feature = "default-hasher")] +impl Drop for PanickyHashItem { + fn drop(&mut self) { + crate::panic_safety::observe_panicky_call("item-drop"); + } +} + #[cfg(feature = "default-hasher")] mod proptest_panic_safety { use super::*; use crate::panic_safety::{ PanicSafety, PanickyOp, PanickySearchKey, - assert_panic_fired_as_expected, assert_post_op_invariants, run_armed, - sorted_keys, + assert_panic_fired_as_expected, assert_post_op_invariants, + drop_unarmed, run_armed, sorted_keys, }; // Keys are kept in a small range so collisions, hits, and misses @@ -944,7 +951,8 @@ mod proptest_panic_safety { impl PanickyAction { /// Classify panic safety for this action. /// - /// * `RetainModulo` and `Extend` loop over per-step atomic mutations. + /// * `RetainModulo`, `Extend`, and `Clear` loop over per-step atomic + /// item destruction. fn panic_safety(&self) -> PanicSafety { match self { PanickyAction::InsertUnique(_) @@ -953,21 +961,21 @@ mod proptest_panic_safety { | PanickyAction::Get(_) | PanickyAction::ContainsKey(_) => PanicSafety::Atomic, PanickyAction::RetainModulo(_, _, _) - | PanickyAction::Extend(_) => PanicSafety::StepAtomic, - PanickyAction::Clear => PanicSafety::Atomic, + | PanickyAction::Extend(_) + | PanickyAction::Clear => PanicSafety::StepAtomic, } } fn run(self, map: &mut IdHashMap) { match self { PanickyAction::InsertUnique(key) => { - let _ = map.insert_unique(PanickyHashItem { key }); + drop_unarmed(map.insert_unique(PanickyHashItem { key })); } PanickyAction::InsertOverwrite(key) => { - let _ = map.insert_overwrite(PanickyHashItem { key }); + drop_unarmed(map.insert_overwrite(PanickyHashItem { key })); } PanickyAction::Remove(key) => { - let _ = map.remove(&PanickySearchKey(key)); + drop_unarmed(map.remove(&PanickySearchKey(key))); } PanickyAction::Get(key) => { let _ = map.get(&PanickySearchKey(key)); diff --git a/crates/iddqd/tests/integration/id_ord_map.rs b/crates/iddqd/tests/integration/id_ord_map.rs index fb9056d..a8f742d 100644 --- a/crates/iddqd/tests/integration/id_ord_map.rs +++ b/crates/iddqd/tests/integration/id_ord_map.rs @@ -1104,12 +1104,18 @@ impl IdOrdItem for PanickyOrdItem { id_upcast!(); } +impl Drop for PanickyOrdItem { + fn drop(&mut self) { + crate::panic_safety::observe_panicky_call("item-drop"); + } +} + mod proptest_panic_safety { use super::*; use crate::panic_safety::{ PanicSafety, PanickyOp, PanickySearchKey, - assert_panic_fired_as_expected, assert_post_op_invariants, run_armed, - sorted_keys, + assert_panic_fired_as_expected, assert_post_op_invariants, + drop_unarmed, run_armed, sorted_keys, }; // Keys are kept in a small range so hits and misses both happen @@ -1144,8 +1150,8 @@ mod proptest_panic_safety { impl PanickyAction { /// Classify panic safety for this action. /// - /// `Extend` and `RetainModulo` loop over per-step atomic - /// mutations. + /// `Extend`, `RetainModulo`, and `Clear` loop over per-step atomic + /// item destruction. fn panic_safety(&self) -> PanicSafety { match self { PanickyAction::InsertUnique(_) @@ -1156,21 +1162,21 @@ mod proptest_panic_safety { | PanickyAction::PopFirst | PanickyAction::PopLast => PanicSafety::Atomic, PanickyAction::RetainModulo(_, _, _) - | PanickyAction::Extend(_) => PanicSafety::StepAtomic, - PanickyAction::Clear => PanicSafety::Atomic, + | PanickyAction::Extend(_) + | PanickyAction::Clear => PanicSafety::StepAtomic, } } fn run(self, map: &mut IdOrdMap) { match self { PanickyAction::InsertUnique(key) => { - let _ = map.insert_unique(PanickyOrdItem { key }); + drop_unarmed(map.insert_unique(PanickyOrdItem { key })); } PanickyAction::InsertOverwrite(key) => { - let _ = map.insert_overwrite(PanickyOrdItem { key }); + drop_unarmed(map.insert_overwrite(PanickyOrdItem { key })); } PanickyAction::Remove(key) => { - let _ = map.remove(&PanickySearchKey(key)); + drop_unarmed(map.remove(&PanickySearchKey(key))); } PanickyAction::Get(key) => { let _ = map.get(&PanickySearchKey(key)); @@ -1179,10 +1185,10 @@ mod proptest_panic_safety { let _ = map.contains_key(&PanickySearchKey(key)); } PanickyAction::PopFirst => { - let _ = map.pop_first(); + drop_unarmed(map.pop_first()); } PanickyAction::PopLast => { - let _ = map.pop_last(); + drop_unarmed(map.pop_last()); } PanickyAction::RetainModulo(rem, modulo, keep) => { map.retain(|item| { diff --git a/crates/iddqd/tests/integration/panic_safety.rs b/crates/iddqd/tests/integration/panic_safety.rs index 4557ad4..1258537 100644 --- a/crates/iddqd/tests/integration/panic_safety.rs +++ b/crates/iddqd/tests/integration/panic_safety.rs @@ -1,12 +1,14 @@ //! Shared scaffolding for panic safety tests. //! //! [`PanickyKey`] is a key whose `Hash`/`Eq`/`Ord`/`Drop` impls share a -//! thread-local panic countdown. The call after `n` successful ones panics. -//! Drop is included so the harness also exercises the post-`prepare_*`, +//! thread-local panic countdown. The map-specific test items also call into +//! the same countdown from `Drop`. The call after `n` successful ones panics. +//! Key `Drop` is included so the harness exercises the post-`prepare_*`, //! pre-commit windows in `IdOrdMap` (and analogous windows in the hash -//! maps) where a user-key Drop is the only thing that could panic. Each -//! proptest drives a random sequence of [`PanickyOp`]s, and after every -//! step asserts: +//! maps) where a user-key Drop is the only thing that could panic; item +//! `Drop` covers container-owned destruction paths such as `retain`, +//! `clear`, and `Extend`. Each proptest drives a random sequence of +//! [`PanickyOp`]s, and after every step asserts: //! //! * `validate()` //! * a `contains_key` round-trip on every surviving item @@ -35,29 +37,42 @@ pub(crate) struct PanickyKey(pub u32); impl PanickyKey { fn observe_call(label: &'static str) { - PANIC_COUNTDOWN.with(|c| { - // When disarmed, don't tick `OP_COUNT`. This matters for two - // distinct cases: - // - // * Calls outside `run_armed` (validation, assertions): they - // shouldn't count toward the next armed step. - // * Calls during panic unwinding *after* the countdown has fired: - // key drops along the unwind path would otherwise tick - // `OP_COUNT` past `n + 1` and break - // `assert_panic_fired_as_expected`. - let Some(n) = c.get() else { return }; - OP_COUNT.with(|c| c.set(c.get() + 1)); - if n == 0 { - // Disarm before panicking so additional `observe_call`s - // during unwinding (notably key drops) don't double-panic. - c.set(None); - panic!("PanickyKey::{label} panic triggered"); - } - c.set(Some(n - 1)); - }); + observe_panicky_call(label); } } +pub(crate) fn observe_panicky_call(label: &'static str) { + PANIC_COUNTDOWN.with(|c| { + // When disarmed, don't tick `OP_COUNT`. This matters for two + // distinct cases: + // + // * Calls outside `run_armed` (validation, assertions): they + // shouldn't count toward the next armed step. + // * Calls during panic unwinding *after* the countdown has fired: + // user drops along the unwind path would otherwise tick + // `OP_COUNT` past `n + 1` and break + // `assert_panic_fired_as_expected`. + let Some(n) = c.get() else { return }; + OP_COUNT.with(|c| c.set(c.get() + 1)); + if n == 0 { + // Disarm before panicking so additional `observe_panicky_call`s + // during unwinding don't double-panic. + c.set(None); + panic!("Panicky::{label} panic triggered"); + } + c.set(Some(n - 1)); + }); +} + +/// Drops a value after the map operation has returned ownership to the caller. +/// +/// This keeps caller-side cleanup of returned/rejected items out of the armed +/// panic window while preserving the operation count already recorded. +pub(crate) fn drop_unarmed(value: T) { + disarm_panic(); + drop(value); +} + impl Drop for PanickyKey { fn drop(&mut self) { Self::observe_call("drop"); @@ -165,8 +180,8 @@ where /// Run `f` with the panic countdown set, then unconditionally disarm /// so a leftover countdown can't trip later code. Returns -/// `(panicked, ops)` where `ops` is the count of `PanickyKey` -/// trait-method calls made during `f`. +/// `(panicked, ops)` where `ops` is the count of observed user calls made +/// during `f`. pub(crate) fn run_armed(armed: Option, f: impl FnOnce()) -> (bool, u32) { let _ = take_op_count(); if let Some(n) = armed { @@ -181,10 +196,11 @@ pub(crate) fn run_armed(armed: Option, f: impl FnOnce()) -> (bool, u32) { /// Asserts that the panic-countdown infrastructure fired (or didn't) /// exactly as the arming would predict. /// -/// "Key call" here means any of `Hash`/`Eq`/`Ord`/`Drop` on `PanickyKey`. -/// With `armed = Some(n)`, the panic should fire on the `(n+1)`-th key -/// call, so `panicked` implies `ops == n + 1`, and `!panicked` implies -/// the action made at most `n` key calls. With `armed = None`, no +/// "User call" here means any of `Hash`/`Eq`/`Ord`/`Drop` on `PanickyKey`, +/// or `Drop` on a map item. With `armed = Some(n)`, the panic should fire +/// on the `(n+1)`-th user call, so `panicked` implies `ops == n + 1`, +/// and `!panicked` implies +/// the action made at most `n` user calls. With `armed = None`, no /// panic should escape. pub(crate) fn assert_panic_fired_as_expected( op_label: &dyn fmt::Display, @@ -196,13 +212,13 @@ pub(crate) fn assert_panic_fired_as_expected( (Some(n), true) => assert_eq!( ops, n + 1, - "op {op_label} (armed: {n}) panicked on key call {ops}, \ + "op {op_label} (armed: {n}) panicked on user call {ops}, \ expected call {}", n + 1, ), (Some(n), false) => assert!( ops <= n, - "op {op_label} (armed: {n}) made {ops} key call(s) but \ + "op {op_label} (armed: {n}) made {ops} user call(s) but \ did not panic — the panic countdown failed to fire", ), (None, true) => panic!( diff --git a/crates/iddqd/tests/integration/pathological.rs b/crates/iddqd/tests/integration/pathological.rs index 7f0ac99..2d6fc1d 100644 --- a/crates/iddqd/tests/integration/pathological.rs +++ b/crates/iddqd/tests/integration/pathological.rs @@ -1,5 +1,6 @@ -//! Pathological adversarial impls of `IdOrdItem` / `IdHashItem`, run under -//! miri (Stacked + Tree Borrows) to probe iddqd's unsafe code for UB. +//! Pathological adversarial impls of `IdOrdItem` / `IdHashItem` / +//! `BiHashItem` / `TriHashItem`, run under miri (Stacked + Tree Borrows) to +//! probe iddqd's unsafe code for UB. //! //! The tests in this file might leave maps in an inconsistent state, but not in //! a way that should cause UB. @@ -765,6 +766,47 @@ impl IdHashItem for ForgettableHashItem { id_upcast!(); } +#[derive(Debug)] +struct ForgettableBiHashItem { + id: u32, + alt: u32, +} + +impl BiHashItem for ForgettableBiHashItem { + type K1<'a> = ForgettableHashKey; + type K2<'a> = ForgettableHashKey; + fn key1(&self) -> Self::K1<'_> { + ForgettableHashKey(self.id) + } + fn key2(&self) -> Self::K2<'_> { + ForgettableHashKey(self.alt) + } + bi_upcast!(); +} + +#[derive(Debug)] +struct ForgettableTriHashItem { + id: u32, + alt: u32, + third: u32, +} + +impl TriHashItem for ForgettableTriHashItem { + type K1<'a> = ForgettableHashKey; + type K2<'a> = ForgettableHashKey; + type K3<'a> = ForgettableHashKey; + fn key1(&self) -> Self::K1<'_> { + ForgettableHashKey(self.id) + } + fn key2(&self) -> Self::K2<'_> { + ForgettableHashKey(self.alt) + } + fn key3(&self) -> Self::K3<'_> { + ForgettableHashKey(self.third) + } + tri_upcast!(); +} + #[test] fn id_hash_silent_key_change_entry_remove_no_panic() { let mut map = IdHashMap::::new(); @@ -796,6 +838,51 @@ fn id_hash_silent_key_change_entry_remove_no_panic() { .expect("map remains valid after silent-mutation remove"); } +#[test] +fn bi_hash_silent_secondary_key_change_retain_no_panic() { + let mut map = BiHashMap::::new(); + for id in 0..8u32 { + map.insert_unique(ForgettableBiHashItem { id, alt: id + 100 }).unwrap(); + } + + let mut ref_mut = map.get1_mut(&ForgettableHashKey(3)).unwrap(); + ref_mut.alt = 10_000; + // This bypasses the drop-time hash equality check on key2, leaving the k2 + // table entry in the old hash bucket. + std::mem::forget(ref_mut); + + map.retain(|_| false); + + assert!(map.is_empty()); + map.validate(ValidateCompact::NonCompact) + .expect("map remains valid after silent-mutation retain"); +} + +#[test] +fn tri_hash_silent_secondary_key_change_retain_no_panic() { + let mut map = TriHashMap::::new(); + for id in 0..8u32 { + map.insert_unique(ForgettableTriHashItem { + id, + alt: id + 100, + third: id + 200, + }) + .unwrap(); + } + + let mut ref_mut = map.get1_mut(&ForgettableHashKey(3)).unwrap(); + ref_mut.third = 10_000; + // This bypasses the drop-time hash equality check on key3, leaving the k3 + // table entry in the old hash bucket. + std::mem::forget(ref_mut); + + map.retain(|_| false); + + assert!(map.is_empty()); + map.validate(ValidateCompact::NonCompact) + .expect("map remains valid after silent-mutation retain"); +} + #[test] fn lying_ord_remove_must_not_remove_wrong_btree_entry() { // Build a 64-element map under an honest `Ord`. The B-tree is sorted by @@ -1005,6 +1092,46 @@ fn drop_panic_during_remove_no_ub() { assert!(map.get(&5).is_none()); } +#[test] +fn drop_panic_during_retain_leaves_map_valid() { + let mut map = IdHashMap::::new(); + for i in 0..8 { + map.insert_unique(DropPanicItem { id: i, armed: i == 5 }).unwrap(); + } + + DROP_PANIC_ARMED.with(|c| c.set(true)); + let panicked = catch_panic(std::panic::AssertUnwindSafe(|| { + map.retain(|item| item.id != 5); + })); + DROP_PANIC_ARMED.with(|c| c.set(false)); + + assert!(panicked.is_none(), "expected retained-away item drop to panic"); + assert_eq!(map.iter_mut().count(), 7); + assert!(map.get(&5).is_none()); + map.validate(ValidateCompact::NonCompact) + .expect("map remains valid after retain drop panic"); +} + +#[test] +fn drop_panic_during_clear_leaves_map_valid() { + let mut map = IdHashMap::::new(); + for i in 0..8 { + map.insert_unique(DropPanicItem { id: i, armed: i == 5 }).unwrap(); + } + + DROP_PANIC_ARMED.with(|c| c.set(true)); + let panicked = catch_panic(std::panic::AssertUnwindSafe(|| { + map.clear(); + })); + DROP_PANIC_ARMED.with(|c| c.set(false)); + + assert!(panicked.is_none(), "expected item drop during clear to panic"); + assert!(map.is_empty()); + assert!(map.get(&5).is_none()); + map.validate(ValidateCompact::NonCompact) + .expect("map remains valid after clear drop panic"); +} + // Test: retain callback panic. #[test] diff --git a/crates/iddqd/tests/integration/tri_hash_map.rs b/crates/iddqd/tests/integration/tri_hash_map.rs index 43d8469..4af4a37 100644 --- a/crates/iddqd/tests/integration/tri_hash_map.rs +++ b/crates/iddqd/tests/integration/tri_hash_map.rs @@ -1119,13 +1119,20 @@ impl TriHashItem for PanickyHashItem { tri_upcast!(); } +#[cfg(feature = "default-hasher")] +impl Drop for PanickyHashItem { + fn drop(&mut self) { + crate::panic_safety::observe_panicky_call("item-drop"); + } +} + #[cfg(feature = "default-hasher")] mod proptest_panic_safety { use super::*; use crate::panic_safety::{ PanicSafety, PanickyOp, PanickySearchKey, - assert_panic_fired_as_expected, assert_post_op_invariants, run_armed, - sorted_keys, + assert_panic_fired_as_expected, assert_post_op_invariants, + drop_unarmed, run_armed, sorted_keys, }; // Keys are kept in a small range so hits and misses both happen @@ -1179,7 +1186,8 @@ mod proptest_panic_safety { /// `remove1; remove2; remove3; insert_unique;` as sequential /// commits, so a mid-sequence panic can leave the map in a /// different (but still valid) state. - /// * `RetainModulo` loops over per-step atomic mutations. + /// * `RetainModulo` and `Clear` loop over per-step atomic item + /// destruction. /// * `Extend` calls `HashTable::reserve` up front, which on a /// tombstone-heavy map drops into hashbrown's /// `rehash_in_place` — documented as not panic-safe under a @@ -1197,32 +1205,35 @@ mod proptest_panic_safety { | PanickyAction::Get2(_) | PanickyAction::Get3(_) => PanicSafety::Atomic, PanickyAction::RetainModulo(_, _, _) - | PanickyAction::Extend(_) => PanicSafety::StepAtomic, - PanickyAction::Clear => PanicSafety::Atomic, + | PanickyAction::Extend(_) + | PanickyAction::Clear => PanicSafety::StepAtomic, } } fn run(self, map: &mut TriHashMap) { match self { PanickyAction::InsertUnique(key1, key2, key3) => { - let _ = - map.insert_unique(PanickyHashItem { key1, key2, key3 }); + drop_unarmed(map.insert_unique(PanickyHashItem { + key1, + key2, + key3, + })); } PanickyAction::InsertOverwrite(key1, key2, key3) => { - let _ = map.insert_overwrite(PanickyHashItem { + drop_unarmed(map.insert_overwrite(PanickyHashItem { key1, key2, key3, - }); + })); } PanickyAction::Remove1(key1) => { - let _ = map.remove1(&PanickySearchKey(key1)); + drop_unarmed(map.remove1(&PanickySearchKey(key1))); } PanickyAction::Remove2(key2) => { - let _ = map.remove2(&PanickySearchKey(key2)); + drop_unarmed(map.remove2(&PanickySearchKey(key2))); } PanickyAction::Remove3(key3) => { - let _ = map.remove3(&PanickySearchKey(key3)); + drop_unarmed(map.remove3(&PanickySearchKey(key3))); } PanickyAction::Get1(key1) => { let _ = map.get1(&PanickySearchKey(key1)); From 3e6fe63037f703a5e9bb88d08f79b912382630a3 Mon Sep 17 00:00:00 2001 From: Rain Date: Fri, 22 May 2026 15:17:55 -0700 Subject: [PATCH 2/2] improve comments Created using spr 1.3.6-beta.1 --- crates/iddqd/src/bi_hash_map/imp.rs | 17 +++++++++++++---- crates/iddqd/src/bi_hash_map/ref_mut.rs | 8 ++++++-- crates/iddqd/src/id_hash_map/imp.rs | 12 +++++++++--- crates/iddqd/src/id_ord_map/imp.rs | 13 ++++++++++--- crates/iddqd/src/tri_hash_map/imp.rs | 17 +++++++++++++---- crates/iddqd/src/tri_hash_map/ref_mut.rs | 8 ++++++-- crates/iddqd/tests/integration/id_hash_map.rs | 6 ++++-- crates/iddqd/tests/integration/id_ord_map.rs | 6 ++++-- crates/iddqd/tests/integration/panic_safety.rs | 4 +++- crates/iddqd/tests/integration/pathological.rs | 8 ++++---- 10 files changed, 72 insertions(+), 27 deletions(-) diff --git a/crates/iddqd/src/bi_hash_map/imp.rs b/crates/iddqd/src/bi_hash_map/imp.rs index 2b07239..760c34c 100644 --- a/crates/iddqd/src/bi_hash_map/imp.rs +++ b/crates/iddqd/src/bi_hash_map/imp.rs @@ -1916,8 +1916,15 @@ impl BiHashMap { let mut removed_item = None; self.tables.k1_to_item.retain(|index| { - // Drop the previously removed item only after the primary table's - // retain machinery has had a chance to unlink its entry. + // Drop the previously-removed item here, at the top of the next + // iteration. + // + // By now, the prior `k1_to_item` entry has been erased, so if + // `drop` below panics, `k1_to_item`, `k2_to_item`, and `items` + // remain in sync. Dropping the item at the end of the prior + // iteration would unwind before the table erased the entry, leaving + // `k1_to_item` pointing at a slot we already removed from `items` + // and `k2_to_item`. drop(removed_item.take()); let (item, dormant_items) = { @@ -1973,7 +1980,9 @@ impl BiHashMap { // SAFETY: The original items is no longer used after the first // block above, and item + dormant_item have been dropped after - // being used above. + // being used above. The k2 work between them borrows only + // `self.tables.k2_to_item`, which is disjoint from + // `self.items`. let items = unsafe { dormant_items.awaken() }; removed_item = Some( items @@ -1985,7 +1994,7 @@ impl BiHashMap { } }); - drop(removed_item); + // Anything in `removed_item` is implicitly dropped now. } fn find1<'a, Q>(&'a self, k: &Q) -> Option<&'a T> diff --git a/crates/iddqd/src/bi_hash_map/ref_mut.rs b/crates/iddqd/src/bi_hash_map/ref_mut.rs index 16e9c24..b82bf1d 100644 --- a/crates/iddqd/src/bi_hash_map/ref_mut.rs +++ b/crates/iddqd/src/bi_hash_map/ref_mut.rs @@ -27,8 +27,12 @@ use core::{ /// /// Also, `RefMut`'s hash detection will not function if [`mem::forget`] is /// called on it. If a key is changed and `mem::forget` is then called on the -/// `RefMut`, the `BiHashMap` will stop functioning correctly. This will not -/// introduce memory safety issues, however. +/// `RefMut`, lookups by the affected key will return the wrong result (or no +/// result at all). The map itself remains structurally valid: subsequent +/// [`retain`](crate::BiHashMap::retain) and +/// [`remove*`](crate::BiHashMap::remove1) operations still clean up the stale +/// entry via a linear-scan fallback. This will not introduce memory safety +/// issues. /// /// The issues here are similar to using interior mutability (e.g. `RefCell` or /// `Mutex`) to mutate keys in a regular `HashMap`. diff --git a/crates/iddqd/src/id_hash_map/imp.rs b/crates/iddqd/src/id_hash_map/imp.rs index 711301b..7a0c9b0 100644 --- a/crates/iddqd/src/id_hash_map/imp.rs +++ b/crates/iddqd/src/id_hash_map/imp.rs @@ -1302,8 +1302,14 @@ impl IdHashMap { let mut removed_item = None; self.tables.key_to_item.retain(|index| { - // Drop the previously removed item only after the primary table's - // retain machinery has had a chance to unlink its entry. + // Drop the previously-removed item here, at the top of the next + // iteration. + // + // By now, the prior `key_to_item` entry has been erased, so if + // `drop` below panics, `key_to_item` and `items` remain in sync. + // Dropping the item at the end of the prior iteration would + // unwind before the table erased the entry, leaving `key_to_item` + // pointing at a slot we already removed from `items`. drop(removed_item.take()); let (item, dormant_items) = { @@ -1351,7 +1357,7 @@ impl IdHashMap { } }); - drop(removed_item); + // Anything in `removed_item` is implicitly dropped now. } fn find_index<'a, Q>(&'a self, k: &Q) -> Option diff --git a/crates/iddqd/src/id_ord_map/imp.rs b/crates/iddqd/src/id_ord_map/imp.rs index 981542a..fb10385 100644 --- a/crates/iddqd/src/id_ord_map/imp.rs +++ b/crates/iddqd/src/id_ord_map/imp.rs @@ -1318,8 +1318,15 @@ impl IdOrdMap { let mut removed_item = None; self.tables.key_to_item.retain(|index| { - // Drop the previously removed item only after the primary table's - // retain machinery has had a chance to unlink its entry. + // Drop the previously-removed item here, at the top of the next + // iteration. + // + // By now, the prior `key_to_item` entry has been erased, so if + // `drop` below panics, `key_to_item` and `items` remain in sync. + // Dropping the item at the end of the prior iteration would + // unwind before the BTree dropped the entry, leaving + // `key_to_item` pointing at a slot we already removed from + // `items`. drop(removed_item.take()); let (item, dormant_items) = { @@ -1368,7 +1375,7 @@ impl IdOrdMap { } }); - drop(removed_item); + // Anything in `removed_item` is implicitly dropped now. } fn find<'a, Q>(&'a self, k: &Q) -> Option<&'a T> diff --git a/crates/iddqd/src/tri_hash_map/imp.rs b/crates/iddqd/src/tri_hash_map/imp.rs index f05ef1e..939900f 100644 --- a/crates/iddqd/src/tri_hash_map/imp.rs +++ b/crates/iddqd/src/tri_hash_map/imp.rs @@ -2568,8 +2568,15 @@ impl TriHashMap { let mut removed_item = None; self.tables.k1_to_item.retain(|index| { - // Drop the previously removed item only after the primary table's - // retain machinery has had a chance to unlink its entry. + // Drop the previously-removed item here, at the top of the next + // iteration. + // + // By now, the prior `k1_to_item` entry has been erased, so if + // `drop` below panics, `k1_to_item`, `k2_to_item`, `k3_to_item`, + // and `items` remain in sync. Dropping the item at the end of the + // prior iteration would unwind before the table erased the entry, + // leaving `k1_to_item` pointing at a slot we already removed from + // `items`, `k2_to_item`, and `k3_to_item`. drop(removed_item.take()); let (item, dormant_items) = { @@ -2644,7 +2651,9 @@ impl TriHashMap { // SAFETY: The original items is no longer used after the first // block above, and item + dormant_item have been dropped after - // being used above. + // being used above. The k2/k3 work between them borrows only + // `self.tables.k2_to_item` and `self.tables.k3_to_item`, + // which are disjoint from `self.items`. let items = unsafe { dormant_items.awaken() }; removed_item = Some( items @@ -2656,7 +2665,7 @@ impl TriHashMap { } }); - drop(removed_item); + // Anything in `removed_item` is implicitly dropped now. } fn find1<'a, Q>(&'a self, k: &Q) -> Option<&'a T> diff --git a/crates/iddqd/src/tri_hash_map/ref_mut.rs b/crates/iddqd/src/tri_hash_map/ref_mut.rs index 4ca13f7..c5f53d5 100644 --- a/crates/iddqd/src/tri_hash_map/ref_mut.rs +++ b/crates/iddqd/src/tri_hash_map/ref_mut.rs @@ -28,8 +28,12 @@ use core::{ /// /// Also, `RefMut`'s hash detection will not function if [`mem::forget`] is /// called on it. If a key is changed and `mem::forget` is then called on the -/// `RefMut`, the `TriHashMap` will stop functioning correctly. This will not -/// introduce memory safety issues, however. +/// `RefMut`, lookups by the affected key will return the wrong result (or no +/// result at all). The map itself remains structurally valid: subsequent +/// [`retain`](crate::TriHashMap::retain) and +/// [`remove*`](crate::TriHashMap::remove1) operations still clean up the stale +/// entry via a linear-scan fallback. This will not introduce memory safety +/// issues. /// /// The issues here are similar to using interior mutability (e.g. `RefCell` or /// `Mutex`) to mutate keys in a regular `HashMap`. diff --git a/crates/iddqd/tests/integration/id_hash_map.rs b/crates/iddqd/tests/integration/id_hash_map.rs index 5d58147..a88d9c8 100644 --- a/crates/iddqd/tests/integration/id_hash_map.rs +++ b/crates/iddqd/tests/integration/id_hash_map.rs @@ -951,8 +951,10 @@ mod proptest_panic_safety { impl PanickyAction { /// Classify panic safety for this action. /// - /// * `RetainModulo`, `Extend`, and `Clear` loop over per-step atomic - /// item destruction. + /// * `RetainModulo` and `Clear` loop over per-step atomic item + /// destruction. + /// * `Extend` is a sequence of per-step atomic `insert_overwrite` + /// calls; a mid-sequence panic leaves earlier inserts committed. fn panic_safety(&self) -> PanicSafety { match self { PanickyAction::InsertUnique(_) diff --git a/crates/iddqd/tests/integration/id_ord_map.rs b/crates/iddqd/tests/integration/id_ord_map.rs index a8f742d..e0b3d3f 100644 --- a/crates/iddqd/tests/integration/id_ord_map.rs +++ b/crates/iddqd/tests/integration/id_ord_map.rs @@ -1150,8 +1150,10 @@ mod proptest_panic_safety { impl PanickyAction { /// Classify panic safety for this action. /// - /// `Extend`, `RetainModulo`, and `Clear` loop over per-step atomic - /// item destruction. + /// * `RetainModulo` and `Clear` loop over per-step atomic item + /// destruction. + /// * `Extend` is a sequence of per-step atomic `insert_overwrite` + /// calls; a mid-sequence panic leaves earlier inserts committed. fn panic_safety(&self) -> PanicSafety { match self { PanickyAction::InsertUnique(_) diff --git a/crates/iddqd/tests/integration/panic_safety.rs b/crates/iddqd/tests/integration/panic_safety.rs index 1258537..06e26f9 100644 --- a/crates/iddqd/tests/integration/panic_safety.rs +++ b/crates/iddqd/tests/integration/panic_safety.rs @@ -67,7 +67,9 @@ pub(crate) fn observe_panicky_call(label: &'static str) { /// Drops a value after the map operation has returned ownership to the caller. /// /// This keeps caller-side cleanup of returned/rejected items out of the armed -/// panic window while preserving the operation count already recorded. +/// panic window while preserving the operation count already recorded. The +/// disarm persists for the rest of the current [`run_armed`] invocation; +/// any subsequent map op in the same arm runs un-armed unless re-armed. pub(crate) fn drop_unarmed(value: T) { disarm_panic(); drop(value); diff --git a/crates/iddqd/tests/integration/pathological.rs b/crates/iddqd/tests/integration/pathological.rs index 2d6fc1d..d0c064c 100644 --- a/crates/iddqd/tests/integration/pathological.rs +++ b/crates/iddqd/tests/integration/pathological.rs @@ -1100,12 +1100,12 @@ fn drop_panic_during_retain_leaves_map_valid() { } DROP_PANIC_ARMED.with(|c| c.set(true)); - let panicked = catch_panic(std::panic::AssertUnwindSafe(|| { + let result = catch_panic(std::panic::AssertUnwindSafe(|| { map.retain(|item| item.id != 5); })); DROP_PANIC_ARMED.with(|c| c.set(false)); - assert!(panicked.is_none(), "expected retained-away item drop to panic"); + assert!(result.is_none(), "expected retained-away item drop to panic"); assert_eq!(map.iter_mut().count(), 7); assert!(map.get(&5).is_none()); map.validate(ValidateCompact::NonCompact) @@ -1120,12 +1120,12 @@ fn drop_panic_during_clear_leaves_map_valid() { } DROP_PANIC_ARMED.with(|c| c.set(true)); - let panicked = catch_panic(std::panic::AssertUnwindSafe(|| { + let result = catch_panic(std::panic::AssertUnwindSafe(|| { map.clear(); })); DROP_PANIC_ARMED.with(|c| c.set(false)); - assert!(panicked.is_none(), "expected item drop during clear to panic"); + assert!(result.is_none(), "expected item drop during clear to panic"); assert!(map.is_empty()); assert!(map.get(&5).is_none()); map.validate(ValidateCompact::NonCompact)