Skip to main content

crcbl_core/
handle.rs

1//! Typed handles and the slot pool that issues them.
2//!
3//! A [`Handle<T>`] is the engine-wide way to name a resource that lives in a
4//! [`Pool<T>`]. Handles are values: `Copy`, comparable, hashable, and cheap to
5//! store in arrays or send over the wire. They are *not* pointers — a handle
6//! whose slot has been recycled fails lookup instead of aliasing whatever moved
7//! in afterwards.
8//!
9//! # Representation
10//!
11//! A handle is a 32-bit slot index plus a 32-bit generation, stored as two
12//! fields rather than a packed `u64`:
13//!
14//! * The struct is 8 bytes with 4-byte alignment either way, so packing buys no
15//!   space — it would only add shift/mask noise at every use site.
16//! * The generation is a [`NonZeroU32`], which gives the struct a niche, so
17//!   `Option<Handle<T>>` is *also* 8 bytes. That matters: optional handles are
18//!   everywhere in component arrays.
19//! * [`Handle::to_bits`] / [`Handle::from_bits`] provide the packed `u64` form
20//!   for serialization and FFI without forcing it on in-memory layout.
21//!
22//! The type parameter is carried as `PhantomData<fn() -> T>`: covariant in `T`
23//! (an owned-value marker), and unconditionally `Send`, `Sync`, and `Unpin`
24//! regardless of `T`. A handle is an integer; it must not inherit thread-safety
25//! restrictions from a type it merely names. For the same reason every trait
26//! impl below is hand-written — `#[derive]` would bound `Clone`/`Copy`/`Eq`/…
27//! on `T: Clone`/`T: Copy`/`T: Eq`, which is wrong for a plain index.
28
29use core::fmt;
30use core::hash::{Hash, Hasher};
31use core::marker::PhantomData;
32use core::mem;
33use core::num::NonZeroU32;
34
35/// The generation every fresh slot starts at.
36const FIRST_GENERATION: NonZeroU32 = NonZeroU32::MIN;
37
38/// A typed, generational reference to a value stored in a [`Pool<T>`].
39///
40/// See the [module docs](self) for the representation and variance rationale.
41#[repr(C)]
42pub struct Handle<T> {
43    index: u32,
44    generation: NonZeroU32,
45    marker: PhantomData<fn() -> T>,
46}
47
48impl<T> Handle<T> {
49    /// Builds a handle from its parts.
50    ///
51    /// Only the owning [`Pool`] should need this; a hand-made handle simply
52    /// fails lookup unless it happens to match a live slot.
53    #[inline]
54    const fn new(index: u32, generation: NonZeroU32) -> Self {
55        Self {
56            index,
57            generation,
58            marker: PhantomData,
59        }
60    }
61
62    /// The slot index this handle points at.
63    #[inline]
64    pub const fn index(self) -> u32 {
65        self.index
66    }
67
68    /// The generation this handle was issued with.
69    ///
70    /// Never zero — see the [module docs](self).
71    #[inline]
72    pub const fn generation(self) -> u32 {
73        self.generation.get()
74    }
75
76    /// Packs the handle into a `u64` (generation in the high half).
77    ///
78    /// The packed form is stable and never zero, which makes it safe to use as
79    /// a wire/serialization representation with `0` reserved for "none".
80    #[inline]
81    pub const fn to_bits(self) -> u64 {
82        ((self.generation.get() as u64) << 32) | self.index as u64
83    }
84
85    /// Unpacks a handle produced by [`Handle::to_bits`].
86    ///
87    /// Returns `None` if the generation half is zero, which no real handle ever
88    /// has.
89    #[inline]
90    pub const fn from_bits(bits: u64) -> Option<Self> {
91        match NonZeroU32::new((bits >> 32) as u32) {
92            Some(generation) => Some(Self::new(bits as u32, generation)),
93            None => None,
94        }
95    }
96
97    /// Reinterprets the handle as naming a different type.
98    ///
99    /// Useful only at seams that erase types (e.g. a generic resource table);
100    /// the result is meaningless in any other pool.
101    #[inline]
102    pub const fn cast<U>(self) -> Handle<U> {
103        Handle::new(self.index, self.generation)
104    }
105}
106
107impl<T> Clone for Handle<T> {
108    #[inline]
109    fn clone(&self) -> Self {
110        *self
111    }
112}
113
114impl<T> Copy for Handle<T> {}
115
116impl<T> PartialEq for Handle<T> {
117    #[inline]
118    fn eq(&self, other: &Self) -> bool {
119        self.index == other.index && self.generation == other.generation
120    }
121}
122
123impl<T> Eq for Handle<T> {}
124
125impl<T> Hash for Handle<T> {
126    #[inline]
127    fn hash<H: Hasher>(&self, state: &mut H) {
128        self.to_bits().hash(state);
129    }
130}
131
132impl<T> fmt::Debug for Handle<T> {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        write!(
135            f,
136            "Handle<{}>({}, gen {})",
137            core::any::type_name::<T>(),
138            self.index,
139            self.generation
140        )
141    }
142}
143
144/// A slot in a [`Pool`].
145enum Slot<T> {
146    /// Holds a live value; `generation` is the one issued to its handle.
147    Occupied { generation: NonZeroU32, value: T },
148    /// Free and on the free list; `generation` is the one the *next* insert
149    /// into this slot will issue.
150    Vacant {
151        generation: NonZeroU32,
152        next_free: Option<u32>,
153    },
154    /// Permanently withdrawn: its generation counter is exhausted. Never
155    /// reused, so no stale handle can ever be revived.
156    Retired,
157}
158
159/// A slotmap-style arena: values in, [`Handle`]s out.
160///
161/// Insertion recycles free slots through a free list, so live values stay
162/// densely packed near the front of the backing `Vec` under steady-state
163/// churn. Every removal bumps the slot's generation, so handles issued before
164/// the removal stop resolving.
165///
166/// # Generation exhaustion
167///
168/// Generations are 32-bit and *never wrap*. A slot that has been recycled
169/// `u32::MAX - 1` times is **retired**: its storage is dropped, the slot is
170/// removed from the free list, and it is never handed out again. Wrapping would
171/// silently resurrect a >4-billion-removals-old handle as a valid alias for an
172/// unrelated value — a bug that would surface as impossible-looking aliasing
173/// weeks into a server's uptime. Retiring instead costs one dead `Vec` entry
174/// (a few bytes) per exhausted slot, and only after ~4.3e9 reuses of that one
175/// slot: at 60 Hz, recycling the same slot every tick, that is over two years
176/// of continuous churn. See [`Pool::retired_slots`] to observe it.
177///
178/// # Safety
179///
180/// This module contains no `unsafe`. The `Vec<Slot<T>>` + enum payload costs
181/// one discriminant per slot versus a hand-rolled `MaybeUninit` arena; clarity
182/// wins at this layer, and the pool is not in any inner loop that measures.
183pub struct Pool<T> {
184    slots: Vec<Slot<T>>,
185    /// Index of the first free slot, if any (singly linked through `Vacant`).
186    free_head: Option<u32>,
187    len: usize,
188    retired: usize,
189}
190
191impl<T> Pool<T> {
192    /// Creates an empty pool.
193    #[inline]
194    #[must_use]
195    pub const fn new() -> Self {
196        Self {
197            slots: Vec::new(),
198            free_head: None,
199            len: 0,
200            retired: 0,
201        }
202    }
203
204    /// Creates an empty pool with room for `capacity` slots.
205    #[inline]
206    #[must_use]
207    pub fn with_capacity(capacity: usize) -> Self {
208        Self {
209            slots: Vec::with_capacity(capacity),
210            free_head: None,
211            len: 0,
212            retired: 0,
213        }
214    }
215
216    /// Number of live values.
217    #[inline]
218    #[must_use]
219    pub const fn len(&self) -> usize {
220        self.len
221    }
222
223    /// Whether the pool holds no live values.
224    #[inline]
225    #[must_use]
226    pub const fn is_empty(&self) -> bool {
227        self.len == 0
228    }
229
230    /// Number of slots permanently withdrawn by generation exhaustion.
231    ///
232    /// Expected to be zero forever; a non-zero value in a long-running server
233    /// is worth a metric, not an alarm.
234    #[inline]
235    #[must_use]
236    pub const fn retired_slots(&self) -> usize {
237        self.retired
238    }
239
240    /// Inserts `value` and returns a handle to it.
241    ///
242    /// # Panics
243    ///
244    /// If the pool would exceed `u32::MAX + 1` slots — the index is 32-bit by
245    /// design, and a pool that large is a leak, not a workload.
246    pub fn insert(&mut self, value: T) -> Handle<T> {
247        if let Some(index) = self.free_head {
248            let slot = &mut self.slots[index as usize];
249            let Slot::Vacant {
250                generation,
251                next_free,
252            } = *slot
253            else {
254                unreachable!("free list only links Vacant slots");
255            };
256            *slot = Slot::Occupied { generation, value };
257            self.free_head = next_free;
258            self.len += 1;
259            Handle::new(index, generation)
260        } else {
261            let index = u32::try_from(self.slots.len()).expect("Pool exceeded 2^32 slots");
262            self.slots.push(Slot::Occupied {
263                generation: FIRST_GENERATION,
264                value,
265            });
266            self.len += 1;
267            Handle::new(index, FIRST_GENERATION)
268        }
269    }
270
271    /// Borrows the value `handle` names, or `None` if the handle is stale.
272    #[inline]
273    #[must_use]
274    pub fn get(&self, handle: Handle<T>) -> Option<&T> {
275        match self.slots.get(handle.index as usize) {
276            Some(Slot::Occupied { generation, value }) if *generation == handle.generation => {
277                Some(value)
278            }
279            _ => None,
280        }
281    }
282
283    /// Mutably borrows the value `handle` names, or `None` if the handle is
284    /// stale.
285    #[inline]
286    #[must_use]
287    pub fn get_mut(&mut self, handle: Handle<T>) -> Option<&mut T> {
288        match self.slots.get_mut(handle.index as usize) {
289            Some(Slot::Occupied { generation, value }) if *generation == handle.generation => {
290                Some(value)
291            }
292            _ => None,
293        }
294    }
295
296    /// Whether `handle` still resolves.
297    #[inline]
298    #[must_use]
299    pub fn contains(&self, handle: Handle<T>) -> bool {
300        self.get(handle).is_some()
301    }
302
303    /// Removes and returns the value `handle` names, invalidating every handle
304    /// to that slot.
305    ///
306    /// Returns `None` (and changes nothing) if the handle is already stale.
307    pub fn remove(&mut self, handle: Handle<T>) -> Option<T> {
308        let slot = self.slots.get_mut(handle.index as usize)?;
309        match slot {
310            Slot::Occupied { generation, .. } if *generation == handle.generation => {}
311            _ => return None,
312        }
313        // Retire first, then decide: the value has to move out before the slot
314        // can be rewritten, and `Retired` is the correct state if the
315        // generation turns out to be exhausted.
316        let Slot::Occupied { generation, value } = mem::replace(slot, Slot::Retired) else {
317            unreachable!("checked above");
318        };
319        self.len -= 1;
320        match generation.checked_add(1) {
321            Some(next) => {
322                *slot = Slot::Vacant {
323                    generation: next,
324                    next_free: self.free_head,
325                };
326                self.free_head = Some(handle.index);
327            }
328            // Generation exhausted: the slot stays `Retired` and off the free
329            // list, forever. See the type-level docs.
330            None => self.retired += 1,
331        }
332        Some(value)
333    }
334
335    /// Removes every value, invalidating all outstanding handles.
336    ///
337    /// Slot storage is kept for reuse; generations continue from where they
338    /// were, so handles issued before the clear stay dead.
339    pub fn clear(&mut self) {
340        self.free_head = None;
341        for index in 0..self.slots.len() {
342            let slot = &mut self.slots[index];
343            let generation = match slot {
344                Slot::Occupied { generation, .. } => match generation.checked_add(1) {
345                    Some(next) => next,
346                    None => {
347                        *slot = Slot::Retired;
348                        self.retired += 1;
349                        continue;
350                    }
351                },
352                Slot::Vacant { generation, .. } => *generation,
353                Slot::Retired => continue,
354            };
355            *slot = Slot::Vacant {
356                generation,
357                next_free: self.free_head,
358            };
359            // `index < slots.len() <= u32::MAX + 1` and slots were created via
360            // `insert`, which caps the index at `u32::MAX`.
361            self.free_head = Some(index as u32);
362        }
363        self.len = 0;
364    }
365
366    /// Iterates live `(handle, &value)` pairs in slot order.
367    pub fn iter(&self) -> impl Iterator<Item = (Handle<T>, &T)> {
368        self.slots
369            .iter()
370            .enumerate()
371            .filter_map(|(index, slot)| match slot {
372                Slot::Occupied { generation, value } => {
373                    Some((Handle::new(index as u32, *generation), value))
374                }
375                _ => None,
376            })
377    }
378
379    /// Iterates live `(handle, &mut value)` pairs in slot order.
380    pub fn iter_mut(&mut self) -> impl Iterator<Item = (Handle<T>, &mut T)> {
381        self.slots
382            .iter_mut()
383            .enumerate()
384            .filter_map(|(index, slot)| match slot {
385                Slot::Occupied { generation, value } => {
386                    Some((Handle::new(index as u32, *generation), value))
387                }
388                _ => None,
389            })
390    }
391}
392
393impl<T> Default for Pool<T> {
394    #[inline]
395    fn default() -> Self {
396        Self::new()
397    }
398}
399
400impl<T> Extend<T> for Pool<T> {
401    fn extend<I: IntoIterator<Item = T>>(&mut self, iter: I) {
402        for value in iter {
403            self.insert(value);
404        }
405    }
406}
407
408impl<T> FromIterator<T> for Pool<T> {
409    fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
410        let mut pool = Self::new();
411        pool.extend(iter);
412        pool
413    }
414}
415
416/// Deliberately does not print the values: a pool is usually large, and this
417/// impl must exist for *every* `T`, not just `T: Debug`.
418impl<T> fmt::Debug for Pool<T> {
419    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420        f.debug_struct("Pool")
421            .field("type", &core::any::type_name::<T>())
422            .field("len", &self.len)
423            .field("slots", &self.slots.len())
424            .field("retired", &self.retired)
425            .finish()
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use proptest::prelude::*;
433    use std::collections::HashMap;
434
435    #[test]
436    fn handle_is_eight_bytes_and_option_is_free() {
437        assert_eq!(mem::size_of::<Handle<String>>(), 8);
438        assert_eq!(mem::size_of::<Option<Handle<String>>>(), 8);
439    }
440
441    #[test]
442    fn handle_traits_do_not_depend_on_t() {
443        // `*mut ()` is neither `Send`, `Sync`, `Copy`, nor `Eq`; the handle is
444        // all of them anyway. This is the whole point of the manual impls.
445        fn assert_traits<H: Copy + Eq + Hash + fmt::Debug + Send + Sync>() {}
446        assert_traits::<Handle<*mut ()>>();
447    }
448
449    #[test]
450    fn a_handle_survives_to_bits_and_back_and_a_zero_generation_is_not_a_handle() {
451        let handle = Handle::<u8>::new(0xDEAD_BEEF, NonZeroU32::new(0x0BAD_F00D).unwrap());
452        assert_eq!(Handle::from_bits(handle.to_bits()), Some(handle));
453        assert_ne!(handle.to_bits(), 0);
454        assert_eq!(Handle::<u8>::from_bits(0), None);
455        // Index-only bits still have a zero generation, hence no handle.
456        assert_eq!(Handle::<u8>::from_bits(0xFFFF_FFFF), None);
457    }
458
459    #[test]
460    fn a_pool_hands_back_what_was_inserted_and_a_removed_handle_never_resurrects() {
461        let mut pool = Pool::new();
462        let a = pool.insert("a".to_string());
463        let b = pool.insert("b".to_string());
464        assert_eq!(pool.len(), 2);
465        assert_eq!(pool.get(a).map(String::as_str), Some("a"));
466        assert_eq!(pool.get(b).map(String::as_str), Some("b"));
467
468        assert_eq!(pool.remove(a), Some("a".to_string()));
469        assert_eq!(pool.remove(a), None, "double remove must not resurrect");
470        assert!(!pool.contains(a));
471        assert!(pool.contains(b));
472        assert_eq!(pool.len(), 1);
473
474        pool.get_mut(b).unwrap().push('!');
475        assert_eq!(pool.get(b).map(String::as_str), Some("b!"));
476    }
477
478    #[test]
479    fn removed_slot_is_recycled_with_a_new_generation() {
480        let mut pool = Pool::new();
481        let old = pool.insert(1u32);
482        pool.remove(old);
483        let new = pool.insert(2u32);
484        assert_eq!(new.index(), old.index(), "free list should reuse the slot");
485        assert_ne!(new.generation(), old.generation());
486        assert_eq!(
487            pool.get(old),
488            None,
489            "stale handle must not see the new value"
490        );
491        assert_eq!(pool.get(new), Some(&2));
492    }
493
494    #[test]
495    fn clear_invalidates_everything_but_keeps_slots() {
496        let mut pool = Pool::new();
497        let handles: Vec<_> = (0..8).map(|i| pool.insert(i)).collect();
498        pool.clear();
499        assert!(pool.is_empty());
500        for handle in &handles {
501            assert_eq!(pool.get(*handle), None);
502        }
503        // Slots come back from the free list, so indices are reused.
504        let fresh = pool.insert(99);
505        assert!(handles.iter().any(|h| h.index() == fresh.index()));
506        assert_eq!(pool.get(fresh), Some(&99));
507    }
508
509    #[test]
510    fn iteration_yields_live_pairs_only() {
511        let mut pool = Pool::new();
512        let a = pool.insert(10);
513        let b = pool.insert(20);
514        let c = pool.insert(30);
515        pool.remove(b);
516
517        let mut seen: Vec<_> = pool.iter().map(|(h, v)| (h, *v)).collect();
518        seen.sort_by_key(|(h, _)| h.index());
519        assert_eq!(seen, vec![(a, 10), (c, 30)]);
520
521        for (_, value) in pool.iter_mut() {
522            *value += 1;
523        }
524        assert_eq!(pool.get(a), Some(&11));
525        assert_eq!(pool.get(c), Some(&31));
526    }
527
528    #[test]
529    fn exhausted_generation_retires_the_slot() {
530        let mut pool = Pool::new();
531        let handle = pool.insert(1u8);
532        // Fast-forward the slot to its last generation rather than churning
533        // 4.3 billion times.
534        let last = NonZeroU32::new(u32::MAX).unwrap();
535        pool.slots[0] = Slot::Occupied {
536            generation: last,
537            value: 1,
538        };
539        let live = Handle::<u8>::new(0, last);
540        assert_eq!(pool.get(handle), None);
541        assert_eq!(pool.get(live), Some(&1));
542
543        assert_eq!(pool.remove(live), Some(1));
544        assert_eq!(pool.retired_slots(), 1);
545        assert_eq!(pool.get(live), None);
546
547        // The retired slot is off the free list: the next insert takes a new
548        // one and can never collide with `live`.
549        let next = pool.insert(2u8);
550        assert_ne!(next.index(), live.index());
551        assert_eq!(pool.get(live), None);
552    }
553
554    #[test]
555    fn collected_and_debug_render() {
556        let pool: Pool<i32> = (0..3).collect();
557        assert_eq!(pool.len(), 3);
558        let rendered = format!("{pool:?}");
559        assert!(rendered.contains("len: 3"), "{rendered}");
560    }
561
562    #[test]
563    fn len_equals_iter_count_after_any_operation() {
564        // If `insert` increments `len` before `Vec::push` and the push
565        // panics (OOM), this invariant would break.  The OOM path cannot
566        // be forced in a normal test, but the invariant itself is what
567        // the `len` move-after-push protects.
568        let mut pool = Pool::new();
569        assert_eq!(pool.len(), pool.iter().count());
570
571        let mut handles = Vec::new();
572        for i in 0..20 {
573            handles.push(pool.insert(i));
574            assert_eq!(pool.len(), pool.iter().count());
575        }
576
577        for h in &handles[..10] {
578            pool.remove(*h);
579            assert_eq!(pool.len(), pool.iter().count());
580        }
581
582        // Insert again — exercises the free-list path.
583        for i in 30..40 {
584            pool.insert(i);
585            assert_eq!(pool.len(), pool.iter().count());
586        }
587
588        pool.clear();
589        assert_eq!(pool.len(), 0);
590        assert_eq!(pool.iter().count(), 0);
591    }
592
593    /// Reference-model comparison: a `HashMap` of every handle ever issued and
594    /// whether it should still be live.
595    #[test]
596    fn model_matches_under_scripted_churn() {
597        let mut pool = Pool::new();
598        let mut model: HashMap<Handle<u64>, u64> = HashMap::new();
599        let mut dead: Vec<Handle<u64>> = Vec::new();
600        // Deterministic pseudo-random script; the proptest version is
601        // `prop_pool_matches_hashmap_model` below.
602        let mut state = 0x1234_5678_9ABC_DEF0u64;
603        for step in 0..2_000u64 {
604            state = state
605                .wrapping_mul(6364136223846793005)
606                .wrapping_add(1442695040888963407);
607            if state.is_multiple_of(3) && !model.is_empty() {
608                let victim = *model.keys().next().unwrap();
609                assert_eq!(pool.remove(victim), model.remove(&victim));
610                dead.push(victim);
611            } else {
612                let handle = pool.insert(step);
613                assert!(
614                    model.insert(handle, step).is_none(),
615                    "handle reissued while live"
616                );
617            }
618            assert_eq!(pool.len(), model.len());
619        }
620        for (handle, value) in &model {
621            assert_eq!(pool.get(*handle), Some(value));
622        }
623        for handle in &dead {
624            assert!(!pool.contains(*handle));
625        }
626    }
627
628    // --- property tests -------------------------------------------------
629
630    /// One step of the pool script.
631    #[derive(Clone, Debug)]
632    enum Op {
633        Insert(u64),
634        /// Remove the nth live handle (modulo the number live).
635        RemoveLive(usize),
636        /// Re-remove the nth already-dead handle — must be a no-op.
637        RemoveDead(usize),
638        Clear,
639    }
640
641    fn any_op() -> impl Strategy<Value = Op> {
642        prop_oneof![
643            8 => any::<u64>().prop_map(Op::Insert),
644            6 => any::<usize>().prop_map(Op::RemoveLive),
645            2 => any::<usize>().prop_map(Op::RemoveDead),
646            1 => Just(Op::Clear),
647        ]
648    }
649
650    proptest! {
651        /// The anchor test from `docs/notes/process.md`: after an arbitrary
652        /// insert/remove/clear sequence, the pool agrees with a `HashMap`
653        /// reference model on length and contents, every live handle still
654        /// resolves to the value it was inserted with, and every handle that
655        /// was ever removed stays dead forever.
656        #[test]
657        fn prop_pool_matches_hashmap_model(ops in proptest::collection::vec(any_op(), 1..120)) {
658            let mut pool: Pool<u64> = Pool::new();
659            let mut live: HashMap<Handle<u64>, u64> = HashMap::new();
660            let mut dead: Vec<Handle<u64>> = Vec::new();
661
662            for op in &ops {
663                match *op {
664                    Op::Insert(value) => {
665                        let handle = pool.insert(value);
666                        prop_assert!(
667                            !dead.contains(&handle),
668                            "a dead handle was reissued: {handle:?}"
669                        );
670                        prop_assert!(live.insert(handle, value).is_none());
671                    }
672                    Op::RemoveLive(nth) => {
673                        if live.is_empty() {
674                            continue;
675                        }
676                        // Sort for determinism: `HashMap` iteration order is
677                        // not reproducible, and a property test that is not
678                        // reproducible is a coin flip.
679                        let mut handles: Vec<_> = live.keys().copied().collect();
680                        handles.sort_by_key(|handle| handle.to_bits());
681                        let victim = handles[nth % handles.len()];
682                        prop_assert_eq!(pool.remove(victim), live.remove(&victim));
683                        dead.push(victim);
684                    }
685                    Op::RemoveDead(nth) => {
686                        if dead.is_empty() {
687                            continue;
688                        }
689                        let zombie = dead[nth % dead.len()];
690                        prop_assert_eq!(pool.remove(zombie), None);
691                    }
692                    Op::Clear => {
693                        pool.clear();
694                        dead.extend(live.keys().copied());
695                        live.clear();
696                    }
697                }
698
699                prop_assert_eq!(pool.len(), live.len());
700                prop_assert_eq!(pool.is_empty(), live.is_empty());
701                prop_assert_eq!(pool.iter().count(), live.len());
702                for handle in &dead {
703                    prop_assert!(!pool.contains(*handle), "stale handle resolved: {handle:?}");
704                }
705            }
706
707            for (handle, value) in &live {
708                prop_assert_eq!(pool.get(*handle), Some(value));
709            }
710            // Iteration must yield exactly the model, handles included.
711            let mut iterated: Vec<_> = pool.iter().map(|(h, v)| (h.to_bits(), *v)).collect();
712            let mut expected: Vec<_> = live.iter().map(|(h, v)| (h.to_bits(), *v)).collect();
713            iterated.sort_unstable();
714            expected.sort_unstable();
715            prop_assert_eq!(iterated, expected);
716        }
717    }
718}