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}