Skip to main content

gc_lang/
heap.rs

1//! The garbage-collected heap: allocation, resolution, and mark-and-sweep collection.
2
3extern crate alloc;
4
5use alloc::vec::Vec;
6use core::fmt;
7
8use crate::{Gc, GcError, Trace, Tracer};
9
10/// One storage slot. An occupied slot holds its value; a free slot holds `None` and
11/// waits on the free list to be handed out again. The generation advances each time
12/// the slot is reclaimed, which is what invalidates handles into a slot's earlier
13/// occupants.
14struct Slot<T> {
15    value: Option<T>,
16    generation: u32,
17}
18
19/// A garbage-collected heap of `T` objects, reclaimed by tracing mark-and-sweep.
20///
21/// A `Heap<T>` is the object store for an interpreted-language runtime: allocate a
22/// value with [`alloc`](Heap::alloc) and hold the returned [`Gc<T>`] handle, wire
23/// objects to each other by storing handles inside them, and periodically call
24/// [`collect`](Heap::collect) with the runtime's roots to reclaim everything no
25/// longer reachable. Cycles are collected — reachability, not reference counting,
26/// decides what lives — so a runtime can build arbitrary object graphs without
27/// leaking them.
28///
29/// The design is deliberately narrow and entirely safe (`#![forbid(unsafe_code)]`):
30///
31/// - **Handles, not pointers.** A [`Gc<T>`] is an index plus a generation stamp, so
32///   objects can point at one another freely without borrows, and a handle to a
33///   collected object resolves to `None` instead of dangling.
34/// - **Slots are reused.** Sweeping a dead object returns its slot to a free list;
35///   the next allocation reuses it and bumps its generation. Steady-state
36///   allocate/collect loops do not grow the backing store.
37/// - **Scratch is pooled.** The mark queue and the mark bitset are retained between
38///   collections, so a collection allocates nothing on the steady-state path.
39///
40/// `T` must implement [`Trace`] to be collected, so the collector can follow the
41/// handles each object owns. Resolution ([`get`](Heap::get)) and allocation do not
42/// require `Trace`; only [`collect`](Heap::collect) does.
43///
44/// # Examples
45///
46/// Allocate a small graph, drop a root, and collect the unreachable part:
47///
48/// ```
49/// use gc_lang::{Gc, Heap, Trace, Tracer};
50///
51/// struct Node {
52///     edges: Vec<Gc<Node>>,
53/// }
54///
55/// impl Trace for Node {
56///     fn trace(&self, tracer: &mut Tracer<'_>) {
57///         for &e in &self.edges {
58///             tracer.mark(e);
59///         }
60///     }
61/// }
62///
63/// let mut heap = Heap::new();
64/// let leaf = heap.alloc(Node { edges: vec![] });
65/// let root = heap.alloc(Node { edges: vec![leaf] });
66/// let orphan = heap.alloc(Node { edges: vec![] });
67///
68/// assert_eq!(heap.len(), 3);
69///
70/// // Collect with `root` as the only root: `root` and `leaf` survive, `orphan` does not.
71/// let stats = heap.collect([root]);
72/// assert_eq!(stats.freed, 1);
73/// assert_eq!(heap.len(), 2);
74/// assert!(heap.get(orphan).is_none());
75/// assert!(heap.get(leaf).is_some());
76/// ```
77pub struct Heap<T> {
78    /// Object storage, indexed by slot. Grows on demand; never shrinks.
79    slots: Vec<Slot<T>>,
80    /// Indices of reclaimed slots available for reuse, most-recently-freed first.
81    free: Vec<u32>,
82    /// Number of occupied slots. Tracked incrementally so [`len`](Heap::len) is O(1).
83    live: usize,
84    /// Retained mark-phase work queue: `(index, generation)` pairs. Pooled so a
85    /// collection allocates nothing once the queue has grown to its working size.
86    worklist: Vec<(u32, u32)>,
87    /// Retained mark bitset, one bit per slot, packed 64 to a word. Pooled and
88    /// cleared at the start of each collection.
89    marks: Vec<u64>,
90}
91
92impl<T> Heap<T> {
93    /// Creates an empty heap. `const`, so it can initialise a `static`.
94    ///
95    /// No allocation happens until the first value is added.
96    ///
97    /// # Examples
98    ///
99    /// ```
100    /// use gc_lang::Heap;
101    ///
102    /// let heap: Heap<u32> = Heap::new();
103    /// assert!(heap.is_empty());
104    /// ```
105    #[inline]
106    #[must_use]
107    pub const fn new() -> Self {
108        Self {
109            slots: Vec::new(),
110            free: Vec::new(),
111            live: 0,
112            worklist: Vec::new(),
113            marks: Vec::new(),
114        }
115    }
116
117    /// Creates an empty heap with room for `capacity` objects preallocated.
118    ///
119    /// A hint only: it reserves backing storage so the first `capacity` allocations
120    /// do not reallocate. Sizing it to the runtime's expected live-object count
121    /// keeps allocation off the reallocation path during warm-up.
122    ///
123    /// # Examples
124    ///
125    /// ```
126    /// use gc_lang::Heap;
127    ///
128    /// let heap: Heap<u64> = Heap::with_capacity(1024);
129    /// assert!(heap.capacity() >= 1024);
130    /// ```
131    #[inline]
132    #[must_use]
133    pub fn with_capacity(capacity: usize) -> Self {
134        Self {
135            slots: Vec::with_capacity(capacity),
136            free: Vec::new(),
137            live: 0,
138            worklist: Vec::new(),
139            marks: Vec::new(),
140        }
141    }
142
143    /// Allocates `value` and returns a stable [`Gc<T>`] handle to it.
144    ///
145    /// This is the hot path. It reuses a slot freed by an earlier collection when
146    /// one is available, and only grows the backing store otherwise. The handle
147    /// stays valid until the object it names is collected.
148    ///
149    /// # Panics
150    ///
151    /// Panics only if the heap has already exhausted its slot space — more than
152    /// `u32::MAX` slots that were never reclaimed, an unreachable ceiling for a
153    /// heap that collects. Use [`try_alloc`](Heap::try_alloc) for an explicit
154    /// non-panicking path.
155    ///
156    /// # Examples
157    ///
158    /// ```
159    /// use gc_lang::{Heap, Trace, Tracer};
160    ///
161    /// struct Leaf;
162    /// impl Trace for Leaf {
163    ///     fn trace(&self, _: &mut Tracer<'_>) {}
164    /// }
165    ///
166    /// let mut heap = Heap::new();
167    /// let handle = heap.alloc(Leaf);
168    /// assert!(heap.get(handle).is_some());
169    /// ```
170    #[inline]
171    pub fn alloc(&mut self, value: T) -> Gc<T> {
172        match self.try_alloc(value) {
173            Ok(handle) => handle,
174            Err(_) => panic!("heap is full: cannot address beyond u32::MAX slots"),
175        }
176    }
177
178    /// Allocates `value`, returning its [`Gc<T>`] handle or an error if the heap's
179    /// slot space is exhausted.
180    ///
181    /// The non-panicking counterpart to [`alloc`](Heap::alloc): identical on
182    /// success, but it returns [`GcError::CapacityExhausted`] instead of panicking
183    /// at the slot-space ceiling. Prefer it when a runtime allocates in response to
184    /// untrusted input whose volume it does not control.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`GcError::CapacityExhausted`] when every one of the `u32::MAX + 1`
189    /// slot indices is in use and no slot is free. The heap is left unchanged;
190    /// running a collection to reclaim dead slots is the way to recover.
191    ///
192    /// # Examples
193    ///
194    /// ```
195    /// use gc_lang::{Heap, Trace, Tracer};
196    ///
197    /// struct Leaf;
198    /// impl Trace for Leaf {
199    ///     fn trace(&self, _: &mut Tracer<'_>) {}
200    /// }
201    ///
202    /// let mut heap = Heap::new();
203    /// let handle = heap.try_alloc(Leaf)?;
204    /// assert!(heap.get(handle).is_some());
205    /// # Ok::<(), gc_lang::GcError>(())
206    /// ```
207    #[inline]
208    pub fn try_alloc(&mut self, value: T) -> Result<Gc<T>, GcError> {
209        if let Some(index) = self.free.pop() {
210            // Reuse a reclaimed slot. Its generation was advanced when it was freed,
211            // so any handle to the slot's previous occupant no longer matches.
212            let slot = &mut self.slots[index as usize];
213            slot.value = Some(value);
214            let generation = slot.generation;
215            self.live += 1;
216            return Ok(Gc::new(index, generation));
217        }
218
219        // No free slot: append a fresh one. The next index is the current slot
220        // count; if that no longer fits in a `u32`, the slot space is exhausted.
221        // Checked before the push, so a rejected allocation leaves the heap intact.
222        let index = u32::try_from(self.slots.len()).map_err(|_| GcError::CapacityExhausted)?;
223        self.slots.push(Slot {
224            value: Some(value),
225            generation: 0,
226        });
227        self.live += 1;
228        Ok(Gc::new(index, 0))
229    }
230
231    /// Borrows the object behind `handle`, or `None` if the handle does not name a
232    /// live object in this heap.
233    ///
234    /// Resolution is a direct slot lookup, not a search. The `None` case covers both
235    /// an out-of-range handle and a stale one — a handle whose object was collected
236    /// and whose slot has since moved on to a new generation — so resolving a
237    /// handle never reads an unrelated value.
238    ///
239    /// # Examples
240    ///
241    /// ```
242    /// use gc_lang::{Heap, Trace, Tracer};
243    ///
244    /// struct Payload(u32);
245    /// impl Trace for Payload {
246    ///     fn trace(&self, _: &mut Tracer<'_>) {}
247    /// }
248    ///
249    /// let mut heap = Heap::new();
250    /// let handle = heap.alloc(Payload(7));
251    /// assert_eq!(heap.get(handle).map(|p| p.0), Some(7));
252    /// ```
253    #[inline]
254    #[must_use]
255    pub fn get(&self, handle: Gc<T>) -> Option<&T> {
256        let slot = self.slots.get(handle.index() as usize)?;
257        if slot.generation == handle.generation() {
258            slot.value.as_ref()
259        } else {
260            None
261        }
262    }
263
264    /// Mutably borrows the object behind `handle`, or `None` if the handle does not
265    /// name a live object in this heap.
266    ///
267    /// The mutating counterpart to [`get`](Heap::get), with the same staleness
268    /// guarantees. Use it to update an object in place — including rewiring the
269    /// handles it holds, which is how a runtime mutates its object graph.
270    ///
271    /// # Examples
272    ///
273    /// ```
274    /// use gc_lang::{Heap, Trace, Tracer};
275    ///
276    /// struct Cell(i64);
277    /// impl Trace for Cell {
278    ///     fn trace(&self, _: &mut Tracer<'_>) {}
279    /// }
280    ///
281    /// let mut heap = Heap::new();
282    /// let handle = heap.alloc(Cell(0));
283    /// if let Some(cell) = heap.get_mut(handle) {
284    ///     cell.0 = 42;
285    /// }
286    /// assert_eq!(heap.get(handle).map(|c| c.0), Some(42));
287    /// ```
288    #[inline]
289    pub fn get_mut(&mut self, handle: Gc<T>) -> Option<&mut T> {
290        let slot = self.slots.get_mut(handle.index() as usize)?;
291        if slot.generation == handle.generation() {
292            slot.value.as_mut()
293        } else {
294            None
295        }
296    }
297
298    /// Returns `true` if `handle` names a live object in this heap.
299    ///
300    /// Equivalent to `heap.get(handle).is_some()`, without producing a borrow.
301    ///
302    /// # Examples
303    ///
304    /// ```
305    /// use gc_lang::{Heap, Trace, Tracer};
306    ///
307    /// struct Leaf;
308    /// impl Trace for Leaf {
309    ///     fn trace(&self, _: &mut Tracer<'_>) {}
310    /// }
311    ///
312    /// let mut heap = Heap::new();
313    /// let handle = heap.alloc(Leaf);
314    /// assert!(heap.contains(handle));
315    /// ```
316    #[inline]
317    #[must_use]
318    pub fn contains(&self, handle: Gc<T>) -> bool {
319        match self.slots.get(handle.index() as usize) {
320            Some(slot) => slot.generation == handle.generation() && slot.value.is_some(),
321            None => false,
322        }
323    }
324
325    /// Returns the number of live objects in the heap.
326    ///
327    /// This is the occupied-slot count, not the backing-store size; freed slots
328    /// awaiting reuse are not counted.
329    ///
330    /// # Examples
331    ///
332    /// ```
333    /// use gc_lang::{Heap, Trace, Tracer};
334    ///
335    /// struct Leaf;
336    /// impl Trace for Leaf {
337    ///     fn trace(&self, _: &mut Tracer<'_>) {}
338    /// }
339    ///
340    /// let mut heap = Heap::new();
341    /// assert_eq!(heap.len(), 0);
342    /// heap.alloc(Leaf);
343    /// assert_eq!(heap.len(), 1);
344    /// ```
345    #[inline]
346    #[must_use]
347    pub fn len(&self) -> usize {
348        self.live
349    }
350
351    /// Returns `true` if the heap holds no live objects.
352    ///
353    /// # Examples
354    ///
355    /// ```
356    /// use gc_lang::{Heap, Trace, Tracer};
357    ///
358    /// struct Leaf;
359    /// impl Trace for Leaf {
360    ///     fn trace(&self, _: &mut Tracer<'_>) {}
361    /// }
362    ///
363    /// let mut heap = Heap::new();
364    /// assert!(heap.is_empty());
365    /// heap.alloc(Leaf);
366    /// assert!(!heap.is_empty());
367    /// ```
368    #[inline]
369    #[must_use]
370    pub fn is_empty(&self) -> bool {
371        self.live == 0
372    }
373
374    /// Returns the number of slots the backing store can hold before it must grow.
375    ///
376    /// Reflects allocated capacity, including slots currently free. It never
377    /// decreases across a collection: sweeping returns slots to the free list rather
378    /// than releasing memory, so the store stays sized to the high-water mark.
379    ///
380    /// # Examples
381    ///
382    /// ```
383    /// use gc_lang::Heap;
384    ///
385    /// let heap: Heap<u64> = Heap::with_capacity(64);
386    /// assert!(heap.capacity() >= 64);
387    /// ```
388    #[inline]
389    #[must_use]
390    pub fn capacity(&self) -> usize {
391        self.slots.capacity()
392    }
393
394    /// Reclaims every object not reachable from `roots`, returning what the pass did.
395    ///
396    /// This is a tracing mark-and-sweep collection in two phases. **Mark:** starting
397    /// from each root, the collector follows the handles every object reports through
398    /// [`Trace::trace`], visiting each reachable object exactly once — so cycles
399    /// terminate and shared subgraphs are not re-scanned. **Sweep:** every object
400    /// that was not marked is dropped, its slot's generation is advanced, and the
401    /// slot is returned to the free list for reuse.
402    ///
403    /// `roots` is the set of handles the runtime considers live from the outside: an
404    /// interpreter's value stack, its global environment, VM registers. Anything
405    /// reachable from a root survives; anything else — including whole unreachable
406    /// cycles — is reclaimed. A root handle that is already stale is ignored, so it
407    /// is safe to pass a conservative, slightly-oversized root set.
408    ///
409    /// The cost is `O(reachable)` to mark plus `O(slots)` to sweep. The mark queue
410    /// and mark bitset are retained between calls, so a steady-state collection does
411    /// not allocate.
412    ///
413    /// # Examples
414    ///
415    /// Two objects in a cycle, unreachable from any root, are still collected:
416    ///
417    /// ```
418    /// use gc_lang::{Gc, Heap, Trace, Tracer};
419    ///
420    /// struct Node {
421    ///     link: Option<Gc<Node>>,
422    /// }
423    /// impl Trace for Node {
424    ///     fn trace(&self, tracer: &mut Tracer<'_>) {
425    ///         if let Some(link) = self.link {
426    ///             tracer.mark(link);
427    ///         }
428    ///     }
429    /// }
430    ///
431    /// let mut heap = Heap::new();
432    /// let a = heap.alloc(Node { link: None });
433    /// let b = heap.alloc(Node { link: Some(a) });
434    /// heap.get_mut(a).unwrap().link = Some(b); // a <-> b cycle, no external root
435    ///
436    /// let stats = heap.collect([]); // empty root set
437    /// assert_eq!(stats.freed, 2);
438    /// assert!(heap.is_empty());
439    /// ```
440    pub fn collect<I>(&mut self, roots: I) -> CollectStats
441    where
442        I: IntoIterator<Item = Gc<T>>,
443        T: Trace,
444    {
445        // Seed the work queue with the roots. Reuse the pooled queue so a
446        // steady-state collection does not allocate. Roots are validated at pop
447        // time along with everything else, so stale roots cost nothing here.
448        let mut work = core::mem::take(&mut self.worklist);
449        work.clear();
450        for root in roots {
451            work.push((root.index(), root.generation()));
452        }
453
454        // A fresh, zeroed mark bit for every slot.
455        self.reset_marks();
456
457        // Mark: drain the queue, marking each live, current, not-yet-marked slot and
458        // enqueuing the handles it reports. The mark check makes cycles terminate.
459        while let Some((index, generation)) = work.pop() {
460            let i = index as usize;
461            let current = matches!(
462                self.slots.get(i),
463                Some(slot) if slot.generation == generation && slot.value.is_some()
464            );
465            if !current || bit_is_set(&self.marks, i) {
466                continue;
467            }
468            set_bit(&mut self.marks, i);
469            // Immutable borrow of `slots` plus a mutable borrow of the local `work`:
470            // disjoint, so tracing children needs no unsafe and no second pass.
471            if let Some(value) = self.slots[i].value.as_ref() {
472                value.trace(&mut Tracer::new(&mut work));
473            }
474        }
475
476        // Sweep: drop every unmarked occupant, advance its slot's generation to
477        // invalidate outstanding handles, and return the slot to the free list.
478        let mut freed = 0usize;
479        for i in 0..self.slots.len() {
480            let marked = bit_is_set(&self.marks, i);
481            let slot = &mut self.slots[i];
482            if slot.value.is_some() && !marked {
483                slot.value = None; // drops the object
484                slot.generation = slot.generation.wrapping_add(1);
485                self.free.push(i as u32);
486                freed += 1;
487            }
488        }
489        self.live -= freed;
490
491        // Return the pooled queue for the next collection to reuse.
492        self.worklist = work;
493
494        CollectStats {
495            live: self.live,
496            freed,
497        }
498    }
499
500    /// Resizes the mark bitset to cover every slot and zeroes it. The allocation is
501    /// reused across collections; only a grow past the high-water mark reallocates.
502    #[inline]
503    fn reset_marks(&mut self) {
504        let words = self.slots.len().div_ceil(64);
505        self.marks.clear();
506        self.marks.resize(words, 0);
507    }
508}
509
510/// Reads the mark bit for slot `i`.
511#[inline]
512fn bit_is_set(marks: &[u64], i: usize) -> bool {
513    (marks[i >> 6] >> (i & 63)) & 1 == 1
514}
515
516/// Sets the mark bit for slot `i`.
517#[inline]
518fn set_bit(marks: &mut [u64], i: usize) {
519    marks[i >> 6] |= 1u64 << (i & 63);
520}
521
522impl<T> Default for Heap<T> {
523    #[inline]
524    fn default() -> Self {
525        Self::new()
526    }
527}
528
529impl<T> fmt::Debug for Heap<T> {
530    /// Shows the heap's shape — live objects, free slots, capacity — not its
531    /// contents. A heap can hold millions of objects, and dumping them is rarely
532    /// what a debug print wants; this also keeps `Heap<T>: Debug` free of a
533    /// `T: Debug` bound.
534    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
535        f.debug_struct("Heap")
536            .field("live", &self.live)
537            .field("free", &self.free.len())
538            .field("capacity", &self.slots.capacity())
539            .finish()
540    }
541}
542
543/// What a [`collect`](Heap::collect) pass did.
544///
545/// Returned by [`Heap::collect`]. `live + freed` equals the number of objects that
546/// were resident when the pass began.
547///
548/// The struct is `#[non_exhaustive]`: a later phase may report more (bytes
549/// reclaimed, pause time), so construct it only through the collector and read the
550/// fields you need.
551#[derive(Clone, Copy, Debug, PartialEq, Eq)]
552#[non_exhaustive]
553pub struct CollectStats {
554    /// Objects that survived the collection — the reachable set.
555    pub live: usize,
556    /// Objects reclaimed by the collection — the unreachable set.
557    pub freed: usize,
558}
559
560#[cfg(test)]
561mod tests {
562    #![allow(clippy::unwrap_used, clippy::expect_used)]
563
564    extern crate alloc;
565    use alloc::vec;
566    use alloc::vec::Vec;
567
568    use super::*;
569
570    /// A node with arbitrary outgoing edges — enough to build any object graph.
571    struct Node {
572        edges: Vec<Gc<Node>>,
573    }
574
575    impl Node {
576        fn leaf() -> Self {
577            Self { edges: Vec::new() }
578        }
579        fn with(edges: Vec<Gc<Node>>) -> Self {
580            Self { edges }
581        }
582    }
583
584    impl Trace for Node {
585        fn trace(&self, tracer: &mut Tracer<'_>) {
586            for &edge in &self.edges {
587                tracer.mark(edge);
588            }
589        }
590    }
591
592    #[test]
593    fn test_alloc_then_get_round_trips() {
594        let mut heap = Heap::new();
595        let handle = heap.alloc(Node::leaf());
596        assert!(heap.get(handle).is_some());
597        assert!(heap.contains(handle));
598        assert_eq!(heap.len(), 1);
599        assert!(!heap.is_empty());
600    }
601
602    #[test]
603    fn test_unreachable_object_is_collected() {
604        let mut heap = Heap::new();
605        let keep = heap.alloc(Node::leaf());
606        let drop = heap.alloc(Node::leaf());
607        let stats = heap.collect([keep]);
608        assert_eq!(stats.freed, 1);
609        assert_eq!(stats.live, 1);
610        assert!(heap.get(keep).is_some());
611        assert!(heap.get(drop).is_none());
612    }
613
614    #[test]
615    fn test_reachable_subgraph_survives() {
616        let mut heap = Heap::new();
617        let leaf = heap.alloc(Node::leaf());
618        let mid = heap.alloc(Node::with(vec![leaf]));
619        let root = heap.alloc(Node::with(vec![mid]));
620        let orphan = heap.alloc(Node::leaf());
621
622        let stats = heap.collect([root]);
623        assert_eq!(stats.freed, 1); // only the orphan
624        assert!(heap.get(root).is_some());
625        assert!(heap.get(mid).is_some());
626        assert!(heap.get(leaf).is_some());
627        assert!(heap.get(orphan).is_none());
628    }
629
630    #[test]
631    fn test_cycle_with_no_root_is_collected() {
632        let mut heap = Heap::new();
633        let a = heap.alloc(Node::leaf());
634        let b = heap.alloc(Node::with(vec![a]));
635        heap.get_mut(a).expect("a is live").edges.push(b); // a <-> b
636        let stats = heap.collect([]);
637        assert_eq!(stats.freed, 2);
638        assert!(heap.is_empty());
639    }
640
641    #[test]
642    fn test_self_cycle_is_collected() {
643        let mut heap = Heap::new();
644        let a = heap.alloc(Node::leaf());
645        heap.get_mut(a).expect("a is live").edges.push(a); // a -> a
646        let stats = heap.collect([]);
647        assert_eq!(stats.freed, 1);
648        assert!(heap.get(a).is_none());
649    }
650
651    #[test]
652    fn test_freed_slot_is_reused_and_old_handle_goes_stale() {
653        let mut heap = Heap::new();
654        let first = heap.alloc(Node::leaf());
655        let _ = heap.collect([]); // frees `first`'s slot
656        assert!(heap.get(first).is_none());
657
658        // The next allocation reuses the slot but at a new generation.
659        let second = heap.alloc(Node::leaf());
660        assert_eq!(first.index(), second.index());
661        assert_ne!(first.generation(), second.generation());
662        assert!(heap.get(second).is_some());
663        assert!(heap.get(first).is_none()); // the stale handle stays dead
664    }
665
666    #[test]
667    fn test_capacity_does_not_grow_across_steady_state_loop() {
668        let mut heap: Heap<Node> = Heap::with_capacity(4);
669        let baseline = heap.capacity();
670        for _ in 0..1000 {
671            let a = heap.alloc(Node::leaf());
672            let b = heap.alloc(Node::leaf());
673            let _ = heap.alloc(Node::with(vec![a, b]));
674            let _ = heap.collect([]); // nothing rooted: reclaim all three
675        }
676        assert!(heap.is_empty());
677        assert_eq!(
678            heap.capacity(),
679            baseline,
680            "slots should be reused, not grown"
681        );
682    }
683
684    #[test]
685    fn test_collect_twice_is_idempotent_on_survivors() {
686        let mut heap = Heap::new();
687        let root = heap.alloc(Node::leaf());
688        let s1 = heap.collect([root]);
689        let s2 = heap.collect([root]);
690        assert_eq!(s1.live, 1);
691        assert_eq!(s2.freed, 0);
692        assert_eq!(s2.live, 1);
693        assert!(heap.get(root).is_some());
694    }
695
696    #[test]
697    fn test_stale_root_is_ignored() {
698        let mut heap = Heap::new();
699        let gone = heap.alloc(Node::leaf());
700        let _ = heap.collect([]); // `gone` is now stale
701        let live = heap.alloc(Node::leaf());
702        // Passing the stale handle as a root must not resurrect anything or panic.
703        let stats = heap.collect([gone, live]);
704        assert_eq!(stats.live, 1);
705        assert!(heap.get(live).is_some());
706    }
707
708    #[test]
709    fn test_out_of_range_handle_resolves_to_none() {
710        let mut big = Heap::new();
711        let mut last = big.alloc(Node::leaf());
712        for _ in 0..50 {
713            last = big.alloc(Node::leaf());
714        }
715        let small: Heap<Node> = Heap::new();
716        assert!(small.get(last).is_none());
717        assert!(!small.contains(last));
718    }
719
720    #[test]
721    fn test_empty_heap_collect_is_a_noop() {
722        let mut heap: Heap<Node> = Heap::new();
723        let stats = heap.collect([]);
724        assert_eq!(stats.freed, 0);
725        assert_eq!(stats.live, 0);
726    }
727
728    #[test]
729    fn test_debug_reports_shape_not_contents() {
730        let mut heap = Heap::new();
731        let _ = heap.alloc(Node::leaf());
732        let text = alloc::format!("{heap:?}");
733        assert!(text.contains("live"), "{text}");
734        assert!(text.contains("capacity"), "{text}");
735    }
736}