Skip to main content

lua_gc/
heap.rs

1//! Mark-and-sweep garbage collector.
2//!
3//! This module is the production GC for the Lua runtime — a single-threaded
4//! precise tracing collector with incremental and generational paths plus
5//! forward/backward write barriers. `GcRef<T>` (in `lua-types`) is a type
6//! alias for this crate's `Gc<T>`; an earlier revision backed `GcRef<T>`
7//! with a plain `Rc<T>` instead.
8//!
9//! # Vocabulary
10//!
11//! - **Gc<T>**: a pointer-sized handle. `Copy + Clone`. Replaces `GcRef<T>`.
12//! - **GcBox<T>**: the heap allocation; contains a header and the value.
13//! - **GcHeader**: per-object metadata (color, age, finalized flag, and an
14//!   intrusive `next` pointer for exactly one heap owner list). The grayagain
15//!   revisit set is heap-owned (`Heap::grayagain`), not an intrusive link.
16//! - **Trace**: trait every GC-rooted type implements. The `trace` method
17//!   walks all `Gc<_>` fields and calls `Marker::mark` on each.
18//! - **Marker**: passed to `trace`; carries the gray queue.
19//! - **Heap**: owns the allgc/finobj/tobefnz list heads, byte counters, and
20//!   GC state machine.
21//!
22//! # Safety model
23//!
24//! All `unsafe` is confined to this crate (per `harness/unsafe-budgets.toml`).
25//! The invariants are:
26//!
27//! 1. Every `Gc<T>` points to a valid, allocated, not-yet-swept `GcBox<T>`.
28//! 2. The intrusive heap lists are consistent: traversing `header.next` from
29//!    `Heap.head`, `Heap.finobj`, and `Heap.tobefnz` reaches every live
30//!    heap-owned `GcBox` exactly once.
31//! 3. After `Heap::full_collect(roots)`, every `Gc<T>` reachable from `roots`
32//!    is still valid; unreachable boxes are dropped and deallocated.
33//!
34//! # Allocation paths
35//!
36//! `GcRef::new(value)` (in `lua-types`) allocates through
37//! `state.heap().allocate(value)`, joining the active heap's owner lists; a
38//! guard-less call panics rather than silently falling back to an
39//! untracked allocation (issue #249). `Gc::new_uncollected` remains a
40//! separate, explicit escape hatch for the rare detached/process-lifetime
41//! case — it allocates a `GcBox` but does not register it in any heap.
42
43use std::cell::{Cell, RefCell};
44use std::collections::{HashMap, HashSet};
45use std::hash::{BuildHasherDefault, Hasher};
46use std::marker::PhantomData;
47use std::ptr::NonNull;
48
49#[derive(Default)]
50struct IdentityHasher {
51    value: u64,
52}
53
54impl Hasher for IdentityHasher {
55    #[inline]
56    fn write(&mut self, bytes: &[u8]) {
57        const PRIME: u64 = 0x0000_0100_0000_01b3;
58        for &byte in bytes {
59            self.value ^= u64::from(byte);
60            self.value = self.value.wrapping_mul(PRIME);
61        }
62    }
63
64    #[inline]
65    fn write_usize(&mut self, i: usize) {
66        self.value = i as u64;
67    }
68
69    #[inline]
70    fn write_u64(&mut self, i: u64) {
71        self.value = i;
72    }
73
74    #[inline]
75    fn finish(&self) -> u64 {
76        let mut x = self.value;
77        x ^= x >> 30;
78        x = x.wrapping_mul(0xbf58_476d_1ce4_e5b9);
79        x ^= x >> 27;
80        x = x.wrapping_mul(0x94d0_49bb_1331_11eb);
81        x ^ (x >> 31)
82    }
83}
84
85type IdentityBuildHasher = BuildHasherDefault<IdentityHasher>;
86type IdentityHashSet = HashSet<usize, IdentityBuildHasher>;
87type IdentityHashMap<V> = HashMap<usize, V, IdentityBuildHasher>;
88
89// ──────────────────────────────────────────────────────────────────────────
90// Scoped thread-local HeapGuard
91//
92// Lua's C API supports multiple `lua_State`s on one OS thread (sandbox-per-
93// state is a real embedding pattern). We honor that by stacking the
94// currently-active heap rather than holding a single slot. `HeapGuard::push`
95// activates a heap; drop pops it.
96//
97// `with_current_heap(...)` exposes the top of the stack only for the dynamic
98// extent of a closure — used by `GcRef::new` call sites that don't have
99// `&mut LuaState` in scope.
100// ──────────────────────────────────────────────────────────────────────────
101
102thread_local! {
103    static CURRENT_HEAP_STACK: RefCell<Vec<std::rc::Rc<Heap>>> = const { RefCell::new(Vec::new()) };
104}
105
106/// A scoped guard for the currently-active heap. Pushed at entry to
107/// `state.run()` / `state.protected_call()` / `state.load()`; popped on
108/// drop. Supports nesting (multiple LuaStates on one thread).
109///
110/// Holds a strong `Rc<Heap>` on the TLS stack (issue #252): the guard is
111/// sound with no outlives contract — a guard that outlives its `Lua` keeps
112/// the heap alive until the guard pops, rather than dangling. Guards are
113/// function-scoped everywhere in-tree, so the lifetime extension is
114/// transient by construction.
115///
116/// `!Send`/`!Sync` (via the `Rc` it carries): the stack it pops lives in
117/// this thread's TLS, so moving a guard to another thread would pop the
118/// wrong stack. The guard keeps its own copy of the heap handle so `Drop`
119/// can assert it pops the frame it pushed.
120pub struct HeapGuard {
121    heap: std::rc::Rc<Heap>,
122}
123
124impl HeapGuard {
125    /// Push `heap` onto the active stack. Returns a guard; dropping it pops.
126    pub fn push(heap: &std::rc::Rc<Heap>) -> Self {
127        CURRENT_HEAP_STACK.with(|stack| stack.borrow_mut().push(std::rc::Rc::clone(heap)));
128        HeapGuard {
129            heap: std::rc::Rc::clone(heap),
130        }
131    }
132}
133
134impl Drop for HeapGuard {
135    fn drop(&mut self) {
136        CURRENT_HEAP_STACK.with(|stack| {
137            let popped = stack.borrow_mut().pop();
138            debug_assert!(
139                popped
140                    .as_ref()
141                    .is_some_and(|top| std::rc::Rc::ptr_eq(top, &self.heap)),
142                "HeapGuard::drop popped a frame it did not push — guards must \
143                 drop in reverse push order on the thread that created them"
144            );
145        });
146    }
147}
148
149/// RAII handle for a heap bootstrap window; created by
150/// [`Heap::bootstrap_scope`], ends the window on drop. Exists so error paths
151/// (`?` during stdlib install, `lua_open` failure) cannot leave the heap
152/// stuck in bootstrap mode the way a manual `end_bootstrap` call can.
153///
154/// Sound with no lifetime and no unsafe: the scope shares ownership of the
155/// heap's depth counter (`Rc<Cell<usize>>`), so a scope that outlives its
156/// heap decrements a still-live cell instead of dereferencing a dead heap —
157/// the count on a dead heap is meaningless but harmless. (Contrast with
158/// [`HeapGuard::push`]'s raw-pointer contract, tracked for the same
159/// treatment in the heap-ownership follow-up.)
160pub struct BootstrapScope {
161    depth: std::rc::Rc<Cell<usize>>,
162}
163
164impl Drop for BootstrapScope {
165    fn drop(&mut self) {
166        let depth = self.depth.get();
167        debug_assert!(depth > 0, "BootstrapScope dropped with zero depth");
168        self.depth.set(depth.saturating_sub(1));
169    }
170}
171
172thread_local! {
173    static DETACHED_ALLOCATIONS: Cell<usize> = const { Cell::new(0) };
174}
175
176/// Total detached ([`Gc::new_uncollected`]) allocations made on this thread
177/// since it started. Leak canaries assert a zero delta across embedding
178/// scenarios. This counter deliberately lives outside the heap's own
179/// bookkeeping: detached boxes never touch `bytes`/`objects`, which is
180/// exactly the blind spot that hid issue #249 — a mechanism must not be
181/// verified with bookkeeping it maintains itself.
182pub fn detached_allocations() -> usize {
183    DETACHED_ALLOCATIONS.with(|c| c.get())
184}
185
186/// Runs `f` with a reference to the currently-active heap, or `None` if no
187/// `HeapGuard` is in scope.
188///
189/// The heap reference is deliberately scoped to the closure. This avoids the
190/// previous `current_heap() -> Option<&'static Heap>` lifetime lie while still
191/// supporting legacy `GcRef::new` call sites that do not receive `&mut LuaState`.
192pub fn with_current_heap<R>(f: impl for<'a> FnOnce(Option<&'a std::rc::Rc<Heap>>) -> R) -> R {
193    let top = CURRENT_HEAP_STACK.with(|stack| stack.borrow().last().cloned());
194    f(top.as_ref())
195}
196
197/// A non-owning heap identity handle for weak references (issue #252):
198/// holds a `Weak<Heap>`, so a `GcWeak` that outlives its heap answers
199/// "does the heap still contain my target" with a plain `false` — after
200/// sweep *or* after heap teardown, indistinguishably — instead of
201/// dereferencing freed memory. No unsafe, no outlives contract.
202#[derive(Clone, Debug)]
203pub struct HeapRef {
204    weak: std::rc::Weak<Heap>,
205}
206
207impl HeapRef {
208    pub fn from_heap(heap: &std::rc::Rc<Heap>) -> Self {
209        HeapRef {
210            weak: std::rc::Rc::downgrade(heap),
211        }
212    }
213
214    pub fn contains_allocation(&self, identity: usize, token: usize) -> bool {
215        match self.weak.upgrade() {
216            Some(heap) => heap.contains_allocation(identity, token),
217            None => false,
218        }
219    }
220}
221
222/// A traced color in the tri-color invariant.
223#[derive(Copy, Clone, PartialEq, Eq, Debug)]
224pub enum Color {
225    /// Not yet visited in the current cycle. The collector alternates between
226    /// two white bits so allocations made during sweep are not collected by
227    /// the cycle already in progress.
228    White0,
229    /// Alternate white bit.
230    White1,
231    /// Visited; outgoing references not yet traced.
232    Gray,
233    /// Fully traced; no outgoing pointers to white objects.
234    Black,
235}
236
237impl Color {
238    pub fn is_white(self) -> bool {
239        matches!(self, Color::White0 | Color::White1)
240    }
241
242    fn other_white(self) -> Self {
243        match self {
244            Color::White0 => Color::White1,
245            Color::White1 => Color::White0,
246            Color::Gray | Color::Black => self,
247        }
248    }
249}
250
251/// Object age used by Lua's generational collector.
252///
253/// Mirrors `G_NEW` through `G_TOUCHED2` in `lgc.h`.
254#[derive(Copy, Clone, PartialEq, Eq, Debug)]
255pub enum GcAge {
256    New,
257    Survival,
258    Old0,
259    Old1,
260    Old,
261    Touched1,
262    Touched2,
263}
264
265impl GcAge {
266    pub fn is_old(self) -> bool {
267        !matches!(self, GcAge::New | GcAge::Survival)
268    }
269
270    fn next_after_minor(self) -> Self {
271        match self {
272            GcAge::New => GcAge::Survival,
273            GcAge::Survival | GcAge::Old0 => GcAge::Old1,
274            GcAge::Old1 | GcAge::Old | GcAge::Touched2 => GcAge::Old,
275            GcAge::Touched1 => GcAge::Touched2,
276        }
277    }
278}
279
280/// A Lua 5.1 collect-time finalizability probe for a single userdata.
281///
282/// Lua 5.1 decides which userdata need finalization at collection time, not
283/// at `setmetatable` time: `luaC_separateudata` (`lgc.c`) walks every
284/// userdata and, for each white non-finalized one, reads its **live**
285/// metatable for `__gc`. This is observably different from 5.2+, where
286/// `luaC_checkfinalizer` makes the decision eagerly at `setmetatable` and a
287/// `__gc` added to a shared metatable afterwards never takes effect.
288///
289/// The collector cannot read a userdata's metatable (a VM concept), so the VM
290/// supplies one probe per userdata. The collector only stores probes and
291/// prunes dead ones via [`is_alive`](Self::is_alive); the VM drives the
292/// metatable read and the actual finalizer registration. Probes are erased
293/// behind [`std::any::Any`] so the VM can downcast back to its concrete type
294/// without the collector naming a VM type.
295pub trait Udata51Probe: std::any::Any {
296    /// True while the backing userdata box has not yet been swept. Backed by
297    /// a weak handle on the VM side, so this is safe to call after a sweep
298    /// freed the box.
299    fn is_alive(&self) -> bool;
300
301    /// Erased self for VM-side downcast.
302    fn as_any(&self) -> &dyn std::any::Any;
303}
304
305/// Minimal metadata a finalizable VM object must expose for collector-owned
306/// finalizer-list bookkeeping.
307pub trait FinalizerEntry: Clone {
308    fn identity(&self) -> usize;
309    fn heap_ptr(&self) -> Option<NonNull<GcBox<dyn Trace>>> {
310        None
311    }
312    fn age(&self) -> GcAge;
313    fn is_finalized(&self) -> bool;
314    fn set_finalized(&self, finalized: bool);
315}
316
317/// Minimal operations needed for collector-owned weak-registry bookkeeping.
318pub trait WeakEntry: Clone {
319    type Strong: Clone;
320
321    fn identity(&self) -> usize;
322    fn list_kind(&self) -> WeakListKind;
323    fn upgrade(&self) -> Option<Self::Strong>;
324}
325
326#[derive(Copy, Clone, Debug, PartialEq, Eq)]
327pub enum WeakListKind {
328    WeakValues,
329    Ephemeron,
330    AllWeak,
331}
332
333#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
334pub struct WeakRegistryStats {
335    pub tracked: usize,
336    pub snapshot_live: usize,
337    pub snapshot_dead: usize,
338    pub retained: usize,
339    pub weak_values: usize,
340    pub ephemeron: usize,
341    pub all_weak: usize,
342}
343
344#[derive(Clone, Debug)]
345pub struct WeakRegistry<T: WeakEntry> {
346    weak_values: Vec<T>,
347    ephemeron: Vec<T>,
348    all_weak: Vec<T>,
349    last_stats: WeakRegistryStats,
350}
351
352#[derive(Clone, Debug, PartialEq, Eq)]
353pub struct WeakRegistrySnapshot<T> {
354    pub weak_values: Vec<T>,
355    pub ephemeron: Vec<T>,
356    pub all_weak: Vec<T>,
357}
358
359impl<T> Default for WeakRegistrySnapshot<T> {
360    fn default() -> Self {
361        Self {
362            weak_values: Vec::new(),
363            ephemeron: Vec::new(),
364            all_weak: Vec::new(),
365        }
366    }
367}
368
369impl<T> WeakRegistrySnapshot<T> {
370    pub fn len(&self) -> usize {
371        self.weak_values
372            .len()
373            .saturating_add(self.ephemeron.len())
374            .saturating_add(self.all_weak.len())
375    }
376
377    pub fn into_flat(self) -> Vec<T> {
378        self.weak_values
379            .into_iter()
380            .chain(self.ephemeron)
381            .chain(self.all_weak)
382            .collect()
383    }
384}
385
386impl<T: WeakEntry> Default for WeakRegistry<T> {
387    fn default() -> Self {
388        Self {
389            weak_values: Vec::new(),
390            ephemeron: Vec::new(),
391            all_weak: Vec::new(),
392            last_stats: WeakRegistryStats::default(),
393        }
394    }
395}
396
397impl<T: WeakEntry> WeakRegistry<T> {
398    pub fn len(&self) -> usize {
399        self.weak_values
400            .len()
401            .saturating_add(self.ephemeron.len())
402            .saturating_add(self.all_weak.len())
403    }
404
405    pub fn stats(&self) -> WeakRegistryStats {
406        self.last_stats
407    }
408
409    fn list_mut(&mut self, kind: WeakListKind) -> &mut Vec<T> {
410        match kind {
411            WeakListKind::WeakValues => &mut self.weak_values,
412            WeakListKind::Ephemeron => &mut self.ephemeron,
413            WeakListKind::AllWeak => &mut self.all_weak,
414        }
415    }
416
417    pub fn remove_identity(&mut self, id: usize) {
418        self.weak_values.retain(|entry| entry.identity() != id);
419        self.ephemeron.retain(|entry| entry.identity() != id);
420        self.all_weak.retain(|entry| entry.identity() != id);
421        self.last_stats.tracked = self.len();
422        self.last_stats.retained = self.len();
423        self.update_cohort_stats();
424    }
425
426    fn update_cohort_stats(&mut self) {
427        self.last_stats.weak_values = self.weak_values.len();
428        self.last_stats.ephemeron = self.ephemeron.len();
429        self.last_stats.all_weak = self.all_weak.len();
430    }
431
432    pub fn push_unique(&mut self, entry: T) {
433        let id = entry.identity();
434        self.remove_identity(id);
435        self.list_mut(entry.list_kind()).push(entry);
436        self.last_stats.tracked = self.len();
437        self.last_stats.retained = self.len();
438        self.update_cohort_stats();
439    }
440
441    pub fn live_snapshot_by_kind(&mut self) -> WeakRegistrySnapshot<T::Strong> {
442        let tracked_before = self.len();
443        let weak_values_capacity = self.weak_values.len();
444        let ephemeron_capacity = self.ephemeron.len();
445        let all_weak_capacity = self.all_weak.len();
446        let mut seen = std::collections::HashSet::<usize>::with_capacity(tracked_before);
447        let mut live = WeakRegistrySnapshot {
448            weak_values: Vec::with_capacity(weak_values_capacity),
449            ephemeron: Vec::with_capacity(ephemeron_capacity),
450            all_weak: Vec::with_capacity(all_weak_capacity),
451        };
452        let mut dead = 0usize;
453
454        let entries = std::mem::take(&mut self.weak_values)
455            .into_iter()
456            .chain(std::mem::take(&mut self.ephemeron))
457            .chain(std::mem::take(&mut self.all_weak));
458        for entry in entries {
459            if !seen.insert(entry.identity()) {
460                continue;
461            }
462            match entry.upgrade() {
463                Some(strong) => {
464                    match entry.list_kind() {
465                        WeakListKind::WeakValues => live.weak_values.push(strong),
466                        WeakListKind::Ephemeron => live.ephemeron.push(strong),
467                        WeakListKind::AllWeak => live.all_weak.push(strong),
468                    }
469                    self.list_mut(entry.list_kind()).push(entry);
470                }
471                None => dead += 1,
472            }
473        }
474
475        self.last_stats = WeakRegistryStats {
476            tracked: tracked_before,
477            snapshot_live: live.len(),
478            snapshot_dead: dead,
479            retained: self.len(),
480            weak_values: self.weak_values.len(),
481            ephemeron: self.ephemeron.len(),
482            all_weak: self.all_weak.len(),
483        };
484        live
485    }
486
487    pub fn live_snapshot(&mut self) -> Vec<T::Strong> {
488        self.live_snapshot_by_kind().into_flat()
489    }
490
491    pub fn retain_identities(&mut self, ids: &std::collections::HashSet<usize>) {
492        self.weak_values
493            .retain(|entry| ids.contains(&entry.identity()));
494        self.ephemeron
495            .retain(|entry| ids.contains(&entry.identity()));
496        self.all_weak
497            .retain(|entry| ids.contains(&entry.identity()));
498        self.last_stats.retained = self.len();
499        self.last_stats.tracked = self.len();
500        self.update_cohort_stats();
501    }
502}
503
504#[derive(Clone, Debug)]
505pub struct FinalizerRegistry<T: FinalizerEntry> {
506    pending: Vec<T>,
507    to_be_finalized: Vec<T>,
508    pending_reallyold: usize,
509    pending_old1: usize,
510    pending_survival: usize,
511}
512
513#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
514pub struct FinalizerRegistryStats {
515    pub pending_young: usize,
516    pub pending_old: usize,
517    pub to_be_finalized_young: usize,
518    pub to_be_finalized_old: usize,
519    pub finobj_new: usize,
520    pub finobj_survival: usize,
521    pub finobj_old1: usize,
522    pub finobj_reallyold: usize,
523    pub finobj_minor_scan: usize,
524}
525
526impl<T: FinalizerEntry> Default for FinalizerRegistry<T> {
527    fn default() -> Self {
528        Self {
529            pending: Vec::new(),
530            to_be_finalized: Vec::new(),
531            pending_reallyold: 0,
532            pending_old1: 0,
533            pending_survival: 0,
534        }
535    }
536}
537
538impl<T: FinalizerEntry> FinalizerRegistry<T> {
539    fn pending_new_len(&self) -> usize {
540        self.pending.len().saturating_sub(
541            self.pending_reallyold
542                .saturating_add(self.pending_old1)
543                .saturating_add(self.pending_survival),
544        )
545    }
546
547    fn minor_scan_start(&self) -> usize {
548        self.pending_reallyold.saturating_add(self.pending_old1)
549    }
550
551    fn debug_assert_pending_cohorts(&self) {
552        debug_assert!(
553            self.pending_reallyold
554                .saturating_add(self.pending_old1)
555                .saturating_add(self.pending_survival)
556                <= self.pending.len()
557        );
558    }
559
560    pub fn pending(&self) -> &[T] {
561        &self.pending
562    }
563
564    pub fn pending_snapshot(&self) -> Vec<T> {
565        self.pending.clone()
566    }
567
568    pub fn pending_minor_snapshot(&self) -> Vec<T> {
569        self.pending[self.minor_scan_start().min(self.pending.len())..].to_vec()
570    }
571
572    pub fn to_be_finalized(&self) -> &[T] {
573        &self.to_be_finalized
574    }
575
576    pub fn pending_len(&self) -> usize {
577        self.pending.len()
578    }
579
580    pub fn to_be_finalized_len(&self) -> usize {
581        self.to_be_finalized.len()
582    }
583
584    pub fn has_to_be_finalized(&self) -> bool {
585        !self.to_be_finalized.is_empty()
586    }
587
588    pub fn stats(&self) -> FinalizerRegistryStats {
589        fn count_by_age<T: FinalizerEntry>(objects: &[T]) -> (usize, usize) {
590            objects
591                .iter()
592                .fold((0usize, 0usize), |(young, old), object| {
593                    if object.age().is_old() {
594                        (young, old + 1)
595                    } else {
596                        (young + 1, old)
597                    }
598                })
599        }
600        let (pending_young, pending_old) = count_by_age(&self.pending);
601        let (to_be_finalized_young, to_be_finalized_old) = count_by_age(&self.to_be_finalized);
602        FinalizerRegistryStats {
603            pending_young,
604            pending_old,
605            to_be_finalized_young,
606            to_be_finalized_old,
607            finobj_new: self.pending_new_len(),
608            finobj_survival: self.pending_survival,
609            finobj_old1: self.pending_old1,
610            finobj_reallyold: self.pending_reallyold,
611            finobj_minor_scan: self.pending.len().saturating_sub(self.minor_scan_start()),
612        }
613    }
614
615    pub fn push_pending_unique(&mut self, object: T) -> bool {
616        if object.is_finalized() {
617            return false;
618        }
619        let id = object.identity();
620        if !self.pending.iter().any(|o| o.identity() == id) {
621            object.set_finalized(true);
622            self.pending.push(object);
623            self.debug_assert_pending_cohorts();
624            true
625        } else {
626            false
627        }
628    }
629
630    pub fn take_pending(&mut self) -> Vec<T> {
631        self.pending_reallyold = 0;
632        self.pending_old1 = 0;
633        self.pending_survival = 0;
634        std::mem::take(&mut self.pending)
635    }
636
637    fn retain_pending_not_in(&mut self, ids: &std::collections::HashSet<usize>) {
638        if ids.is_empty() {
639            return;
640        }
641        let original_reallyold = self.pending_reallyold;
642        let original_old1 = self.pending_old1;
643        let original_survival = self.pending_survival;
644        let mut retained_reallyold = original_reallyold;
645        let mut retained_old1 = original_old1;
646        let mut retained_survival = original_survival;
647        let mut retained = Vec::with_capacity(self.pending.len());
648        for (index, object) in std::mem::take(&mut self.pending).into_iter().enumerate() {
649            if ids.contains(&object.identity()) {
650                if index < original_reallyold {
651                    retained_reallyold -= 1;
652                } else if index < original_reallyold + original_old1 {
653                    retained_old1 -= 1;
654                } else if index < original_reallyold + original_old1 + original_survival {
655                    retained_survival -= 1;
656                }
657            } else {
658                retained.push(object);
659            }
660        }
661        self.pending = retained;
662        self.pending_reallyold = retained_reallyold;
663        self.pending_old1 = retained_old1;
664        self.pending_survival = retained_survival;
665        self.debug_assert_pending_cohorts();
666    }
667
668    pub fn push_to_be_finalized(&mut self, object: T) {
669        object.set_finalized(true);
670        self.to_be_finalized.push(object);
671    }
672
673    fn extend_to_be_finalized(&mut self, objects: Vec<T>) -> Vec<T> {
674        let drain_order: Vec<T> = objects.into_iter().rev().collect();
675        for object in drain_order.iter().cloned() {
676            self.push_to_be_finalized(object);
677        }
678        drain_order
679    }
680
681    pub fn promote_pending_to_finalized(&mut self, objects: Vec<T>) -> Vec<T> {
682        if objects.is_empty() {
683            return Vec::new();
684        }
685        let mut ids: std::collections::HashSet<usize> =
686            std::collections::HashSet::with_capacity(objects.len());
687        ids.extend(objects.iter().map(|object| object.identity()));
688        self.retain_pending_not_in(&ids);
689        self.extend_to_be_finalized(objects)
690    }
691
692    pub fn promote_all_pending_to_old(&mut self) {
693        self.pending_reallyold = self.pending.len();
694        self.pending_old1 = 0;
695        self.pending_survival = 0;
696    }
697
698    pub fn reset_generation_boundaries(&mut self) {
699        self.pending_reallyold = 0;
700        self.pending_old1 = 0;
701        self.pending_survival = 0;
702    }
703
704    pub fn finish_minor_collection(&mut self) {
705        let new = self.pending_new_len();
706        self.pending_reallyold = self.pending_reallyold.saturating_add(self.pending_old1);
707        self.pending_old1 = self.pending_survival;
708        self.pending_survival = new;
709        self.debug_assert_pending_cohorts();
710    }
711
712    pub fn pop_to_be_finalized(&mut self) -> Option<T> {
713        let object = if self.to_be_finalized.is_empty() {
714            None
715        } else {
716            Some(self.to_be_finalized.remove(0))
717        };
718        if let Some(ref object) = object {
719            object.set_finalized(false);
720        }
721        object
722    }
723}
724
725/// Per-object GC metadata. Lives at the start of every `GcBox`.
726#[repr(C)]
727pub struct GcHeader {
728    /// Hot fields read/written by the mark/sweep/barrier loops keep their
729    /// own bytes — packing them measurably taxed gc-heavy workloads
730    /// (recount 2026-06-10: +4% Ir on gc_pressure). The 64 -> 40 byte diet
731    /// came from the COLD side instead: the diagnostics-only `type_name`
732    /// fat pointer became a `Trace` method, the three cold bool flags share
733    /// one byte, and the pacer size is u32. The 40 -> 24 byte diet (#113
734    /// Wave 1) removed the `gray_next` grayagain link entirely — the revisit
735    /// set now lives heap-side as `Heap::grayagain` (a `Vec`), leaving the
736    /// header `color + age + flags + pad + size + next` (24 B on 64-bit,
737    /// 16 B on wasm32).
738    color: Cell<Color>,
739    age: Cell<GcAge>,
740    /// Cold flags, one bit each: finalized (FINALIZEDBIT — set while the
741    /// object is registered in a pending/to-be-finalized list, cleared when
742    /// popped for `__gc`), collected (true iff this box is linked into a
743    /// heap owner list so it will be swept and its `size` refunded;
744    /// `new_uncollected` boxes stay false and must never have buffer bytes
745    /// charged — [`Gc::account_buffer`] no-ops), gray_listed (true while the
746    /// object has an entry in the heap-side `Heap::grayagain` revisit vector).
747    flags: Cell<u8>,
748    /// Rough byte size charged to the pacer for this object. Starts at the
749    /// `GcBox<T>` size and is adjusted in place by [`Gc::account_buffer`]
750    /// when the value's owned heap buffers (table array/node Vecs) grow or
751    /// shrink. Invariant: this is always exactly the amount sweep will
752    /// refund to the heap's byte counter when this object is freed. `u32`:
753    /// a single object cannot meaningfully exceed 4 GiB; setters saturate.
754    size: Cell<u32>,
755    /// Intrusive link into exactly one heap owner list.
756    next: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
757}
758
759const HDR_FINALIZED: u8 = 1;
760const HDR_COLLECTED: u8 = 2;
761const HDR_GRAY_LISTED: u8 = 4;
762/// Set on every box a `Heap` owns — both the sweepable `allocate` path and
763/// the never-swept `allocate_uncollected` bootstrap path — and never on
764/// detached `Gc::new_uncollected` boxes. Distinct from `HDR_COLLECTED`
765/// (sweepable only): strict-guard checks need "will this box ever be freed
766/// by a heap" (bootstrap boxes die in `drop_all`, so a guard-less weak
767/// handle to one dangles after heap teardown), not "is it sweepable".
768const HDR_HEAP_OWNED: u8 = 16;
769/// Set by sweep under `LUA_RS_GC_QUARANTINE=1` instead of freeing the box.
770/// Debug builds assert this bit is clear on every `Gc` dereference and on
771/// every `Marker::mark_box` visit, turning use-after-sweep into a
772/// deterministic panic with a backtrace (see `Heap::release_box`).
773const HDR_FREED: u8 = 8;
774
775impl GcHeader {
776    fn new_white(size: usize, color: Color, flags: u8) -> Self {
777        Self {
778            color: Cell::new(color),
779            age: Cell::new(GcAge::New),
780            flags: Cell::new(flags),
781            size: Cell::new(size.min(u32::MAX as usize) as u32),
782            next: Cell::new(None),
783        }
784    }
785
786    fn flag(&self, bit: u8) -> bool {
787        self.flags.get() & bit != 0
788    }
789
790    fn set_flag(&self, bit: u8, on: bool) {
791        let f = self.flags.get();
792        self.flags.set(if on { f | bit } else { f & !bit });
793    }
794
795    pub fn finalized(&self) -> bool {
796        self.flag(HDR_FINALIZED)
797    }
798
799    pub fn set_finalized(&self, finalized: bool) {
800        self.set_flag(HDR_FINALIZED, finalized);
801    }
802
803    pub fn collected(&self) -> bool {
804        self.flag(HDR_COLLECTED)
805    }
806
807    pub fn gray_listed(&self) -> bool {
808        self.flag(HDR_GRAY_LISTED)
809    }
810
811    pub fn set_gray_listed(&self, listed: bool) {
812        self.set_flag(HDR_GRAY_LISTED, listed);
813    }
814
815    pub fn size(&self) -> usize {
816        self.size.get() as usize
817    }
818
819    pub fn set_size(&self, size: usize) {
820        self.size.set(size.min(u32::MAX as usize) as u32);
821    }
822}
823
824/// A heap-allocated, GC-tracked value plus its header.
825#[repr(C)]
826pub struct GcBox<T: ?Sized> {
827    header: GcHeader,
828    value: T,
829}
830
831impl<T: ?Sized> GcBox<T> {
832    pub fn header(&self) -> &GcHeader {
833        &self.header
834    }
835    pub fn value(&self) -> &T {
836        &self.value
837    }
838}
839
840/// A GC-managed pointer. Copy + Clone (one-machine-word). Replaces `GcRef<T>`.
841pub struct Gc<T: ?Sized> {
842    ptr: NonNull<GcBox<T>>,
843    /// Marker so `Gc<T>` is treated as if it owns `T` for variance.
844    _marker: PhantomData<T>,
845}
846
847// SAFETY: Gc is just a pointer. The Cell-based interior mutability of the
848// header is single-threaded (the entire Lua runtime is single-threaded), so
849// no Send/Sync impls are needed and we don't provide them.
850impl<T: ?Sized> Copy for Gc<T> {}
851impl<T: ?Sized> Clone for Gc<T> {
852    fn clone(&self) -> Self {
853        *self
854    }
855}
856
857impl<T: ?Sized> PartialEq for Gc<T> {
858    fn eq(&self, other: &Self) -> bool {
859        std::ptr::addr_eq(self.ptr.as_ptr(), other.ptr.as_ptr())
860    }
861}
862impl<T: ?Sized> Eq for Gc<T> {}
863
864impl<T: ?Sized> std::hash::Hash for Gc<T> {
865    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
866        self.ptr.as_ptr().hash(state)
867    }
868}
869
870impl<T: ?Sized> std::fmt::Debug for Gc<T> {
871    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
872        write!(f, "Gc({:p})", self.ptr.as_ptr())
873    }
874}
875
876impl<T: Trace + 'static> Gc<T> {
877    /// Allocate a `GcBox<T>` outside any heap registry: a deliberate escape
878    /// hatch for the rare detached/process-lifetime case. The returned
879    /// `Gc<T>` is reachable only through the caller's own retention path;
880    /// without joining a heap owner list, it will never be swept (so
881    /// effectively leaks until process exit — same as Rc behavior).
882    pub fn new_uncollected(value: T) -> Self {
883        DETACHED_ALLOCATIONS.with(|c| c.set(c.get() + 1));
884        let size = std::mem::size_of::<T>();
885        let boxed = Box::new(GcBox {
886            header: GcHeader::new_white(size, Color::White0, 0),
887            value,
888        });
889        Gc {
890            ptr: NonNull::new(Box::into_raw(boxed)).expect("Box::into_raw is non-null"),
891            _marker: PhantomData,
892        }
893    }
894
895    /// Erased heap-list pointer for collector-owned intrusive bookkeeping.
896    pub fn as_trace_ptr(self) -> NonNull<GcBox<dyn Trace>> {
897        self.ptr
898    }
899}
900
901impl<T: ?Sized> Gc<T> {
902    /// Two `Gc<T>`s are identity-equal iff they point at the same box.
903    pub fn ptr_eq(a: Self, b: Self) -> bool {
904        std::ptr::addr_eq(a.ptr.as_ptr(), b.ptr.as_ptr())
905    }
906
907    /// Identity as a usize — usable as a hash table key for "is the *same
908    /// object*" lookups.
909    pub fn identity(self) -> usize {
910        self.ptr.as_ptr() as *const () as usize
911    }
912
913    /// Access the underlying value. Returns `&T` so callers can read fields
914    /// without taking the `Gc` apart. Interior mutability lives inside T's
915    /// own fields (Cell, RefCell, etc.).
916    fn as_box(&self) -> &GcBox<T> {
917        // SAFETY: A Gc<T> is constructed only by allocate() or
918        // new_uncollected(), both of which produce a valid GcBox. The box
919        // outlives the Gc until sweep, which only frees boxes not reachable
920        // from any root — so as long as this Gc is on the stack or in a
921        // traced field, the box is live.
922        let bx = unsafe { self.ptr.as_ref() };
923        debug_assert!(
924            !bx.header.flag(HDR_FREED),
925            "use-after-sweep: Gc<{}> dereferenced after the collector swept it \
926             (caught by LUA_RS_GC_QUARANTINE; this is a rooting bug — the object \
927             was reachable by execution but not by the root trace)",
928            std::any::type_name::<T>()
929        );
930        bx
931    }
932
933    fn header(&self) -> &GcHeader {
934        &self.as_box().header
935    }
936
937    /// True iff this box is linked into a sweepable heap owner list
938    /// (`HDR_COLLECTED` set) — the collector may free it during the owning
939    /// heap's lifetime. Detached (`new_uncollected`) boxes and heap-owned
940    /// bootstrap (`allocate_uncollected`) boxes report false.
941    pub fn is_heap_tracked(self) -> bool {
942        self.header().collected()
943    }
944
945    /// True iff some `Heap` will free this box — during collection
946    /// (`allocate`) or at teardown in `drop_all` (`allocate_uncollected`).
947    /// Only detached `Gc::new_uncollected` boxes report false. Strict-guard
948    /// mode keys on this: a guard-less weak handle or dropped buffer charge
949    /// is hazardous for any heap-owned box (a bootstrap box's weak handle
950    /// dangles after heap teardown just the same), while the detached
951    /// process-lifetime path is the sanctioned legacy behavior.
952    pub fn is_heap_owned(self) -> bool {
953        self.header().flag(HDR_HEAP_OWNED)
954    }
955
956    pub fn color(self) -> Color {
957        self.header().color.get()
958    }
959
960    pub fn set_color(self, color: Color) {
961        self.header().color.set(color);
962    }
963
964    pub fn age(self) -> GcAge {
965        self.header().age.get()
966    }
967
968    pub fn set_age(self, age: GcAge) {
969        self.header().age.set(age);
970    }
971
972    pub fn is_finalized(self) -> bool {
973        self.header().finalized()
974    }
975
976    pub fn set_finalized(self, finalized: bool) {
977        self.header().set_finalized(finalized);
978    }
979
980    /// Charge (`delta > 0`) or refund (`delta < 0`) `delta` bytes of this
981    /// object's owned heap buffers against the pacer, keeping `header.size`
982    /// as the single source of truth for what sweep will refund.
983    ///
984    /// No-op when `delta == 0` or when this box is not on a heap owner list
985    /// (`collected == false`): an uncollected box is never swept, so charging
986    /// it would permanently inflate the byte counter. On the collected path,
987    /// `header.size` and the heap's byte counter move together, so after sweep
988    /// frees this box it refunds exactly the bytes that were charged here.
989    pub fn account_buffer(&self, heap: &Heap, delta: isize) {
990        if delta == 0 {
991            return;
992        }
993        let header = self.header();
994        if !header.collected() {
995            return;
996        }
997        if delta >= 0 {
998            header.set_size(header.size().saturating_add(delta as usize));
999        } else {
1000            header.set_size(header.size().saturating_sub((-delta) as usize));
1001        }
1002        heap.adjust_bytes(delta);
1003    }
1004}
1005
1006impl<T: ?Sized> std::ops::Deref for Gc<T> {
1007    type Target = T;
1008    fn deref(&self) -> &T {
1009        &self.as_box().value
1010    }
1011}
1012
1013impl<T: ?Sized> AsRef<T> for Gc<T> {
1014    fn as_ref(&self) -> &T {
1015        &self.as_box().value
1016    }
1017}
1018
1019/// Every GC-rooted type implements `Trace` to expose its `Gc<_>` fields.
1020///
1021/// The `trace` method visits every reachable `Gc<_>` and calls
1022/// `Marker::mark` on it. For container fields (`Vec<LuaValue>`, etc.) call
1023/// `field.trace(m)` to delegate.
1024///
1025/// # Mechanical pattern
1026///
1027/// ```ignore
1028/// impl Trace for LuaTable {
1029///     fn trace(&self, m: &mut Marker) {
1030///         for v in self.array.iter() { v.trace(m); }
1031///         if let Some(mt) = self.metatable { m.mark(mt); }
1032///     }
1033/// }
1034/// ```
1035pub trait Trace {
1036    fn trace(&self, m: &mut Marker);
1037
1038    /// Concrete Rust type name for diagnostic/testC telemetry
1039    /// ([`Heap::type_name_count`]). Collector behavior must not branch on
1040    /// this. The default covers container blanket impls, which are never
1041    /// GC-boxed directly; concrete runtime types override it with
1042    /// `std::any::type_name::<Self>()`.
1043    fn type_name(&self) -> &'static str {
1044        "unknown"
1045    }
1046}
1047
1048// Common blanket impls so most container types Just Work.
1049impl<T: Trace> Trace for Vec<T> {
1050    fn trace(&self, m: &mut Marker) {
1051        for item in self.iter() {
1052            item.trace(m);
1053        }
1054    }
1055}
1056
1057impl<T: Trace> Trace for Option<T> {
1058    fn trace(&self, m: &mut Marker) {
1059        if let Some(v) = self {
1060            v.trace(m);
1061        }
1062    }
1063}
1064
1065impl<T: Trace + ?Sized> Trace for Box<T> {
1066    fn trace(&self, m: &mut Marker) {
1067        (**self).trace(m);
1068    }
1069}
1070
1071impl<T: Trace + ?Sized> Trace for std::rc::Rc<T> {
1072    fn trace(&self, m: &mut Marker) {
1073        (**self).trace(m);
1074    }
1075}
1076
1077impl<T: Trace> Trace for RefCell<T> {
1078    fn trace(&self, m: &mut Marker) {
1079        self.borrow().trace(m);
1080    }
1081}
1082
1083/// `Gc<T>` is itself traceable: marking it forwards to the contained `T`.
1084impl<T: Trace + 'static> Trace for Gc<T> {
1085    fn trace(&self, m: &mut Marker) {
1086        m.mark(*self);
1087    }
1088}
1089
1090// Trivially-traceable primitive types: visiting does nothing.
1091macro_rules! trace_noop {
1092    ($($t:ty),*) => {
1093        $(impl Trace for $t {
1094            fn trace(&self, _m: &mut Marker) {}
1095        })*
1096    };
1097}
1098trace_noop!(
1099    bool, u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize, f32, f64, char, String,
1100    str
1101);
1102
1103impl<T> Trace for std::marker::PhantomData<T> {
1104    fn trace(&self, _m: &mut Marker) {}
1105}
1106
1107/// Diagnostic counters for the latest mark phase.
1108///
1109/// These are read-only telemetry for testC/canaries and unit tests. Collector
1110/// decisions must continue to use object color/age metadata, not these counts.
1111#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
1112pub struct MarkerStats {
1113    pub marked: usize,
1114    pub marked_young: usize,
1115    pub marked_old: usize,
1116    pub traced: usize,
1117    pub traced_young: usize,
1118    pub traced_old: usize,
1119}
1120
1121/// Diagnostic counters for the latest sweep phase.
1122#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
1123pub struct SweepStats {
1124    pub visited: usize,
1125    pub visited_young: usize,
1126    pub visited_old: usize,
1127    pub revisit: usize,
1128    pub freed: usize,
1129    pub freed_bytes: usize,
1130}
1131
1132impl SweepStats {
1133    fn record_visit(&mut self, age: GcAge) {
1134        self.visited += 1;
1135        if age.is_old() {
1136            self.visited_old += 1;
1137        } else {
1138            self.visited_young += 1;
1139        }
1140    }
1141
1142    fn record_free(&mut self, bytes: usize) {
1143        self.freed += 1;
1144        self.freed_bytes += bytes;
1145    }
1146
1147    fn add(&mut self, other: Self) {
1148        self.visited += other.visited;
1149        self.visited_young += other.visited_young;
1150        self.visited_old += other.visited_old;
1151        self.revisit += other.revisit;
1152        self.freed += other.freed;
1153        self.freed_bytes += other.freed_bytes;
1154    }
1155}
1156
1157struct OldRevisitTracker {
1158    old_revisit_ids: Vec<usize>,
1159    processed_ids: Vec<usize>,
1160}
1161
1162impl OldRevisitTracker {
1163    fn new(old_revisit: &[NonNull<GcBox<dyn Trace>>]) -> Option<Self> {
1164        if old_revisit.is_empty() {
1165            return None;
1166        }
1167        let mut old_revisit_ids: Vec<usize> = old_revisit
1168            .iter()
1169            .map(|ptr| ptr.as_ptr() as *const () as usize)
1170            .collect();
1171        old_revisit_ids.sort_unstable();
1172        old_revisit_ids.dedup();
1173        Some(Self {
1174            old_revisit_ids,
1175            processed_ids: Vec::new(),
1176        })
1177    }
1178
1179    #[inline(always)]
1180    fn record_processed(&mut self, id: usize) {
1181        if self.old_revisit_ids.binary_search(&id).is_ok() {
1182            self.processed_ids.push(id);
1183        }
1184    }
1185
1186    fn finish(&mut self) {
1187        self.processed_ids.sort_unstable();
1188        self.processed_ids.dedup();
1189    }
1190
1191    #[inline(always)]
1192    fn was_processed(&self, id: usize) -> bool {
1193        self.processed_ids.binary_search(&id).is_ok()
1194    }
1195}
1196
1197/// Diagnostic counts for the allgc list split by generational cursors.
1198#[derive(Copy, Clone, Default, Debug, PartialEq, Eq)]
1199pub struct AllGcCohortStats {
1200    pub new: usize,
1201    pub survival: usize,
1202    pub old1: usize,
1203    pub old: usize,
1204}
1205
1206#[derive(Copy, Clone, Debug, PartialEq, Eq)]
1207enum MarkerMode {
1208    Full,
1209    Minor,
1210}
1211
1212/// Holds the gray queue during a mark phase. Passed to `Trace::trace`.
1213pub struct Marker {
1214    gray_queue: Vec<NonNull<GcBox<dyn Trace>>>,
1215    visited: IdentityHashSet,
1216    stats: MarkerStats,
1217    mode: MarkerMode,
1218}
1219
1220impl Marker {
1221    fn new_with_capacity(mode: MarkerMode, capacity: usize) -> Self {
1222        Self {
1223            gray_queue: Vec::with_capacity(256),
1224            visited: IdentityHashSet::with_capacity_and_hasher(
1225                capacity,
1226                IdentityBuildHasher::default(),
1227            ),
1228            stats: MarkerStats::default(),
1229            mode,
1230        }
1231    }
1232
1233    fn should_trace_age(&self, age: GcAge) -> bool {
1234        match self.mode {
1235            MarkerMode::Full => true,
1236            MarkerMode::Minor => !matches!(age, GcAge::Old),
1237        }
1238    }
1239
1240    /// Mark a `Gc<T>` as gray (reachable, but its outgoing edges not yet
1241    /// traced). Called by `Trace::trace` implementations.
1242    ///
1243    /// Per-cycle dedup uses `visited` (a HashSet of box identities) rather
1244    /// than the color flag. Color-based dedup would silently skip
1245    /// `new_uncollected` boxes left Black by the previous cycle — those
1246    /// allocations are NOT on a heap owner list, so the start-of-mark
1247    /// "reset heap-owned objects to White" loop does not reach them, and a Black
1248    /// uncollected box would be skipped without re-tracing its children
1249    /// (causing reachable allgc descendants to be swept). The visited set
1250    /// is rebuilt every `full_collect` (Marker::new), so this dedup is
1251    /// always per-cycle.
1252    pub fn mark<T: Trace + 'static>(&mut self, gc: Gc<T>) {
1253        let ptr: NonNull<GcBox<dyn Trace>> = gc.ptr;
1254        self.mark_box(ptr, gc.header(), gc.identity());
1255    }
1256
1257    fn mark_box(&mut self, ptr: NonNull<GcBox<dyn Trace>>, header: &GcHeader, id: usize) {
1258        debug_assert!(
1259            !header.flag(HDR_FREED),
1260            "GC marker reached a quarantined (swept) object at {id:#x} — a root \
1261             traced a stale GcRef (caught by LUA_RS_GC_QUARANTINE; bug-B class: \
1262             garbage slot fed into the marker)"
1263        );
1264        if self.visited.insert(id) {
1265            let age = header.age.get();
1266            self.stats.marked += 1;
1267            if age.is_old() {
1268                self.stats.marked_old += 1;
1269            } else {
1270                self.stats.marked_young += 1;
1271            }
1272            if self.should_trace_age(age) {
1273                header.color.set(Color::Gray);
1274                self.gray_queue.push(ptr);
1275            }
1276        }
1277    }
1278
1279    /// Record that an object has been visited and return whether this is the
1280    /// first visit. Predates the real `Gc<T>` gray-queue path (`Color::Gray`
1281    /// via `Marker::mark`), which now handles cycles natively (e.g. `_G._G
1282    /// == _G`) without this bookkeeping; this method's only caller
1283    /// (`GcRef::trace_obj`) itself has no remaining call sites, so both look
1284    /// like dead code.
1285    pub fn try_visit(&mut self, addr: usize) -> bool {
1286        self.visited.insert(addr)
1287    }
1288
1289    /// True iff `id` was reached during the mark phase. Used by the
1290    /// post-mark hook (`Heap::full_collect_with_post_mark`) to decide whether
1291    /// a weak-table entry's target is still strongly reachable. Read-only —
1292    /// callers cannot add entries.
1293    pub fn is_visited(&self, id: usize) -> bool {
1294        self.visited.contains(&id)
1295    }
1296
1297    /// True when the object was marked in this cycle, or when a minor cycle
1298    /// deliberately skipped an old object that the young sweep will not free.
1299    pub fn is_marked_or_old<T: Trace + 'static>(&self, gc: Gc<T>) -> bool {
1300        self.is_visited(gc.identity())
1301            || (matches!(self.mode, MarkerMode::Minor) && gc.age().is_old())
1302    }
1303
1304    /// Number of objects marked so far. Used by the post-mark hook's
1305    /// ephemeron-convergence fixed-point loop to detect when an iteration
1306    /// added no new reachable objects and the loop can terminate.
1307    pub fn visited_count(&self) -> usize {
1308        self.visited.len()
1309    }
1310
1311    /// Return diagnostic counters for the current mark phase.
1312    pub fn stats(&self) -> MarkerStats {
1313        self.stats
1314    }
1315
1316    /// Drain the gray queue, transitively marking children. Each gray box
1317    /// becomes black; its `Trace::trace` is called so the children it points
1318    /// at get pushed onto the queue. Repeats until the queue is empty.
1319    ///
1320    /// Exposed for the post-mark hook so it can run ephemeron convergence:
1321    /// after marking new values via [`Marker::mark`] (or `value.trace(self)`),
1322    /// the hook calls `drain_gray_queue` to propagate the new reachability,
1323    /// then re-checks for fixed-point.
1324    pub fn drain_gray_queue(&mut self) {
1325        while let Some(gray_ptr) = self.gray_queue.pop() {
1326            unsafe {
1327                let bx = gray_ptr.as_ref();
1328                self.stats.traced += 1;
1329                if bx.header.age.get().is_old() {
1330                    self.stats.traced_old += 1;
1331                } else {
1332                    self.stats.traced_young += 1;
1333                }
1334                bx.header.color.set(Color::Black);
1335                bx.value.trace(self);
1336            }
1337        }
1338    }
1339}
1340
1341/// Phases of the incremental collection cycle.
1342///
1343/// The state machine matches a simplified subset of C-Lua's `lgc.c` FSM and
1344/// is driven by [`Heap::incremental_step_with_post_mark`].
1345///
1346/// Transitions:
1347/// - `Pause` → `Propagate` (on first step: reset colors, trace roots).
1348/// - `Propagate` → `EnterAtomic` (when the gray queue empties).
1349/// - `EnterAtomic` → `Atomic` (atomic phase is about to run).
1350/// - `Atomic` → `SweepAllGc` (post-mark hook has run; sweep cursor is initialized).
1351/// - `SweepAllGc` → `SweepFinObj` (allgc sweep cursor reached the end).
1352/// - `SweepFinObj` → `SweepToBeFnz` (finobj sweep cursor reached the end).
1353/// - `SweepToBeFnz` → `SweepEnd` (tobefnz sweep cursor reached the end).
1354/// - `SweepEnd` → `CallFin` (finish sweep bookkeeping).
1355/// - `CallFin` → `Pause` (cycle is complete).
1356///
1357/// `Collecting` is kept as a compatibility alias for the old API (used by
1358/// `barrier`) — it means "anything but Pause."
1359#[derive(Copy, Clone, PartialEq, Eq, Debug)]
1360pub enum GcState {
1361    Pause,
1362    Propagate,
1363    EnterAtomic,
1364    Atomic,
1365    SweepAllGc,
1366    SweepFinObj,
1367    SweepToBeFnz,
1368    SweepEnd,
1369    CallFin,
1370}
1371
1372impl GcState {
1373    pub fn is_pause(self) -> bool {
1374        matches!(self, GcState::Pause)
1375    }
1376    pub fn is_propagate(self) -> bool {
1377        matches!(self, GcState::Propagate)
1378    }
1379    pub fn is_invariant(self) -> bool {
1380        matches!(
1381            self,
1382            GcState::Propagate | GcState::EnterAtomic | GcState::Atomic
1383        )
1384    }
1385    pub fn is_sweep(self) -> bool {
1386        matches!(
1387            self,
1388            GcState::SweepAllGc | GcState::SweepFinObj | GcState::SweepToBeFnz | GcState::SweepEnd
1389        )
1390    }
1391}
1392
1393/// Outcome of one call to [`Heap::incremental_step_with_post_mark`].
1394#[derive(Copy, Clone, PartialEq, Eq, Debug)]
1395pub enum StepOutcome {
1396    /// The step finished a cycle. The heap is back at `GcState::Pause`.
1397    Paused,
1398    /// The step performed work but the cycle is not finished. Caller may
1399    /// step again.
1400    InProgress,
1401    /// The heap is paused (via [`Heap::pause`]) or the caller asked for zero
1402    /// budget while no cycle was in progress and no work was needed.
1403    SkippedStopped,
1404}
1405
1406/// Work budget for one incremental step.
1407///
1408/// `remaining_work` counts down by one for each unit of work performed (one
1409/// gray object traced, one swept node visited, one finalizer dispatched).
1410/// `max_credit` caps how negative `remaining_work` may be allowed to go — a
1411/// step that overshoots its budget rolls the overrun into the caller's debt
1412/// rather than letting unbounded work happen in one call.
1413#[derive(Copy, Clone, Debug)]
1414pub struct StepBudget {
1415    pub remaining_work: isize,
1416    pub max_credit: isize,
1417}
1418
1419impl StepBudget {
1420    /// Build a budget from a number of allowed work units.
1421    pub fn from_work(work: isize) -> Self {
1422        Self {
1423            remaining_work: work.max(1),
1424            max_credit: work.max(1),
1425        }
1426    }
1427}
1428
1429/// The heap. One per `GlobalState`. Owns the intrusive allgc list of every
1430/// allocated `GcBox`, tracks total bytes, and runs collections.
1431/// Floor for the post-collection threshold. Without it, a tight
1432/// allocation loop drives the live set near zero, so `bytes * pause/100`
1433/// collapses toward zero and a full stop-the-world collection fires every
1434/// few allocations, re-tracing all roots each time (issue #38, GC path).
1435/// The floor bounds the wasted work: the collector waits until at least
1436/// this many bytes of garbage accumulate before collecting a small heap.
1437///
1438/// Raised from 256 KB to 1 MB once table array/node buffer bytes became
1439/// honestly accounted (see [`Gc::account_buffer`]): with real buffer bytes
1440/// flowing into the pacer, a 256 KB floor fires too eagerly on table-heavy
1441/// workloads, re-tracing all roots each time. 1 MB keeps the small-heap
1442/// over-collection guard while letting honest pressure drive the threshold.
1443const GC_MIN_THRESHOLD: usize = 1024 * 1024;
1444
1445pub struct Heap {
1446    /// Head of the singly-linked allgc list (heap-owned objects not currently
1447    /// registered for finalization).
1448    head: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
1449    /// Head of the singly-linked finobj list (objects registered for `__gc`).
1450    finobj: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
1451    /// Head of the singly-linked tobefnz list (objects awaiting `__gc`).
1452    tobefnz: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
1453    /// First object that survived one minor collection. Objects before this
1454    /// cursor are the current nursery/new generation.
1455    survival: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
1456    /// First object that survived two minor collections. Objects from
1457    /// `survival` up to this cursor are the survival generation.
1458    old1: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
1459    /// First regular old object. Objects from `old1` up to this cursor became
1460    /// old in the previous young collection.
1461    reallyold: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
1462    /// First OLD1 object when one may appear before the `old1` cursor due to
1463    /// barriers aging objects in younger list segments.
1464    firstold1: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
1465    /// First survival object in the finobj list.
1466    finobjsur: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
1467    /// First old1 object in the finobj list.
1468    finobjold1: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
1469    /// First really-old object in the finobj list.
1470    finobjrold: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
1471    /// Total bytes allocated (sum of header sizes; rough).
1472    bytes: Cell<usize>,
1473    /// Number of currently heap-owned GC boxes across all owner lists.
1474    objects: Cell<usize>,
1475    /// White bit used for new allocations and for survivors after a sweep.
1476    current_white: Cell<Color>,
1477    /// Heap-owned allocation tokens keyed by box address. Weak handles store
1478    /// these tokens so address reuse after sweep cannot resurrect a stale weak
1479    /// target.
1480    allocation_tokens: RefCell<IdentityHashMap<usize>>,
1481    /// Next non-zero token for a collected allocation.
1482    next_allocation_token: Cell<usize>,
1483    /// Threshold above which `step` triggers a collection.
1484    threshold: Cell<usize>,
1485    /// HARDMEMTESTS-style stress mode (env `LUA_RS_GC_STRESS=1`, read once
1486    /// at construction): `would_collect` fires at every checkpoint, so a
1487    /// collection happens at essentially every allocation boundary. Turns
1488    /// GC-cadence-dependent anchoring bugs (objects reachable from Rust
1489    /// locals but not from roots) into deterministic failures — pair with
1490    /// an ASAN build. Debug instrument only; never set in benchmarks.
1491    stress: bool,
1492    /// Quarantine mode (env `LUA_RS_GC_QUARANTINE=1`, read once at
1493    /// construction): sweep unlinks dead boxes but parks them on the
1494    /// `quarantined` list with `HDR_FREED` set instead of freeing, so a
1495    /// use-after-sweep dereference reads intact (still-allocated) memory and
1496    /// trips a `debug_assert` in `Gc::as_box` / `Marker::mark_box` — a
1497    /// deterministic Rust panic with a backtrace, no ASAN or nightly needed.
1498    /// All sweep accounting (byte refunds, token removal, object counts) is
1499    /// unchanged so collection cadence is identical to a normal run. Memory
1500    /// grows without bound; debug-build test instrument only — the asserts
1501    /// compile out in release. Pair with `LUA_RS_GC_STRESS=1`.
1502    quarantine: bool,
1503    /// Intrusive list of swept-but-not-freed boxes under quarantine mode,
1504    /// linked through `header.next` (unused once unlinked from the owner
1505    /// list). Freed for real in `drop_all`.
1506    quarantined: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
1507    /// Intrusive list of boxes allocated via [`allocate_uncollected`](Self::allocate_uncollected)
1508    /// — heap-owned so `drop_all` frees them, but never linked into
1509    /// `head`/`finobj`/`tobefnz` so sweep never visits them (issue #249: this
1510    /// is what makes "never collected during the VM's life" not mean "leaked
1511    /// past the VM's life"). Distinct from the true process-lifetime
1512    /// `Gc::new_uncollected` boxes, which carry no heap reference at all
1513    /// (allocated before any `Heap` exists, or with no `HeapGuard` active).
1514    uncollected: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
1515    /// Set once at the top of [`drop_all`](Self::drop_all) and never cleared:
1516    /// this heap has begun (or completed) teardown. This is the durable half
1517    /// of a deliberate two-state distinction with [`tearing_down`](Self::tearing_down),
1518    /// which is true *only* while the drain loop runs. A post-close
1519    /// [`allocate`](Self::allocate) / [`allocate_uncollected`](Self::allocate_uncollected)
1520    /// panics precisely when `closed && !tearing_down` — the signature of a
1521    /// [`HeapGuard`] that outlived `close()` and tried to allocate into a
1522    /// torn-down heap. Read through [`is_closed`](Self::is_closed).
1523    closed: Cell<bool>,
1524    /// True for the duration of the [`drop_all`](Self::drop_all) drain loop
1525    /// only, and the transient half of the two-state teardown distinction with
1526    /// [`closed`](Self::closed). Re-entrant allocation from a payload `Drop`
1527    /// running *inside* the drain is legitimate — drain-until-stable frees
1528    /// whatever a destructor allocates on a later pass — so the closed-heap
1529    /// allocation panic is suppressed while this is set. It re-arms the instant
1530    /// the final (empty) pass completes and this clears.
1531    tearing_down: Cell<bool>,
1532    /// While non-zero, [`allocate`](Self::allocate) routes through
1533    /// [`allocate_uncollected`](Self::allocate_uncollected) instead of the
1534    /// normal collectable `head` list. Raised around VM construction
1535    /// (`new_state()` / stdlib install), where objects may not yet be
1536    /// reachable from a self-consistent root set even though `paused` has
1537    /// already been cleared partway through — so a step triggered by
1538    /// allocation pressure during setup must not sweep them. A depth rather
1539    /// than a flag so windows nest (an outer embedding-layer window survives
1540    /// an inner one closing). Zero by default: a bare `Heap::new()`
1541    /// (low-level GC tests) allocates normally. Behind an `Rc` so
1542    /// [`BootstrapScope`] can decrement it without holding a heap pointer
1543    /// (sound even if a scope outlives its heap).
1544    bootstrap_depth: std::rc::Rc<Cell<usize>>,
1545    /// Multiplier on bytes_used to set next threshold after collection.
1546    pause_multiplier: Cell<usize>,
1547    /// State machine for the incremental collector.
1548    state: Cell<GcState>,
1549    /// If true, `step` and `barrier` are no-ops (for bootstrap before the
1550    /// world is consistent).
1551    paused: Cell<bool>,
1552    /// Counter of completed collections performed (for diagnostics).
1553    collections: Cell<usize>,
1554    /// Counter of completed young-generation collections.
1555    minor_collections: Cell<usize>,
1556    /// Counter of explicit stop-the-world full-collection requests.
1557    full_collections: Cell<usize>,
1558    /// Diagnostic counters from the most recently completed mark phase.
1559    last_mark_stats: Cell<MarkerStats>,
1560    /// Diagnostic counters from the most recent sweep phase.
1561    last_sweep_stats: Cell<SweepStats>,
1562    /// Heap-owned grayagain revisit set: objects young collections must
1563    /// revisit even if they are not reached through normal roots (OLD0/OLD1
1564    /// and touched old objects). Non-owning — every entry is a live sweepable
1565    /// box, membership is a subset of the live sweepable set, and
1566    /// `HDR_GRAY_LISTED` is the O(1) membership/dedup bit. Replaced the
1567    /// `GcHeader::gray_next` intrusive link in #113 Wave 1, shrinking every
1568    /// object's header by one fat pointer; the `Vec` slot exists only while
1569    /// an object is on the revisit list.
1570    grayagain: RefCell<Vec<NonNull<GcBox<dyn Trace>>>>,
1571    /// Capacity-recycling scratch for [`sweep_young`](Self::sweep_young)'s
1572    /// next-revisit builder: the buffer drained from `grayagain` last minor
1573    /// is cleared and parked here, then reused as the builder next minor —
1574    /// the two buffers swap roles each cycle, so steady-state minors
1575    /// allocate nothing (spec rev-3 W1 buffer-reuse requirement). Always
1576    /// empty outside `sweep_young`; carries no membership and no flags.
1577    grayagain_scratch: RefCell<Vec<NonNull<GcBox<dyn Trace>>>>,
1578    /// In-progress marker state for incremental cycles. `Some` between
1579    /// `Propagate` start and `Sweep` start; `None` otherwise.
1580    marker: RefCell<Option<Marker>>,
1581    /// Recycled mark-phase buffers (gray queue + visited set). A mark phase
1582    /// sizes its visited set to the live-object count (hundreds of KB);
1583    /// without pooling, binarytrees churned 396 such buffers for 249 MB of
1584    /// allocator traffic (dhat, 2026-06-10). One buffer pair is kept and
1585    /// reused across cycles; capacity follows the heap's high-water mark.
1586    marker_pool: RefCell<Option<(Vec<NonNull<GcBox<dyn Trace>>>, IdentityHashSet)>>,
1587    /// Sweep cursor. Points at the `Cell` whose `Option<NonNull>` is the
1588    /// "current" link being inspected during the sweep phase. Encoded as a
1589    /// raw pointer because the cell lives inside a `GcHeader` (Cell, not Cell<Cell>).
1590    /// `None` means: sweep starts from `self.head`.
1591    sweep_prev_next: Cell<Option<NonNull<Cell<Option<NonNull<GcBox<dyn Trace>>>>>>>,
1592    /// Lua 5.1 only: every userdata that has ever carried a metatable, so the
1593    /// collect-time finalizability scan (`luaC_separateudata`) can re-read its
1594    /// live metatable for a late-added `__gc`. Empty on 5.2–5.5, where
1595    /// finalizability is decided eagerly at `setmetatable`. Probes hold weak
1596    /// handles, so they never root the userdata; dead ones are pruned by
1597    /// [`scan_v51_finalizable`](Self::scan_v51_finalizable).
1598    v51_udata_roster: RefCell<Vec<std::rc::Rc<dyn Udata51Probe>>>,
1599}
1600
1601impl Heap {
1602    /// Construct a heap behind `Rc` — the only ownership shape the guard
1603    /// and weak-handle machinery accept since issue #252. Everything that
1604    /// needs `&Heap` gets it by deref.
1605    ///
1606    /// Do not store a clone of this `Rc` inside anything the heap itself
1607    /// owns (a traced value, a userdata payload): that is a reference cycle
1608    /// and the heap will never drop. Collector-internal bookkeeping only
1609    /// ever holds `Weak<Heap>` (`HeapRef`) for this reason.
1610    pub fn new() -> std::rc::Rc<Self> {
1611        std::rc::Rc::new(Self {
1612            head: Cell::new(None),
1613            finobj: Cell::new(None),
1614            tobefnz: Cell::new(None),
1615            survival: Cell::new(None),
1616            old1: Cell::new(None),
1617            reallyold: Cell::new(None),
1618            firstold1: Cell::new(None),
1619            finobjsur: Cell::new(None),
1620            finobjold1: Cell::new(None),
1621            finobjrold: Cell::new(None),
1622            bytes: Cell::new(0),
1623            objects: Cell::new(0),
1624            current_white: Cell::new(Color::White0),
1625            allocation_tokens: RefCell::new(IdentityHashMap::default()),
1626            next_allocation_token: Cell::new(1),
1627            threshold: Cell::new(64 * 1024), // initial threshold: 64 KB
1628            stress: std::env::var_os("LUA_RS_GC_STRESS").is_some_and(|v| v == "1"),
1629            quarantine: std::env::var_os("LUA_RS_GC_QUARANTINE").is_some_and(|v| v == "1"),
1630            quarantined: Cell::new(None),
1631            uncollected: Cell::new(None),
1632            closed: Cell::new(false),
1633            tearing_down: Cell::new(false),
1634            bootstrap_depth: std::rc::Rc::new(Cell::new(0)),
1635            pause_multiplier: Cell::new(200), // 200% = collect when bytes 2x threshold
1636            state: Cell::new(GcState::Pause),
1637            paused: Cell::new(true), // start paused; caller enables when world is consistent
1638            collections: Cell::new(0),
1639            minor_collections: Cell::new(0),
1640            full_collections: Cell::new(0),
1641            last_mark_stats: Cell::new(MarkerStats::default()),
1642            last_sweep_stats: Cell::new(SweepStats::default()),
1643            grayagain: RefCell::new(Vec::new()),
1644            grayagain_scratch: RefCell::new(Vec::new()),
1645            marker: RefCell::new(None),
1646            marker_pool: RefCell::new(None),
1647            sweep_prev_next: Cell::new(None),
1648            v51_udata_roster: RefCell::new(Vec::new()),
1649        })
1650    }
1651
1652    /// Enable collection. Until this is called, `step` is a no-op (so the
1653    /// runtime can bootstrap without prematurely freeing objects).
1654    pub fn unpause(&self) {
1655        self.paused.set(false);
1656    }
1657
1658    pub fn is_paused(&self) -> bool {
1659        self.paused.get()
1660    }
1661
1662    /// True once [`drop_all`](Self::drop_all) has begun tearing this heap
1663    /// down. After this returns true, [`allocate`](Self::allocate) and
1664    /// [`allocate_uncollected`](Self::allocate_uncollected) panic — a
1665    /// [`HeapGuard`] that outlived `close()` cannot allocate into a
1666    /// torn-down heap — with one exception: re-entrant allocation from a
1667    /// destructor running inside the teardown drain itself, which the drain
1668    /// is designed to accept.
1669    pub fn is_closed(&self) -> bool {
1670        self.closed.get()
1671    }
1672
1673    /// Enter bootstrap mode: [`allocate`](Self::allocate) routes new boxes
1674    /// through [`allocate_uncollected`](Self::allocate_uncollected) instead of
1675    /// the normal collectable list until the matching
1676    /// [`end_bootstrap`](Self::end_bootstrap) is called. Prefer the RAII
1677    /// [`bootstrap_scope`](Self::bootstrap_scope) — a manual `end_bootstrap`
1678    /// is skipped by early returns and error paths. See the `bootstrap_depth`
1679    /// field doc for why this exists separately from `paused`.
1680    pub fn begin_bootstrap(&self) {
1681        self.bootstrap_depth.set(
1682            self.bootstrap_depth
1683                .get()
1684                .checked_add(1)
1685                .expect("Heap bootstrap depth overflow"),
1686        );
1687    }
1688
1689    /// Leave one level of bootstrap mode; once the depth returns to zero,
1690    /// subsequent `allocate` calls join the normal collectable `head` list.
1691    pub fn end_bootstrap(&self) {
1692        let depth = self.bootstrap_depth.get();
1693        debug_assert!(depth > 0, "Heap::end_bootstrap without begin_bootstrap");
1694        self.bootstrap_depth.set(depth.saturating_sub(1));
1695    }
1696
1697    pub fn is_bootstrapping(&self) -> bool {
1698        self.bootstrap_depth.get() != 0
1699    }
1700
1701    /// RAII form of [`begin_bootstrap`](Self::begin_bootstrap)/
1702    /// [`end_bootstrap`](Self::end_bootstrap): the returned scope ends the
1703    /// bootstrap window when dropped, so `?`-style early exits from VM
1704    /// construction cannot leave the heap stuck in bootstrap mode.
1705    ///
1706    /// Safe to hold past the heap's death (see [`BootstrapScope`]).
1707    pub fn bootstrap_scope(&self) -> BootstrapScope {
1708        self.begin_bootstrap();
1709        BootstrapScope {
1710            depth: std::rc::Rc::clone(&self.bootstrap_depth),
1711        }
1712    }
1713
1714    /// Guard both allocation entry points against a closed heap. Fires only
1715    /// when the heap is `closed` and no longer `tearing_down` — i.e. `close()`
1716    /// (via `drop_all`) has fully completed and a [`HeapGuard`] that outlived
1717    /// it is trying to allocate. Re-entrant allocation from a destructor
1718    /// *during* the drain sees `tearing_down` set and is allowed through,
1719    /// because drain-until-stable frees whatever it allocates on a later pass.
1720    fn assert_open(&self) {
1721        if self.closed.get() && !self.tearing_down.get() {
1722            panic!("allocation into a closed heap — a HeapGuard outlived close()");
1723        }
1724    }
1725
1726    /// Allocate a new `GcBox<T>` and prepend it to the allgc chain. While
1727    /// [`is_bootstrapping`](Self::is_bootstrapping) is true, delegates to
1728    /// [`allocate_uncollected`](Self::allocate_uncollected) instead.
1729    pub fn allocate<T: Trace + 'static>(&self, value: T) -> Gc<T> {
1730        self.assert_open();
1731        if self.is_bootstrapping() {
1732            return self.allocate_uncollected(value);
1733        }
1734        let size = std::mem::size_of::<GcBox<T>>();
1735        let boxed = Box::new(GcBox {
1736            header: GcHeader::new_white(
1737                size,
1738                self.current_white.get(),
1739                HDR_COLLECTED | HDR_HEAP_OWNED,
1740            ),
1741            value,
1742        });
1743        boxed.header.next.set(self.head.get());
1744        let raw: *mut GcBox<T> = Box::into_raw(boxed);
1745        let ptr: NonNull<GcBox<T>> = NonNull::new(raw).expect("Box::into_raw is non-null");
1746        let dyn_ptr: NonNull<GcBox<dyn Trace>> = ptr;
1747        self.head.set(Some(dyn_ptr));
1748        self.bytes.set(self.bytes.get() + size);
1749        self.objects.set(self.objects.get() + 1);
1750        Gc {
1751            ptr,
1752            _marker: PhantomData,
1753        }
1754    }
1755
1756    /// Allocate a `GcBox<T>` owned by this heap but linked onto neither
1757    /// `head`, `finobj`, nor `tobefnz` — sweep never visits it, so it is
1758    /// never collected while the heap is alive (matching `Gc::new_uncollected`
1759    /// semantics for permanent roots), but it *is* linked onto the heap's
1760    /// `uncollected` list, so [`drop_all`](Self::drop_all) frees it when the
1761    /// heap shuts down instead of leaking it past the heap's lifetime.
1762    ///
1763    /// Does not charge `bytes`/`objects` — those drive collection pacing and
1764    /// diagnostics for the collectable set; an object that sweep never visits
1765    /// would inflate them permanently.
1766    pub fn allocate_uncollected<T: Trace + 'static>(&self, value: T) -> Gc<T> {
1767        self.assert_open();
1768        let size = std::mem::size_of::<GcBox<T>>();
1769        let boxed = Box::new(GcBox {
1770            header: GcHeader::new_white(size, self.current_white.get(), HDR_HEAP_OWNED),
1771            value,
1772        });
1773        boxed.header.next.set(self.uncollected.get());
1774        let raw: *mut GcBox<T> = Box::into_raw(boxed);
1775        let ptr: NonNull<GcBox<T>> = NonNull::new(raw).expect("Box::into_raw is non-null");
1776        let dyn_ptr: NonNull<GcBox<dyn Trace>> = ptr;
1777        self.uncollected.set(Some(dyn_ptr));
1778        Gc {
1779            ptr,
1780            _marker: PhantomData,
1781        }
1782    }
1783
1784    /// Bytes currently retained by GC-tracked objects (rough estimate).
1785    pub fn bytes_used(&self) -> usize {
1786        self.bytes.get()
1787    }
1788
1789    /// Adjust the heap's pacer byte counter by a signed delta, saturating at
1790    /// zero. Used by [`Gc::account_buffer`] to charge or refund the bytes of
1791    /// an object's owned heap buffers (table array/node Vecs) so collections
1792    /// fire at honest memory pressure rather than only on header sizes.
1793    pub fn adjust_bytes(&self, delta: isize) {
1794        if delta >= 0 {
1795            self.bytes
1796                .set(self.bytes.get().saturating_add(delta as usize));
1797        } else {
1798            self.bytes
1799                .set(self.bytes.get().saturating_sub((-delta) as usize));
1800        }
1801    }
1802
1803    /// Current collection threshold in bytes. When `bytes_used() >= threshold_bytes()`,
1804    /// the next `step()` will run a full collection (unless paused). Used by
1805    /// callers that want to short-circuit expensive prep work (e.g. snapshotting
1806    /// weak tables / pending finalizers) when no collection will actually fire.
1807    pub fn threshold_bytes(&self) -> usize {
1808        self.threshold.get()
1809    }
1810
1811    /// Override the next automatic collection threshold.
1812    ///
1813    /// The VM uses this when Lua-level GC pacing (`GCdebt`, minor-debt, and
1814    /// pause-debt calculations) has already computed a byte threshold from the
1815    /// collector-owned live-byte counter.
1816    pub fn set_threshold_bytes(&self, threshold: usize) {
1817        self.threshold.set(threshold.max(1));
1818    }
1819
1820    /// Cheap predicate: would a `step()` actually do work? Equivalent to
1821    /// `!paused && bytes_used() >= threshold_bytes()`. Callers that build
1822    /// snapshot state before invoking the heap should gate on this. Always
1823    /// false once the heap is closed (see [`collection_inert`](Self::collection_inert)).
1824    pub fn would_collect(&self) -> bool {
1825        if self.collection_inert() {
1826            return false;
1827        }
1828        if self.stress {
1829            return true;
1830        }
1831        self.bytes.get() >= self.threshold.get()
1832    }
1833
1834    /// True when every collection entry point must be a no-op: the heap is
1835    /// paused, or it is `closed` (teardown has begun or completed). The
1836    /// closed arm is load-bearing for teardown soundness (issue #260 codex
1837    /// round 2, finding 1): a destructor running inside the `drop_all` drain
1838    /// can invoke a collection, and `sweep_young` re-installs grayagain
1839    /// entries via `replace_grayagain` — pointers a later drain pass frees,
1840    /// recreating the second-`drop_all` use-after-free that gating only the
1841    /// barrier path was meant to prevent. No collection may ever run on a
1842    /// closed heap; every public collect/step/mark entry checks this.
1843    fn collection_inert(&self) -> bool {
1844        self.paused.get() || self.closed.get()
1845    }
1846
1847    pub fn collections(&self) -> usize {
1848        self.collections.get()
1849    }
1850
1851    pub fn minor_collections(&self) -> usize {
1852        self.minor_collections.get()
1853    }
1854
1855    pub fn full_collections(&self) -> usize {
1856        self.full_collections.get()
1857    }
1858
1859    pub fn last_mark_stats(&self) -> MarkerStats {
1860        self.last_mark_stats.get()
1861    }
1862
1863    pub fn last_sweep_stats(&self) -> SweepStats {
1864        self.last_sweep_stats.get()
1865    }
1866
1867    pub fn allgc_cohort_stats(&self) -> AllGcCohortStats {
1868        let survival = self.survival.get();
1869        let old1 = self.old1.get();
1870        let reallyold = self.reallyold.get();
1871        let mut stats = AllGcCohortStats::default();
1872        let mut cursor = self.head.get();
1873        let mut seen = IdentityHashSet::default();
1874        let mut cohort = 0u8;
1875        while let Some(ptr) = cursor {
1876            let id = ptr.as_ptr() as *const () as usize;
1877            if !seen.insert(id) {
1878                break;
1879            }
1880            if Some(ptr) == reallyold {
1881                cohort = 3;
1882            } else if Some(ptr) == old1 {
1883                cohort = 2;
1884            } else if Some(ptr) == survival {
1885                cohort = 1;
1886            }
1887            match cohort {
1888                0 => stats.new += 1,
1889                1 => stats.survival += 1,
1890                2 => stats.old1 += 1,
1891                _ => stats.old += 1,
1892            }
1893            cursor = self.header_from_ptr(ptr).next.get();
1894        }
1895        stats
1896    }
1897
1898    fn next_token(&self) -> usize {
1899        let token = self.next_allocation_token.get().max(1);
1900        let next = token.checked_add(1).unwrap_or(1).max(1);
1901        self.next_allocation_token.set(next);
1902        token
1903    }
1904
1905    fn current_white(&self) -> Color {
1906        self.current_white.get()
1907    }
1908
1909    fn other_white(&self) -> Color {
1910        self.current_white.get().other_white()
1911    }
1912
1913    fn flip_current_white(&self) {
1914        self.current_white.set(self.other_white());
1915    }
1916
1917    fn for_each_list_header(
1918        &self,
1919        head: Option<NonNull<GcBox<dyn Trace>>>,
1920        f: &mut impl FnMut(&GcHeader),
1921    ) {
1922        let mut cursor = head;
1923        while let Some(ptr) = cursor {
1924            let header = self.header_from_ptr(ptr);
1925            cursor = header.next.get();
1926            f(header);
1927        }
1928    }
1929
1930    fn for_each_header(&self, mut f: impl FnMut(&GcHeader)) {
1931        self.for_each_list_header(self.head.get(), &mut f);
1932        self.for_each_list_header(self.finobj.get(), &mut f);
1933        self.for_each_list_header(self.tobefnz.get(), &mut f);
1934    }
1935
1936    fn header_from_ptr<'a>(&'a self, ptr: NonNull<GcBox<dyn Trace>>) -> &'a GcHeader {
1937        unsafe { &(*ptr.as_ptr()).header }
1938    }
1939
1940    /// The single point where a swept box leaves the heap. The caller must
1941    /// already have unlinked `ptr` from its owner list and settled all
1942    /// accounting (byte refund, token removal, object count). Under
1943    /// quarantine mode the box is poisoned and parked instead of freed, so
1944    /// later use-after-sweep dereferences hit intact memory and the
1945    /// `HDR_FREED` debug asserts instead of undefined behavior.
1946    fn release_box(&self, ptr: NonNull<GcBox<dyn Trace>>) {
1947        if self.quarantine {
1948            let header = self.header_from_ptr(ptr);
1949            header.set_flag(HDR_FREED, true);
1950            header.next.set(self.quarantined.get());
1951            self.quarantined.set(Some(ptr));
1952        } else {
1953            // SAFETY: the caller unlinked `ptr` from its owner list, so no
1954            // heap chain reaches it; only stale (buggy) GcRefs could. This
1955            // is the sole runtime free of a GcBox.
1956            unsafe {
1957                let _ = Box::from_raw(ptr.as_ptr());
1958            }
1959        }
1960    }
1961
1962    fn clear_generation_cursors(&self) {
1963        self.survival.set(None);
1964        self.old1.set(None);
1965        self.reallyold.set(None);
1966        self.firstold1.set(None);
1967        self.finobjsur.set(None);
1968        self.finobjold1.set(None);
1969        self.finobjrold.set(None);
1970        self.clear_grayagain();
1971    }
1972
1973    fn set_all_cursors_to_head(&self) {
1974        let head = self.head.get();
1975        self.survival.set(head);
1976        self.old1.set(head);
1977        self.reallyold.set(head);
1978        self.firstold1.set(None);
1979        let finobj = self.finobj.get();
1980        self.finobjsur.set(finobj);
1981        self.finobjold1.set(finobj);
1982        self.finobjrold.set(finobj);
1983        self.clear_grayagain();
1984    }
1985
1986    fn correct_generation_pointers(
1987        &self,
1988        removed: NonNull<GcBox<dyn Trace>>,
1989        next: Option<NonNull<GcBox<dyn Trace>>>,
1990    ) {
1991        if self.survival.get() == Some(removed) {
1992            self.survival.set(next);
1993        }
1994        if self.old1.get() == Some(removed) {
1995            self.old1.set(next);
1996        }
1997        if self.reallyold.get() == Some(removed) {
1998            self.reallyold.set(next);
1999        }
2000        if self.firstold1.get() == Some(removed) {
2001            self.firstold1.set(next);
2002        }
2003        if self.finobjsur.get() == Some(removed) {
2004            self.finobjsur.set(next);
2005        }
2006        if self.finobjold1.get() == Some(removed) {
2007            self.finobjold1.set(next);
2008        }
2009        if self.finobjrold.get() == Some(removed) {
2010            self.finobjrold.set(next);
2011        }
2012        if self.header_from_ptr(removed).gray_listed() {
2013            self.unlink_grayagain(removed);
2014        }
2015    }
2016
2017    fn unlink_from_list(
2018        &self,
2019        list: &Cell<Option<NonNull<GcBox<dyn Trace>>>>,
2020        ptr: NonNull<GcBox<dyn Trace>>,
2021    ) -> bool {
2022        let mut prev_cell = list;
2023        loop {
2024            let Some(current) = prev_cell.get() else {
2025                return false;
2026            };
2027            let header = self.header_from_ptr(current);
2028            let next = header.next.get();
2029            if std::ptr::addr_eq(current.as_ptr(), ptr.as_ptr()) {
2030                prev_cell.set(next);
2031                let prev_next_ptr = NonNull::from(prev_cell);
2032                let removed_next_ptr = NonNull::from(&self.header_from_ptr(ptr).next);
2033                if self.sweep_prev_next.get() == Some(removed_next_ptr) {
2034                    self.sweep_prev_next.set(Some(prev_next_ptr));
2035                }
2036                self.correct_generation_pointers(ptr, next);
2037                header.next.set(None);
2038                return true;
2039            }
2040            prev_cell = &header.next;
2041        }
2042    }
2043
2044    fn link_to_head(
2045        &self,
2046        list: &Cell<Option<NonNull<GcBox<dyn Trace>>>>,
2047        ptr: NonNull<GcBox<dyn Trace>>,
2048    ) {
2049        let header = self.header_from_ptr(ptr);
2050        header.next.set(list.get());
2051        list.set(Some(ptr));
2052    }
2053
2054    fn link_to_tail(
2055        &self,
2056        list: &Cell<Option<NonNull<GcBox<dyn Trace>>>>,
2057        ptr: NonNull<GcBox<dyn Trace>>,
2058    ) {
2059        let mut last_cell = list;
2060        loop {
2061            let Some(current) = last_cell.get() else {
2062                let header = self.header_from_ptr(ptr);
2063                header.next.set(None);
2064                last_cell.set(Some(ptr));
2065                return;
2066            };
2067            last_cell = &self.header_from_ptr(current).next;
2068        }
2069    }
2070
2071    pub fn move_allgc_to_finobj(&self, ptr: NonNull<GcBox<dyn Trace>>) -> bool {
2072        let header = self.header_from_ptr(ptr);
2073        if !header.collected() {
2074            return false;
2075        }
2076        if !self.unlink_from_list(&self.head, ptr) {
2077            return false;
2078        }
2079        if self.state.get().is_sweep() {
2080            header.color.set(self.current_white());
2081        }
2082        self.link_to_head(&self.finobj, ptr);
2083        true
2084    }
2085
2086    pub fn move_finobj_to_tobefnz(&self, ptr: NonNull<GcBox<dyn Trace>>) -> bool {
2087        if !self.unlink_from_list(&self.finobj, ptr) {
2088            return false;
2089        }
2090        self.link_to_tail(&self.tobefnz, ptr);
2091        true
2092    }
2093
2094    pub fn move_tobefnz_to_allgc(&self, ptr: NonNull<GcBox<dyn Trace>>) -> bool {
2095        let header = self.header_from_ptr(ptr);
2096        if !self.unlink_from_list(&self.tobefnz, ptr) {
2097            return false;
2098        }
2099        if self.state.get().is_sweep() {
2100            header.color.set(self.current_white());
2101        }
2102        self.link_to_head(&self.head, ptr);
2103        if header.age.get() == GcAge::Old1 {
2104            self.firstold1.set(Some(ptr));
2105        }
2106        true
2107    }
2108
2109    /// Append `ptr` to the grayagain revisit set. `HDR_GRAY_LISTED` is the
2110    /// O(1) dedup guard: an already-listed object is a no-op, so the set never
2111    /// holds a duplicate (`grayagain_links_object_once` pins this). Tail-append
2112    /// replaces the intrusive list's prepend; the two consumers are
2113    /// order-insensitive (see `mark_minor_revisit_objects` / `sweep_young`).
2114    ///
2115    /// Inert once the heap is `closed`: no collection can ever run again on a
2116    /// closed heap, so the revisit obligation is void — and accepting entries
2117    /// would be a use-after-free factory. A destructor running inside the
2118    /// teardown drain can fire a generational barrier on a box it just
2119    /// allocated; a later drain pass frees that box, and if the pointer had
2120    /// been recorded here a second `drop_all` (or `Heap::drop` after an
2121    /// explicit close) would dereference the freed header in
2122    /// `clear_grayagain`. This is the single grayagain entry point (both
2123    /// generational barriers route through it), so gating it here covers
2124    /// every barrier path.
2125    fn remember_minor_revisit(&self, ptr: NonNull<GcBox<dyn Trace>>) {
2126        if self.closed.get() {
2127            return;
2128        }
2129        let header = self.header_from_ptr(ptr);
2130        if header.gray_listed() {
2131            return;
2132        }
2133        header.set_gray_listed(true);
2134        self.grayagain.borrow_mut().push(ptr);
2135    }
2136
2137    /// Re-mark every grayagain entry at the start of a minor mark. Marking is
2138    /// idempotent and deduped by `Marker::visited`, so iteration order affects
2139    /// only gray-queue push order, not the reachability fixed point. The short
2140    /// immutable borrow is safe: `Marker::mark_box` never re-enters grayagain.
2141    fn mark_minor_revisit_objects(&self, marker: &mut Marker) {
2142        let grayagain = self.grayagain.borrow();
2143        for &ptr in grayagain.iter() {
2144            let header = self.header_from_ptr(ptr);
2145            let id = ptr.as_ptr() as *const () as usize;
2146            marker.mark_box(ptr, header, id);
2147        }
2148    }
2149
2150    /// Empty the grayagain set, clearing each entry's `HDR_GRAY_LISTED` bit.
2151    /// `Vec::clear` retains capacity so the buffer is recycled across cycles.
2152    fn clear_grayagain(&self) {
2153        let mut grayagain = self.grayagain.borrow_mut();
2154        for &ptr in grayagain.iter() {
2155            self.header_from_ptr(ptr).set_gray_listed(false);
2156        }
2157        grayagain.clear();
2158    }
2159
2160    /// Drain the grayagain set to an owned `Vec`, clearing each entry's flag.
2161    /// Flags are cleared *after* the `borrow_mut` is released so no owner
2162    /// borrow is ever held across the header writes. `sweep_young` feeds the
2163    /// returned `Vec` to `OldRevisitTracker` unchanged.
2164    fn take_grayagain(&self) -> Vec<NonNull<GcBox<dyn Trace>>> {
2165        let objects = std::mem::take(&mut *self.grayagain.borrow_mut());
2166        for &ptr in &objects {
2167            self.header_from_ptr(ptr).set_gray_listed(false);
2168        }
2169        objects
2170    }
2171
2172    /// Replace the grayagain set with `objects`: clear the current entries'
2173    /// flags, then install `objects` as the new buffer and set their flags.
2174    /// `objects` is already dedup'd by its producer (`next_revisit_seen` in
2175    /// `sweep_young`), so no membership check is needed here.
2176    fn replace_grayagain(&self, objects: Vec<NonNull<GcBox<dyn Trace>>>) {
2177        self.clear_grayagain();
2178        for &ptr in &objects {
2179            self.header_from_ptr(ptr).set_gray_listed(true);
2180        }
2181        *self.grayagain.borrow_mut() = objects;
2182    }
2183
2184    /// Remove `removed` from the grayagain set and clear its flag. Called from
2185    /// `correct_generation_pointers` whenever a gray-listed object is unlinked
2186    /// from an owner list (full-sweep free, young-sweep free, or a cross-list
2187    /// move). Order-preserving `retain`; the borrow is dropped before the box
2188    /// can be freed.
2189    fn unlink_grayagain(&self, removed: NonNull<GcBox<dyn Trace>>) {
2190        self.header_from_ptr(removed).set_gray_listed(false);
2191        self.grayagain
2192            .borrow_mut()
2193            .retain(|ptr| !std::ptr::addr_eq(ptr.as_ptr(), removed.as_ptr()));
2194    }
2195
2196    /// Number of objects on the grayagain revisit set. Flag-dedup guarantees
2197    /// no duplicates, so this equals the intrusive list's old walk count
2198    /// (`grayagain_links_object_once` pins it). Read by lua-cli telemetry.
2199    pub fn grayagain_count(&self) -> usize {
2200        self.grayagain.borrow().len()
2201    }
2202
2203    /// Record a userdata in the Lua 5.1 collect-time finalizability roster.
2204    ///
2205    /// Called by the VM for every userdata that receives a metatable on 5.1.
2206    /// The probe holds a weak handle, so the roster never roots the userdata.
2207    /// No-op for the collector beyond storage; the VM reads metatables and
2208    /// registers finalizers via [`scan_v51_finalizable`](Self::scan_v51_finalizable).
2209    pub fn register_v51_udata(&self, probe: std::rc::Rc<dyn Udata51Probe>) {
2210        self.v51_udata_roster.borrow_mut().push(probe);
2211    }
2212
2213    /// Drain the Lua 5.1 finalizability roster of dead entries and return the
2214    /// still-live probes for the VM to inspect.
2215    ///
2216    /// Mirrors the enumeration half of C 5.1 `luaC_separateudata`: the VM
2217    /// caller then reads each probe's live metatable for `__gc` and registers
2218    /// the finalizable ones. Dead probes (their userdata already swept) are
2219    /// dropped here so the roster stays bounded. The returned probes are kept
2220    /// in the roster too (still live), so an already-finalized userdata that
2221    /// outlives its `__gc` is naturally pruned on a later scan once swept.
2222    pub fn scan_v51_finalizable(&self) -> Vec<std::rc::Rc<dyn Udata51Probe>> {
2223        let mut roster = self.v51_udata_roster.borrow_mut();
2224        roster.retain(|probe| probe.is_alive());
2225        roster.clone()
2226    }
2227
2228    /// Return the current heap token for an allocation identity, or `None` when
2229    /// the identity was never registered or has since been swept.
2230    ///
2231    /// Registration is lazy: tokens are minted at weak-handle creation
2232    /// ([`register_allocation_token`](Self::register_allocation_token)), not at
2233    /// allocation, so an object that was never weak-referenced reports `None`
2234    /// here even while live.
2235    pub fn allocation_token(&self, identity: usize) -> Option<usize> {
2236        self.allocation_tokens.borrow().get(&identity).copied()
2237    }
2238
2239    /// Register `identity` in the weak-handle validation table, returning its
2240    /// token (get-or-insert against the monotonic counter).
2241    ///
2242    /// This is the lazy half of weak-handle validation. The hot allocation path
2243    /// no longer touches `allocation_tokens`; instead a token is minted the
2244    /// first time an object is downgraded to a weak handle, which is the only
2245    /// moment the token is ever consumed.
2246    ///
2247    /// Correctness: every valid weak handle calls this at creation while holding
2248    /// a strong reference, so the object is provably live at registration. A
2249    /// later [`contains_allocation`](Self::contains_allocation) returning false
2250    /// for an absent identity therefore means "swept" exactly as the eager
2251    /// scheme did — sweep removes the entry when it frees the box. The monotonic
2252    /// counter never reissues a token, so when the allocator reuses an address
2253    /// (a freed identity re-registered by a fresh object) the new token differs
2254    /// from any stale handle's, preventing address reuse from resurrecting a
2255    /// dead handle. Objects that are never weak-referenced never enter the map.
2256    ///
2257    /// On a `closed` heap this refuses to mint: it returns 0 — a value
2258    /// [`next_token`](Self::next_token) never issues, so it can never
2259    /// validate — without touching the map. A downgrade of a stale `GcRef`
2260    /// after close would otherwise re-register the freed box's address and
2261    /// resurrect it as an upgradable weak target.
2262    pub fn register_allocation_token(&self, identity: usize) -> usize {
2263        if self.closed.get() {
2264            return 0;
2265        }
2266        let mut tokens = self.allocation_tokens.borrow_mut();
2267        if let Some(token) = tokens.get(&identity) {
2268            return *token;
2269        }
2270        let token = self.next_token();
2271        tokens.insert(identity, token);
2272        token
2273    }
2274
2275    /// Return true when `identity` still names the same heap allocation.
2276    ///
2277    /// The token check prevents allocator address reuse from making a stale
2278    /// weak handle look live again. Unconditionally false once the heap is
2279    /// `closed`: every box is freed (or about to be, mid-drain), so no weak
2280    /// handle may upgrade regardless of what the token map transiently holds.
2281    pub fn contains_allocation(&self, identity: usize, token: usize) -> bool {
2282        if self.closed.get() {
2283            return false;
2284        }
2285        self.allocation_token(identity) == Some(token)
2286    }
2287
2288    /// Forward write barrier: invoked when `parent` (already-traced black
2289    /// object) gains a new reference to `child`. To preserve the tri-color
2290    /// invariant ("no black points to white"), we mark the child gray
2291    /// immediately. Cheap: one branch + maybe one queue push.
2292    ///
2293    /// During incremental mode this prevents the marking phase from missing
2294    /// the new edge. In current stop-the-world mode it's still correct (a
2295    /// no-op when the collection is idle), so call sites can be wired now
2296    /// and the incremental upgrade is mechanical later.
2297    pub fn barrier<P, C>(&self, parent: Gc<P>, child: Gc<C>)
2298    where
2299        P: Trace + 'static,
2300        C: Trace + 'static,
2301    {
2302        if self.paused.get() || self.state.get().is_pause() {
2303            return;
2304        }
2305        if parent.header().color.get() != Color::Black {
2306            return;
2307        }
2308        if !child.header().color.get().is_white() {
2309            return;
2310        }
2311        child.header().color.set(Color::Gray);
2312        if let Ok(mut m_opt) = self.marker.try_borrow_mut() {
2313            if let Some(m) = m_opt.as_mut() {
2314                let ptr: NonNull<GcBox<dyn Trace>> = child.ptr;
2315                m.gray_queue.push(ptr);
2316                m.visited.insert(child.identity());
2317            }
2318        }
2319    }
2320
2321    /// Backward barrier: if a black object receives a reference to a white
2322    /// child, gray the parent so the in-progress cycle will rescan it.
2323    pub fn barrier_back<P, C>(&self, parent: Gc<P>, child: Gc<C>)
2324    where
2325        P: Trace + 'static,
2326        C: Trace + 'static,
2327    {
2328        if self.paused.get() || self.state.get().is_pause() {
2329            return;
2330        }
2331        if parent.header().color.get() != Color::Black {
2332            return;
2333        }
2334        if !child.header().color.get().is_white() {
2335            return;
2336        }
2337        parent.header().color.set(Color::Gray);
2338        if let Ok(mut m_opt) = self.marker.try_borrow_mut() {
2339            if let Some(m) = m_opt.as_mut() {
2340                let ptr: NonNull<GcBox<dyn Trace>> = parent.ptr;
2341                m.gray_queue.push(ptr);
2342                m.visited.insert(parent.identity());
2343            }
2344        }
2345    }
2346
2347    /// Generational forward barrier: if an old object receives a reference to a
2348    /// young object, the child cannot jump directly to OLD because it may still
2349    /// point at younger objects. Lua marks it OLD0 so later young collections
2350    /// advance it through OLD1 to OLD.
2351    pub fn generational_forward_barrier<P, C>(&self, parent: Gc<P>, child: Gc<C>)
2352    where
2353        P: Trace + 'static,
2354        C: Trace + 'static,
2355    {
2356        if parent.age().is_old() && !child.age().is_old() {
2357            child.set_age(GcAge::Old0);
2358            let ptr: NonNull<GcBox<dyn Trace>> = child.ptr;
2359            self.remember_minor_revisit(ptr);
2360        }
2361        self.barrier(parent, child);
2362    }
2363
2364    /// Generational backward barrier: an old object that now points to a young
2365    /// object is revisited by the next young collection. This mirrors
2366    /// `luaC_barrierback_`'s age transition to TOUCHED1.
2367    pub fn generational_backward_barrier<P>(&self, parent: Gc<P>)
2368    where
2369        P: Trace + 'static,
2370    {
2371        if parent.age().is_old() {
2372            parent.set_color(Color::Gray);
2373            parent.set_age(GcAge::Touched1);
2374            let ptr: NonNull<GcBox<dyn Trace>> = parent.ptr;
2375            self.remember_minor_revisit(ptr);
2376        }
2377    }
2378
2379    /// Possibly run a collection. Trigger: bytes_used > threshold.
2380    /// Caller passes the root set (the runtime — typically `GlobalState`
2381    /// implementing `Trace`).
2382    pub fn step(&self, roots: &dyn Trace) {
2383        self.step_with_post_mark(roots, |_: &mut Marker| {});
2384    }
2385
2386    /// Like [`step`] but invokes a `post_mark` hook when a collection
2387    /// actually fires (threshold reached). Hook is a no-op on the
2388    /// short-circuit path. The runtime uses this to bridge weak-table
2389    /// pruning into implicit GC steps fired from inside the VM loop.
2390    pub fn step_with_post_mark<F: FnMut(&mut Marker)>(&self, roots: &dyn Trace, post_mark: F) {
2391        if self.collection_inert() {
2392            return;
2393        }
2394        if !self.stress && self.bytes.get() < self.threshold.get() {
2395            return;
2396        }
2397        self.full_collect_with_post_mark(roots, post_mark);
2398    }
2399
2400    /// Stop-the-world full collect. Marks every reachable object from
2401    /// `roots`, then sweeps white (unreachable) boxes from the heap owner lists.
2402    pub fn full_collect(&self, roots: &dyn Trace) {
2403        self.full_collect_with_post_mark(roots, |_: &mut Marker| {});
2404    }
2405
2406    /// Run only the mark/atomic hook portion of a collection, without sweeping.
2407    ///
2408    /// This is used by runtimes that need an atomic reachability snapshot for
2409    /// weak-table cleanup while they are deliberately avoiding object freeing.
2410    pub fn mark_only_with_post_mark<F: FnMut(&mut Marker)>(
2411        &self,
2412        roots: &dyn Trace,
2413        mut post_mark: F,
2414    ) {
2415        if self.collection_inert() {
2416            return;
2417        }
2418        let mut marker = self.marker_from_pool(MarkerMode::Full);
2419        roots.trace(&mut marker);
2420        marker.drain_gray_queue();
2421        post_mark(&mut marker);
2422        marker.drain_gray_queue();
2423        self.last_mark_stats.set(marker.stats());
2424        self.recycle_marker(marker);
2425    }
2426
2427    /// Metadata transition used when entering generational mode after a full
2428    /// mark: all currently live objects become old.
2429    pub fn promote_all_to_old(&self) {
2430        self.for_each_header(|header| {
2431            header.age.set(GcAge::Old);
2432            header.color.set(Color::Black);
2433        });
2434        self.set_all_cursors_to_head();
2435    }
2436
2437    /// Metadata transition used when returning to incremental mode: Lua clears
2438    /// age information and treats all objects as new again.
2439    pub fn reset_all_ages(&self) {
2440        let current_white = self.current_white();
2441        self.for_each_header(|header| {
2442            header.age.set(GcAge::New);
2443            header.color.set(current_white);
2444        });
2445        self.clear_generation_cursors();
2446    }
2447
2448    /// Run a complete young-generation collection.
2449    ///
2450    /// This is the first generational path: it uses the normal root tracer for
2451    /// correctness, then limits sweep/freeing to young objects. Later work can
2452    /// replace the full root traversal with cohort-list traversal without
2453    /// changing the age/sweep contract introduced here.
2454    pub fn minor_collect_with_post_mark<F: FnMut(&mut Marker)>(
2455        &self,
2456        roots: &dyn Trace,
2457        mut post_mark: F,
2458    ) {
2459        if self.collection_inert() {
2460            return;
2461        }
2462        if !self.state.get().is_pause() {
2463            self.abort_cycle();
2464        }
2465
2466        self.state.set(GcState::Propagate);
2467        let mut marker = self.marker_from_pool(MarkerMode::Minor);
2468        self.last_sweep_stats.set(SweepStats::default());
2469        self.mark_minor_revisit_objects(&mut marker);
2470        roots.trace(&mut marker);
2471        marker.drain_gray_queue();
2472
2473        self.state.set(GcState::EnterAtomic);
2474        self.state.set(GcState::Atomic);
2475        post_mark(&mut marker);
2476        marker.drain_gray_queue();
2477        self.last_mark_stats.set(marker.stats());
2478        self.recycle_marker(marker);
2479
2480        self.state.set(GcState::SweepAllGc);
2481        self.sweep_young();
2482        self.recycle_marker_cell();
2483        self.sweep_prev_next.set(None);
2484        self.state.set(GcState::Pause);
2485        self.collections.set(self.collections.get() + 1);
2486        self.minor_collections.set(self.minor_collections.get() + 1);
2487    }
2488
2489    /// Stop-the-world full collect with a post-mark hook.
2490    ///
2491    /// Internally drives the incremental state machine to completion with
2492    /// an unbounded budget — equivalent to repeatedly calling
2493    /// [`incremental_step_with_post_mark`] until it returns `Paused`. The
2494    /// post-mark hook is invoked exactly once, during the atomic transition.
2495    pub fn full_collect_with_post_mark<F: FnMut(&mut Marker)>(
2496        &self,
2497        roots: &dyn Trace,
2498        mut post_mark: F,
2499    ) {
2500        if self.collection_inert() {
2501            return;
2502        }
2503        if !self.state.get().is_pause() {
2504            self.abort_cycle();
2505        }
2506        self.full_collections.set(self.full_collections.get() + 1);
2507        let unlimited = StepBudget {
2508            remaining_work: isize::MAX,
2509            max_credit: isize::MAX,
2510        };
2511        loop {
2512            let outcome = self.incremental_step_with_post_mark(roots, unlimited, &mut post_mark);
2513            if matches!(outcome, StepOutcome::Paused | StepOutcome::SkippedStopped) {
2514                break;
2515            }
2516        }
2517    }
2518
2519    /// Run one budgeted step of the incremental collector.
2520    ///
2521    /// The state machine advances `Pause → Propagate → EnterAtomic → Atomic →
2522    /// SweepAllGc → SweepFinObj → SweepToBeFnz → SweepEnd → CallFin → Pause`.
2523    /// Each phase consumes budget; the call returns when the budget runs out
2524    /// or the cycle reaches `Pause`. The `post_mark`
2525    /// hook is invoked exactly once per cycle, during the `Atomic`
2526    /// transition (after the initial gray-queue drain, before sweep starts).
2527    ///
2528    /// Returns:
2529    /// - [`StepOutcome::Paused`] — the cycle completed.
2530    /// - [`StepOutcome::InProgress`] — budget exhausted before the cycle
2531    ///   finished; caller may step again.
2532    /// - [`StepOutcome::SkippedStopped`] — heap is paused; nothing happened.
2533    pub fn incremental_step_with_post_mark<F: FnMut(&mut Marker)>(
2534        &self,
2535        roots: &dyn Trace,
2536        mut budget: StepBudget,
2537        mut post_mark: F,
2538    ) -> StepOutcome {
2539        if self.collection_inert() {
2540            return StepOutcome::SkippedStopped;
2541        }
2542        self.run_budgeted(roots, &mut budget, &mut post_mark);
2543        if self.state.get().is_pause() {
2544            StepOutcome::Paused
2545        } else {
2546            StepOutcome::InProgress
2547        }
2548    }
2549
2550    fn run_budgeted(
2551        &self,
2552        roots: &dyn Trace,
2553        budget: &mut StepBudget,
2554        post_mark: &mut dyn FnMut(&mut Marker),
2555    ) -> bool {
2556        self.run_budgeted_until(roots, budget, post_mark, None)
2557    }
2558
2559    fn run_budgeted_until(
2560        &self,
2561        roots: &dyn Trace,
2562        budget: &mut StepBudget,
2563        post_mark: &mut dyn FnMut(&mut Marker),
2564        stop_at: Option<GcState>,
2565    ) -> bool {
2566        let mut did_work = false;
2567        loop {
2568            if stop_at == Some(self.state.get()) {
2569                return did_work;
2570            }
2571            if budget.remaining_work <= -budget.max_credit {
2572                return did_work;
2573            }
2574            match self.state.get() {
2575                GcState::Pause => {
2576                    self.start_cycle(roots);
2577                    self.state.set(GcState::Propagate);
2578                    budget.remaining_work -= 1;
2579                    did_work = true;
2580                    if stop_at == Some(GcState::Propagate) {
2581                        return did_work;
2582                    }
2583                }
2584                GcState::Propagate => {
2585                    let work = self.drain_gray_budgeted(budget.remaining_work.max(1));
2586                    budget.remaining_work -= work as isize;
2587                    did_work = did_work || work > 0;
2588                    let empty = {
2589                        let m = self.marker.borrow();
2590                        m.as_ref().map(|m| m.gray_queue.is_empty()).unwrap_or(true)
2591                    };
2592                    if empty {
2593                        self.state.set(GcState::EnterAtomic);
2594                        if stop_at == Some(GcState::EnterAtomic) {
2595                            return did_work;
2596                        }
2597                    } else if budget.remaining_work <= 0 {
2598                        return did_work;
2599                    }
2600                }
2601                GcState::EnterAtomic => {
2602                    self.state.set(GcState::Atomic);
2603                    budget.remaining_work -= 1;
2604                    did_work = true;
2605                    if stop_at == Some(GcState::Atomic) || budget.remaining_work <= 0 {
2606                        return did_work;
2607                    }
2608                }
2609                GcState::Atomic => {
2610                    self.run_atomic(post_mark);
2611                    self.state.set(GcState::SweepAllGc);
2612                    budget.remaining_work -= 1;
2613                    did_work = true;
2614                    if stop_at == Some(GcState::SweepAllGc) {
2615                        return did_work;
2616                    }
2617                }
2618                GcState::SweepAllGc => {
2619                    let work = self.sweep_budgeted(budget.remaining_work.max(1));
2620                    budget.remaining_work -= work as isize;
2621                    did_work = did_work || work > 0;
2622                    if self.sweep_prev_next.get().is_none() {
2623                        self.state.set(GcState::SweepFinObj);
2624                        self.sweep_prev_next.set(Some(NonNull::from(&self.finobj)));
2625                        if stop_at == Some(GcState::SweepFinObj) {
2626                            return did_work;
2627                        }
2628                    } else if budget.remaining_work <= 0 {
2629                        return did_work;
2630                    }
2631                }
2632                GcState::SweepFinObj => {
2633                    let work = self.sweep_budgeted(budget.remaining_work.max(1));
2634                    budget.remaining_work -= work as isize;
2635                    did_work = did_work || work > 0;
2636                    if self.sweep_prev_next.get().is_none() {
2637                        self.state.set(GcState::SweepToBeFnz);
2638                        self.sweep_prev_next.set(Some(NonNull::from(&self.tobefnz)));
2639                        if stop_at == Some(GcState::SweepToBeFnz) {
2640                            return did_work;
2641                        }
2642                    } else if budget.remaining_work <= 0 {
2643                        return did_work;
2644                    }
2645                }
2646                GcState::SweepToBeFnz => {
2647                    let work = self.sweep_budgeted(budget.remaining_work.max(1));
2648                    budget.remaining_work -= work as isize;
2649                    did_work = did_work || work > 0;
2650                    if self.sweep_prev_next.get().is_none() {
2651                        self.state.set(GcState::SweepEnd);
2652                        if stop_at == Some(GcState::SweepEnd) {
2653                            return did_work;
2654                        }
2655                    } else if budget.remaining_work <= 0 {
2656                        return did_work;
2657                    }
2658                }
2659                GcState::SweepEnd => {
2660                    self.state.set(GcState::CallFin);
2661                    budget.remaining_work -= 1;
2662                    did_work = true;
2663                    if stop_at == Some(GcState::CallFin) || budget.remaining_work <= 0 {
2664                        return did_work;
2665                    }
2666                }
2667                GcState::CallFin => {
2668                    self.finish_cycle();
2669                    self.state.set(GcState::Pause);
2670                    if stop_at == Some(GcState::Pause) {
2671                        return did_work;
2672                    }
2673                    return did_work;
2674                }
2675            }
2676        }
2677    }
2678
2679    /// Drive an incremental cycle until `target` is entered, stopping before any
2680    /// subsequent phase work. Intended for testC-style inspection of mid-cycle
2681    /// color/barrier invariants; normal collector pacing uses
2682    /// [`Self::incremental_step_with_post_mark`].
2683    pub fn incremental_run_until_state_with_post_mark<F: FnMut(&mut Marker)>(
2684        &self,
2685        roots: &dyn Trace,
2686        target: GcState,
2687        max_work: isize,
2688        mut post_mark: F,
2689    ) -> StepOutcome {
2690        if self.collection_inert() {
2691            return StepOutcome::SkippedStopped;
2692        }
2693        let work = max_work.max(1);
2694        let mut budget = StepBudget {
2695            remaining_work: work,
2696            max_credit: work,
2697        };
2698        self.run_budgeted_until(roots, &mut budget, &mut post_mark, Some(target));
2699        if self.state.get().is_pause() {
2700            StepOutcome::Paused
2701        } else {
2702            StepOutcome::InProgress
2703        }
2704    }
2705
2706    /// Take the pooled mark buffers (or build fresh ones sized to the live
2707    /// set). Pair with [`Heap::recycle_marker`] when the mark phase ends.
2708    fn marker_from_pool(&self, mode: MarkerMode) -> Marker {
2709        match self.marker_pool.borrow_mut().take() {
2710            Some((gray_queue, visited)) => Marker {
2711                gray_queue,
2712                visited,
2713                stats: MarkerStats::default(),
2714                mode,
2715            },
2716            None => Marker::new_with_capacity(mode, self.objects.get()),
2717        }
2718    }
2719
2720    fn recycle_marker(&self, marker: Marker) {
2721        let Marker {
2722            mut gray_queue,
2723            mut visited,
2724            ..
2725        } = marker;
2726        gray_queue.clear();
2727        visited.clear();
2728        *self.marker_pool.borrow_mut() = Some((gray_queue, visited));
2729    }
2730
2731    fn recycle_marker_cell(&self) {
2732        if let Some(marker) = self.marker.borrow_mut().take() {
2733            self.recycle_marker(marker);
2734        }
2735    }
2736
2737    fn start_cycle(&self, roots: &dyn Trace) {
2738        self.flip_current_white();
2739        let dead_white = self.other_white();
2740        self.for_each_header(|header| {
2741            header.color.set(dead_white);
2742        });
2743        let mut marker = self.marker_from_pool(MarkerMode::Full);
2744        roots.trace(&mut marker);
2745        *self.marker.borrow_mut() = Some(marker);
2746        self.sweep_prev_next.set(None);
2747    }
2748
2749    fn drain_gray_budgeted(&self, max_units: isize) -> usize {
2750        let mut m_opt = self.marker.borrow_mut();
2751        let marker = match m_opt.as_mut() {
2752            Some(m) => m,
2753            None => return 0,
2754        };
2755        let mut work = 0usize;
2756        let mut budget = max_units;
2757        while budget > 0 {
2758            let next = match marker.gray_queue.pop() {
2759                Some(p) => p,
2760                None => break,
2761            };
2762            unsafe {
2763                let bx = next.as_ref();
2764                marker.stats.traced += 1;
2765                if bx.header.age.get().is_old() {
2766                    marker.stats.traced_old += 1;
2767                } else {
2768                    marker.stats.traced_young += 1;
2769                }
2770                bx.header.color.set(Color::Black);
2771                bx.value.trace(marker);
2772            }
2773            work += 1;
2774            budget -= 1;
2775        }
2776        work
2777    }
2778
2779    fn run_atomic(&self, post_mark: &mut dyn FnMut(&mut Marker)) {
2780        let mut m_opt = self.marker.borrow_mut();
2781        if let Some(marker) = m_opt.as_mut() {
2782            marker.drain_gray_queue();
2783            post_mark(marker);
2784            marker.drain_gray_queue();
2785        }
2786        self.sweep_prev_next.set(Some(NonNull::from(&self.head)));
2787        self.last_sweep_stats.set(SweepStats::default());
2788    }
2789
2790    fn sweep_budgeted(&self, max_units: isize) -> usize {
2791        let mut work = 0usize;
2792        let mut budget = max_units;
2793        let mut freed_bytes = 0usize;
2794        let mut stats = SweepStats::default();
2795        let current_white = self.current_white();
2796        let dead_white = self.other_white();
2797        let mut prev_next_ptr = match self.sweep_prev_next.get() {
2798            Some(p) => p,
2799            None => return 0,
2800        };
2801        while budget > 0 {
2802            let prev_cell = unsafe { prev_next_ptr.as_ref() };
2803            let cursor = prev_cell.get();
2804            let ptr = match cursor {
2805                Some(p) => p,
2806                None => {
2807                    self.sweep_prev_next.set(None);
2808                    break;
2809                }
2810            };
2811            let header = self.header_from_ptr(ptr);
2812            let next = header.next.get();
2813            let age = header.age.get();
2814            stats.record_visit(age);
2815            let color = header.color.get();
2816            if color == dead_white {
2817                prev_cell.set(next);
2818                let size = header.size();
2819                freed_bytes += size;
2820                stats.record_free(size);
2821                self.correct_generation_pointers(ptr, next);
2822                self.allocation_tokens
2823                    .borrow_mut()
2824                    .remove(&(ptr.as_ptr() as *const () as usize));
2825                self.objects.set(self.objects.get().saturating_sub(1));
2826                self.release_box(ptr);
2827            } else {
2828                if matches!(color, Color::Black | Color::Gray) {
2829                    header.color.set(current_white);
2830                }
2831                prev_next_ptr = unsafe { NonNull::from(&(*ptr.as_ptr()).header.next) };
2832                self.sweep_prev_next.set(Some(prev_next_ptr));
2833            }
2834            work += 1;
2835            budget -= 1;
2836        }
2837        if freed_bytes > 0 {
2838            self.bytes.set(self.bytes.get().saturating_sub(freed_bytes));
2839        }
2840        if stats.visited > 0 {
2841            let mut total = self.last_sweep_stats.get();
2842            total.add(stats);
2843            self.last_sweep_stats.set(total);
2844        }
2845        work
2846    }
2847
2848    fn push_next_revisit(
2849        next_revisit: &mut Vec<NonNull<GcBox<dyn Trace>>>,
2850        seen: &mut IdentityHashSet,
2851        ptr: NonNull<GcBox<dyn Trace>>,
2852        age: GcAge,
2853    ) {
2854        if matches!(
2855            age,
2856            GcAge::Old0 | GcAge::Old1 | GcAge::Touched1 | GcAge::Touched2
2857        ) {
2858            let id = ptr.as_ptr() as *const () as usize;
2859            if seen.insert(id) {
2860                next_revisit.push(ptr);
2861            }
2862        }
2863    }
2864
2865    fn sweep_young_range(
2866        &self,
2867        mut prev_next_ptr: NonNull<Cell<Option<NonNull<GcBox<dyn Trace>>>>>,
2868        limit: Option<NonNull<GcBox<dyn Trace>>>,
2869        next_revisit: &mut Vec<NonNull<GcBox<dyn Trace>>>,
2870        next_revisit_seen: &mut IdentityHashSet,
2871        processed: &mut Option<OldRevisitTracker>,
2872        firstold1: &mut Option<NonNull<GcBox<dyn Trace>>>,
2873        freed_bytes: &mut usize,
2874        stats: &mut SweepStats,
2875    ) -> (
2876        NonNull<Cell<Option<NonNull<GcBox<dyn Trace>>>>>,
2877        Option<NonNull<GcBox<dyn Trace>>>,
2878    ) {
2879        let current_white = self.current_white();
2880        loop {
2881            let prev_cell = unsafe { prev_next_ptr.as_ref() };
2882            let Some(ptr) = prev_cell.get() else {
2883                return (prev_next_ptr, None);
2884            };
2885            if Some(ptr) == limit {
2886                return (prev_next_ptr, Some(ptr));
2887            }
2888            let header = self.header_from_ptr(ptr);
2889            let next = header.next.get();
2890            let age = header.age.get();
2891            stats.record_visit(age);
2892            if let Some(processed) = processed.as_mut() {
2893                processed.record_processed(ptr.as_ptr() as *const () as usize);
2894            }
2895            if header.color.get().is_white() && !age.is_old() {
2896                prev_cell.set(next);
2897                let size = header.size();
2898                *freed_bytes += size;
2899                stats.record_free(size);
2900                self.correct_generation_pointers(ptr, next);
2901                self.allocation_tokens
2902                    .borrow_mut()
2903                    .remove(&(ptr.as_ptr() as *const () as usize));
2904                self.objects.set(self.objects.get().saturating_sub(1));
2905                self.release_box(ptr);
2906                continue;
2907            }
2908
2909            if !header.color.get().is_white() {
2910                let next_age = age.next_after_minor();
2911                header.age.set(next_age);
2912                if next_age == GcAge::Old1 && firstold1.is_none() {
2913                    *firstold1 = Some(ptr);
2914                }
2915                match age {
2916                    GcAge::New => header.color.set(current_white),
2917                    GcAge::Touched1 | GcAge::Touched2 => header.color.set(Color::Black),
2918                    _ => {}
2919                }
2920                Self::push_next_revisit(next_revisit, next_revisit_seen, ptr, next_age);
2921            }
2922            prev_next_ptr = unsafe { NonNull::from(&(*ptr.as_ptr()).header.next) };
2923        }
2924    }
2925
2926    fn sweep_young(&self) {
2927        let mut freed_bytes = 0usize;
2928        let mut next_revisit = std::mem::take(&mut *self.grayagain_scratch.borrow_mut());
2929        debug_assert!(next_revisit.is_empty(), "grayagain scratch must be parked empty");
2930        let mut next_revisit_seen = IdentityHashSet::default();
2931        let mut firstold1 = None;
2932        let mut stats = SweepStats::default();
2933        let old_revisit = self.take_grayagain();
2934        let mut processed = OldRevisitTracker::new(&old_revisit);
2935        let survival = self.survival.get();
2936        let old1 = self.old1.get();
2937
2938        let (psurvival, new_old1) = self.sweep_young_range(
2939            NonNull::from(&self.head),
2940            survival,
2941            &mut next_revisit,
2942            &mut next_revisit_seen,
2943            &mut processed,
2944            &mut firstold1,
2945            &mut freed_bytes,
2946            &mut stats,
2947        );
2948        self.sweep_young_range(
2949            psurvival,
2950            old1,
2951            &mut next_revisit,
2952            &mut next_revisit_seen,
2953            &mut processed,
2954            &mut firstold1,
2955            &mut freed_bytes,
2956            &mut stats,
2957        );
2958
2959        let finobjsur = self.finobjsur.get();
2960        let finobjold1 = self.finobjold1.get();
2961        let mut dummy_firstold1 = None;
2962        let (pfinobjsur, new_finobjold1) = self.sweep_young_range(
2963            NonNull::from(&self.finobj),
2964            finobjsur,
2965            &mut next_revisit,
2966            &mut next_revisit_seen,
2967            &mut processed,
2968            &mut dummy_firstold1,
2969            &mut freed_bytes,
2970            &mut stats,
2971        );
2972        self.sweep_young_range(
2973            pfinobjsur,
2974            finobjold1,
2975            &mut next_revisit,
2976            &mut next_revisit_seen,
2977            &mut processed,
2978            &mut dummy_firstold1,
2979            &mut freed_bytes,
2980            &mut stats,
2981        );
2982        self.sweep_young_range(
2983            NonNull::from(&self.tobefnz),
2984            None,
2985            &mut next_revisit,
2986            &mut next_revisit_seen,
2987            &mut processed,
2988            &mut dummy_firstold1,
2989            &mut freed_bytes,
2990            &mut stats,
2991        );
2992
2993        if let Some(processed) = processed.as_mut() {
2994            processed.finish();
2995        }
2996
2997        for &ptr in &old_revisit {
2998            let id = ptr.as_ptr() as *const () as usize;
2999            if processed
3000                .as_ref()
3001                .is_some_and(|processed| processed.was_processed(id))
3002            {
3003                continue;
3004            }
3005            stats.revisit += 1;
3006            let header = self.header_from_ptr(ptr);
3007            if header.color.get().is_white() {
3008                continue;
3009            }
3010            let age = header.age.get();
3011            let next_age = age.next_after_minor();
3012            header.age.set(next_age);
3013            if next_age == GcAge::Old1 && firstold1.is_none() {
3014                firstold1 = Some(ptr);
3015            }
3016            if matches!(age, GcAge::Touched1 | GcAge::Touched2) {
3017                header.color.set(Color::Black);
3018            }
3019            Self::push_next_revisit(&mut next_revisit, &mut next_revisit_seen, ptr, next_age);
3020        }
3021
3022        if freed_bytes > 0 {
3023            self.bytes.set(self.bytes.get().saturating_sub(freed_bytes));
3024        }
3025        let mut recycled = old_revisit;
3026        recycled.clear();
3027        *self.grayagain_scratch.borrow_mut() = recycled;
3028        self.replace_grayagain(next_revisit);
3029        self.reallyold.set(old1);
3030        self.old1.set(new_old1);
3031        self.survival.set(self.head.get());
3032        self.firstold1.set(firstold1);
3033        self.finobjrold.set(finobjold1);
3034        self.finobjold1.set(new_finobjold1);
3035        self.finobjsur.set(self.finobj.get());
3036        self.last_sweep_stats.set(stats);
3037    }
3038
3039    fn finish_cycle(&self) {
3040        let stats = self
3041            .marker
3042            .borrow()
3043            .as_ref()
3044            .map(|marker| marker.stats())
3045            .unwrap_or_default();
3046        self.last_mark_stats.set(stats);
3047        self.recycle_marker_cell();
3048        self.sweep_prev_next.set(None);
3049        let next = self.bytes.get().saturating_mul(self.pause_multiplier.get()) / 100;
3050        self.threshold.set(next.max(GC_MIN_THRESHOLD));
3051        self.collections.set(self.collections.get() + 1);
3052    }
3053
3054    /// Finish an idle `CallFin` phase after the runtime has drained any
3055    /// pending to-be-finalized objects.
3056    pub fn finish_callfin_phase(&self) -> bool {
3057        if self.state.get() != GcState::CallFin {
3058            return false;
3059        }
3060        self.finish_cycle();
3061        self.state.set(GcState::Pause);
3062        true
3063    }
3064
3065    fn abort_cycle(&self) {
3066        if !self.state.get().is_pause() {
3067            self.recycle_marker_cell();
3068            self.sweep_prev_next.set(None);
3069            let current_white = self.current_white();
3070            self.for_each_header(|header| {
3071                header.color.set(current_white);
3072            });
3073            self.state.set(GcState::Pause);
3074        }
3075    }
3076
3077    /// Returns the current state of the incremental collector.
3078    pub fn gc_state(&self) -> GcState {
3079        self.state.get()
3080    }
3081
3082    /// Approximate number of live GC boxes across all heap owner lists.
3083    pub fn allgc_count(&self) -> usize {
3084        self.objects.get()
3085    }
3086
3087    /// Count live allgc objects whose concrete Rust type name matches
3088    /// `predicate`. This is diagnostic/testC telemetry only; collector logic
3089    /// must not depend on Rust type names.
3090    pub fn type_name_count(&self, mut predicate: impl FnMut(&'static str) -> bool) -> usize {
3091        let mut count = 0usize;
3092        for head in [self.head.get(), self.finobj.get(), self.tobefnz.get()] {
3093            let mut cursor = head;
3094            while let Some(ptr) = cursor {
3095                let bx = unsafe { ptr.as_ref() };
3096                cursor = bx.header.next.get();
3097                if predicate(bx.value().type_name()) {
3098                    count += 1;
3099                }
3100            }
3101        }
3102        count
3103    }
3104
3105    /// Drop every allocation, ignoring reachability. Called at shutdown
3106    /// (`Heap::drop`, and the VM's `close_state` / `free_all_objects`).
3107    ///
3108    /// **Drain until stable.** A single pass over the owner lists is not
3109    /// enough: a payload `Drop` may itself allocate — a `GcRef::new` in a
3110    /// destructor relinks a fresh box onto `head` (or onto `uncollected`
3111    /// inside a bootstrap window) — and that box would be stranded on a
3112    /// just-emptied list and leaked outright if the walk were `Heap::drop`'s
3113    /// own. So this loops over every owner list until a full pass frees
3114    /// nothing, then zeroes accounting.
3115    ///
3116    /// `closed` is set at the top and never cleared; `tearing_down` is held
3117    /// across the loop so those re-entrant allocations are accepted rather
3118    /// than panicking against the now-closed heap, and re-arms the panic once
3119    /// the loop settles. Grayagain is emptied and its flags cleared (via
3120    /// [`clear_generation_cursors`](Self::clear_generation_cursors)) before any
3121    /// free, preserving the pre-existing ordering.
3122    ///
3123    /// **Nonconvergence is a panic, in every build.** A destructor that
3124    /// allocates a new box on every pass would spin this loop forever; after
3125    /// 10,000 passes the loop panics naming that cause. An unconditional
3126    /// panic is deliberate — a silent infinite hang inside `close()` in a
3127    /// release build is strictly worse than a loud failure.
3128    ///
3129    /// **Panic safety.** `tearing_down` is restored by an RAII guard, so a
3130    /// panicking destructor (or the pass-cap panic) leaves the heap `closed`
3131    /// with the closed-heap allocation panic correctly re-armed, never stuck
3132    /// in a state that accepts allocations forever. A destructor that panics
3133    /// mid-teardown does, however, leak the not-yet-freed remainder of the
3134    /// list chain being drained: the local cursor into that chain dies with
3135    /// the unwinding frame. Panicking in `Drop` is programmer error; the
3136    /// guarantee kept here is that the heap's state machine stays sound, not
3137    /// that a panicking destructor is leak-free.
3138    ///
3139    /// After this returns, every outstanding `Gc<T>` is dangling — callers
3140    /// must ensure no `Gc<T>` outlives the `Heap`. **Any** operation that
3141    /// dereferences a pre-close `Gc`/`GcRef` after this returns — deref,
3142    /// `downgrade` header reads, `account_buffer` — is use-after-free,
3143    /// exactly as it always was after `Heap::drop`; close does not widen or
3144    /// narrow that contract, it only makes the free deterministic. Note the
3145    /// quarantine caveat: `LUA_RS_GC_QUARANTINE=1` catches use-after-*sweep*
3146    /// while the heap lives, but this teardown drains the quarantined list
3147    /// and frees its boxes for real — post-close stale dereferences have no
3148    /// `HDR_FREED` tripwire left to hit. The VM's own close paths
3149    /// cannot hit this (`free_all_objects` clears every registry that holds
3150    /// handles); giving boxes a checkable owner identity at `downgrade` time
3151    /// is the #252-follow-up ownership redesign, out of scope here.
3152    pub fn drop_all(&self) {
3153        /// Restores `tearing_down` on scope exit — including panic unwind —
3154        /// but only for the outermost `drop_all` frame (`armed`), so a
3155        /// re-entrant `drop_all` from inside a payload `Drop` cannot clear
3156        /// the flag out from under the outer drain.
3157        struct TearingDownReset<'a> {
3158            flag: &'a Cell<bool>,
3159            armed: bool,
3160        }
3161        impl Drop for TearingDownReset<'_> {
3162            fn drop(&mut self) {
3163                if self.armed {
3164                    self.flag.set(false);
3165                }
3166            }
3167        }
3168
3169        self.closed.set(true);
3170        let _reset = TearingDownReset {
3171            armed: !self.tearing_down.replace(true),
3172            flag: &self.tearing_down,
3173        };
3174        self.recycle_marker_cell();
3175        self.sweep_prev_next.set(None);
3176        self.clear_generation_cursors();
3177        self.state.set(GcState::Pause);
3178        self.allocation_tokens.borrow_mut().clear();
3179
3180        let mut passes = 0usize;
3181        loop {
3182            let mut freed_any = false;
3183            freed_any |= self.drop_list(&self.head);
3184            freed_any |= self.drop_list(&self.finobj);
3185            freed_any |= self.drop_list(&self.tobefnz);
3186            freed_any |= self.drop_list(&self.quarantined);
3187            freed_any |= self.drop_list(&self.uncollected);
3188            if !freed_any {
3189                break;
3190            }
3191            passes = passes.saturating_add(1);
3192            if passes >= 10_000 {
3193                panic!(
3194                    "Heap::drop_all did not converge after 10000 passes — a \
3195                     nonconvergent destructor is allocating a new GC box on \
3196                     every teardown pass"
3197                );
3198            }
3199        }
3200
3201        self.allocation_tokens.borrow_mut().clear();
3202        self.bytes.set(0);
3203        self.objects.set(0);
3204    }
3205
3206    /// Free every box on one owner list, returning whether any box was freed.
3207    /// A payload `Drop` run by the `Box::from_raw` here may relink a fresh box
3208    /// onto this same list head; `next` is read before the drop so the local
3209    /// walk is unaffected, and the fresh box is caught by a later drain pass in
3210    /// [`drop_all`](Self::drop_all).
3211    fn drop_list(&self, list: &Cell<Option<NonNull<GcBox<dyn Trace>>>>) -> bool {
3212        let mut cursor = list.get();
3213        list.set(None);
3214        let mut freed = false;
3215        while let Some(ptr) = cursor {
3216            freed = true;
3217            // SAFETY: same chain invariant as full_collect's sweep.
3218            let next = unsafe {
3219                let next = (*ptr.as_ptr()).header.next.get();
3220                let _ = Box::from_raw(ptr.as_ptr());
3221                next
3222            };
3223            cursor = next;
3224        }
3225        freed
3226    }
3227}
3228
3229/// Last-resort teardown when the final `Rc<Heap>` drops without an explicit
3230/// close. Runs the same drain as [`Heap::drop_all`], but with one contract
3231/// difference callers must know: no `HeapGuard` can be active for *this*
3232/// heap here — the `Rc` is mid-destruction, so there is no sound handle to
3233/// push (do not try to smuggle one in). A payload destructor that allocates
3234/// via `GcRef::new` during a plain unguarded `Heap::drop` therefore panics
3235/// loudly with the #253 no-guard message instead of allocating into a dying
3236/// heap. The supported deterministic-teardown path for VM heaps is
3237/// state-level close (`close_state` → `free_all_objects`), which pushes the
3238/// guard around the drain so such destructors work.
3239impl Drop for Heap {
3240    fn drop(&mut self) {
3241        self.drop_all();
3242    }
3243}
3244
3245// ──────────────────────────────────────────────────────────────────────────
3246// Tests — confirm the skeleton's invariants before agents ever touch it.
3247// ──────────────────────────────────────────────────────────────────────────
3248
3249#[cfg(test)]
3250mod tests {
3251    use super::*;
3252
3253    /// A tiny GC-tracked type for the smoke test.
3254    struct Cell0 {
3255        next: Cell<Option<Gc<Cell0>>>,
3256        marker_calls: Cell<usize>,
3257    }
3258
3259    impl Trace for Cell0 {
3260        fn trace(&self, m: &mut Marker) {
3261            self.marker_calls.set(self.marker_calls.get() + 1);
3262            if let Some(n) = self.next.get() {
3263                m.mark(n);
3264            }
3265        }
3266    }
3267
3268    /// Roots for tests: just a single Gc<Cell0>, or none.
3269    struct OneRoot(Option<Gc<Cell0>>);
3270    impl Trace for OneRoot {
3271        fn trace(&self, m: &mut Marker) {
3272            if let Some(g) = self.0 {
3273                m.mark(g);
3274            }
3275        }
3276    }
3277
3278    struct TwoRoots {
3279        first: Option<Gc<Cell0>>,
3280        second: Option<Gc<Cell0>>,
3281    }
3282
3283    impl Trace for TwoRoots {
3284        fn trace(&self, m: &mut Marker) {
3285            if let Some(g) = self.first {
3286                m.mark(g);
3287            }
3288            if let Some(g) = self.second {
3289                m.mark(g);
3290            }
3291        }
3292    }
3293
3294    #[derive(Clone)]
3295    struct FinalizerCell {
3296        id: usize,
3297        age: GcAge,
3298        finalized: std::rc::Rc<Cell<bool>>,
3299    }
3300
3301    impl FinalizerCell {
3302        fn new(id: usize) -> Self {
3303            Self {
3304                id,
3305                age: GcAge::New,
3306                finalized: std::rc::Rc::new(Cell::new(false)),
3307            }
3308        }
3309    }
3310
3311    impl FinalizerEntry for FinalizerCell {
3312        fn identity(&self) -> usize {
3313            self.id
3314        }
3315
3316        fn age(&self) -> GcAge {
3317            self.age
3318        }
3319
3320        fn is_finalized(&self) -> bool {
3321            self.finalized.get()
3322        }
3323
3324        fn set_finalized(&self, finalized: bool) {
3325            self.finalized.set(finalized);
3326        }
3327    }
3328
3329    fn finalizer_ids(objects: &[FinalizerCell]) -> Vec<usize> {
3330        objects.iter().map(|object| object.id).collect()
3331    }
3332
3333    #[derive(Clone)]
3334    struct WeakCell {
3335        id: usize,
3336        live: bool,
3337        kind: WeakListKind,
3338    }
3339
3340    impl WeakEntry for WeakCell {
3341        type Strong = usize;
3342
3343        fn identity(&self) -> usize {
3344            self.id
3345        }
3346
3347        fn list_kind(&self) -> WeakListKind {
3348            self.kind
3349        }
3350
3351        fn upgrade(&self) -> Option<Self::Strong> {
3352            self.live.then_some(self.id)
3353        }
3354    }
3355
3356    #[test]
3357    fn weak_registry_dedups_snapshots_and_retains_live_ids() {
3358        let mut registry = WeakRegistry::default();
3359        registry.push_unique(WeakCell {
3360            id: 1,
3361            live: true,
3362            kind: WeakListKind::WeakValues,
3363        });
3364        registry.push_unique(WeakCell {
3365            id: 1,
3366            live: true,
3367            kind: WeakListKind::Ephemeron,
3368        });
3369        registry.push_unique(WeakCell {
3370            id: 2,
3371            live: false,
3372            kind: WeakListKind::AllWeak,
3373        });
3374        registry.push_unique(WeakCell {
3375            id: 3,
3376            live: true,
3377            kind: WeakListKind::WeakValues,
3378        });
3379
3380        let stats = registry.stats();
3381        assert_eq!(stats.weak_values, 1);
3382        assert_eq!(stats.ephemeron, 1);
3383        assert_eq!(stats.all_weak, 1);
3384
3385        let snapshot = registry.live_snapshot();
3386        assert_eq!(snapshot, vec![3, 1]);
3387        assert_eq!(
3388            registry.stats(),
3389            WeakRegistryStats {
3390                tracked: 3,
3391                snapshot_live: 2,
3392                snapshot_dead: 1,
3393                retained: 2,
3394                weak_values: 1,
3395                ephemeron: 1,
3396                all_weak: 0,
3397            }
3398        );
3399
3400        let keep: std::collections::HashSet<usize> = [3usize].into_iter().collect();
3401        registry.retain_identities(&keep);
3402        assert_eq!(registry.len(), 1);
3403        assert_eq!(registry.stats().retained, 1);
3404        assert_eq!(registry.stats().weak_values, 1);
3405        registry.remove_identity(3);
3406        assert_eq!(registry.len(), 0);
3407    }
3408
3409    #[test]
3410    fn finalizer_registry_tracks_generational_cohorts() {
3411        let mut registry = FinalizerRegistry::default();
3412        registry.push_pending_unique(FinalizerCell::new(1));
3413        registry.push_pending_unique(FinalizerCell::new(2));
3414
3415        let stats = registry.stats();
3416        assert_eq!(stats.finobj_new, 2);
3417        assert_eq!(stats.finobj_survival, 0);
3418        assert_eq!(stats.finobj_old1, 0);
3419        assert_eq!(stats.finobj_reallyold, 0);
3420        assert_eq!(stats.finobj_minor_scan, 2);
3421
3422        registry.finish_minor_collection();
3423        let stats = registry.stats();
3424        assert_eq!(stats.finobj_new, 0);
3425        assert_eq!(stats.finobj_survival, 2);
3426        assert_eq!(stats.finobj_old1, 0);
3427        assert_eq!(stats.finobj_reallyold, 0);
3428        assert_eq!(stats.finobj_minor_scan, 2);
3429
3430        registry.push_pending_unique(FinalizerCell::new(3));
3431        registry.finish_minor_collection();
3432        let stats = registry.stats();
3433        assert_eq!(stats.finobj_new, 0);
3434        assert_eq!(stats.finobj_survival, 1);
3435        assert_eq!(stats.finobj_old1, 2);
3436        assert_eq!(stats.finobj_reallyold, 0);
3437        assert_eq!(stats.finobj_minor_scan, 1);
3438
3439        registry.finish_minor_collection();
3440        let stats = registry.stats();
3441        assert_eq!(stats.finobj_new, 0);
3442        assert_eq!(stats.finobj_survival, 0);
3443        assert_eq!(stats.finobj_old1, 1);
3444        assert_eq!(stats.finobj_reallyold, 2);
3445        assert_eq!(stats.finobj_minor_scan, 0);
3446    }
3447
3448    #[test]
3449    fn finalizer_registry_minor_snapshot_uses_cohort_boundaries() {
3450        let mut registry = FinalizerRegistry::default();
3451        registry.push_pending_unique(FinalizerCell::new(1));
3452        registry.push_pending_unique(FinalizerCell::new(2));
3453        registry.push_pending_unique(FinalizerCell::new(3));
3454        registry.finish_minor_collection();
3455        registry.finish_minor_collection();
3456        registry.push_pending_unique(FinalizerCell::new(4));
3457        registry.push_pending_unique(FinalizerCell::new(5));
3458
3459        assert_eq!(
3460            finalizer_ids(&registry.pending_minor_snapshot()),
3461            vec![4, 5],
3462            "minor finalizer scan must skip the old1/reallyold prefix"
3463        );
3464
3465        registry.push_to_be_finalized(FinalizerCell::new(99));
3466        registry.promote_pending_to_finalized(vec![
3467            FinalizerCell::new(1),
3468            FinalizerCell::new(2),
3469            FinalizerCell::new(4),
3470        ]);
3471
3472        let stats = registry.stats();
3473        assert_eq!(stats.finobj_old1, 1);
3474        assert_eq!(stats.finobj_new, 1);
3475        assert_eq!(stats.finobj_minor_scan, 1);
3476        assert_eq!(finalizer_ids(registry.pending()), vec![3, 5]);
3477        assert_eq!(
3478            finalizer_ids(registry.to_be_finalized()),
3479            vec![99, 4, 2, 1],
3480            "new to-be-finalized batches append behind older queued finalizers"
3481        );
3482    }
3483
3484    #[test]
3485    fn finalizer_registry_marks_and_clears_finalized_bit() {
3486        let mut registry = FinalizerRegistry::default();
3487        let object = FinalizerCell::new(1);
3488
3489        assert!(!object.is_finalized());
3490        registry.push_pending_unique(object.clone());
3491        assert!(object.is_finalized());
3492
3493        registry.push_pending_unique(object.clone());
3494        assert_eq!(registry.pending_len(), 1);
3495
3496        registry.promote_pending_to_finalized(vec![object.clone()]);
3497        assert!(object.is_finalized());
3498        assert_eq!(registry.pending_len(), 0);
3499        assert_eq!(registry.to_be_finalized_len(), 1);
3500
3501        let popped = registry.pop_to_be_finalized().unwrap();
3502        assert_eq!(popped.id, 1);
3503        assert!(!object.is_finalized());
3504
3505        registry.push_pending_unique(object.clone());
3506        assert!(object.is_finalized());
3507        assert_eq!(registry.pending_len(), 1);
3508    }
3509
3510    #[test]
3511    fn alloc_and_drop_all() {
3512        let heap = Heap::new();
3513        heap.unpause();
3514        let _a = heap.allocate(Cell0 {
3515            next: Cell::new(None),
3516            marker_calls: Cell::new(0),
3517        });
3518        let _b = heap.allocate(Cell0 {
3519            next: Cell::new(None),
3520            marker_calls: Cell::new(0),
3521        });
3522        assert!(heap.bytes_used() > 0);
3523        heap.drop_all();
3524        assert_eq!(heap.bytes_used(), 0);
3525    }
3526
3527    fn list_len(heap: &Heap, mut cursor: Option<NonNull<GcBox<dyn Trace>>>) -> usize {
3528        let mut count = 0usize;
3529        while let Some(ptr) = cursor {
3530            count += 1;
3531            cursor = heap.header_from_ptr(ptr).next.get();
3532        }
3533        count
3534    }
3535
3536    #[test]
3537    fn finalizer_intrusive_lists_sweep_and_drop() {
3538        let heap = Heap::new();
3539        heap.unpause();
3540        let _normal = heap.allocate(Cell0 {
3541            next: Cell::new(None),
3542            marker_calls: Cell::new(0),
3543        });
3544        let finobj = heap.allocate(Cell0 {
3545            next: Cell::new(None),
3546            marker_calls: Cell::new(0),
3547        });
3548        let tobefnz = heap.allocate(Cell0 {
3549            next: Cell::new(None),
3550            marker_calls: Cell::new(0),
3551        });
3552
3553        assert!(heap.move_allgc_to_finobj(finobj.as_trace_ptr()));
3554        assert!(heap.move_allgc_to_finobj(tobefnz.as_trace_ptr()));
3555        assert!(heap.move_finobj_to_tobefnz(tobefnz.as_trace_ptr()));
3556        assert_eq!(list_len(&heap, heap.head.get()), 1);
3557        assert_eq!(list_len(&heap, heap.finobj.get()), 1);
3558        assert_eq!(list_len(&heap, heap.tobefnz.get()), 1);
3559        assert_eq!(heap.allgc_count(), 3);
3560
3561        heap.full_collect(&TwoRoots {
3562            first: Some(finobj),
3563            second: Some(tobefnz),
3564        });
3565        assert_eq!(list_len(&heap, heap.head.get()), 0);
3566        assert_eq!(list_len(&heap, heap.finobj.get()), 1);
3567        assert_eq!(list_len(&heap, heap.tobefnz.get()), 1);
3568        assert_eq!(heap.allgc_count(), 2);
3569
3570        assert!(heap.move_tobefnz_to_allgc(tobefnz.as_trace_ptr()));
3571        heap.full_collect(&OneRoot(Some(tobefnz)));
3572        assert_eq!(list_len(&heap, heap.head.get()), 1);
3573        assert_eq!(list_len(&heap, heap.finobj.get()), 0);
3574        assert_eq!(list_len(&heap, heap.tobefnz.get()), 0);
3575        assert_eq!(heap.allgc_count(), 1);
3576
3577        heap.drop_all();
3578        assert_eq!(heap.bytes_used(), 0);
3579    }
3580
3581    #[test]
3582    fn account_buffer_refunded_on_sweep() {
3583        let heap = Heap::new();
3584        heap.unpause();
3585        let baseline = heap.bytes_used();
3586        {
3587            let a = heap.allocate(Cell0 {
3588                next: Cell::new(None),
3589                marker_calls: Cell::new(0),
3590            });
3591            assert!(heap.bytes_used() > baseline);
3592            a.account_buffer(&heap, 4096);
3593            assert_eq!(
3594                a.header().size(),
3595                std::mem::size_of::<GcBox<Cell0>>() + 4096
3596            );
3597        }
3598        // Drop the only root path (a is no longer Trace-visible). The +4096
3599        // must be refunded via header.size when the box is swept.
3600        heap.full_collect(&OneRoot(None));
3601        assert_eq!(
3602            heap.bytes_used(),
3603            baseline,
3604            "account_buffer charge must be fully refunded by sweep"
3605        );
3606    }
3607
3608    #[test]
3609    fn account_buffer_noop_on_uncollected() {
3610        let heap = Heap::new();
3611        heap.unpause();
3612        let g = Gc::new_uncollected(Cell0 {
3613            next: Cell::new(None),
3614            marker_calls: Cell::new(0),
3615        });
3616        let before = heap.bytes_used();
3617        g.account_buffer(&heap, 8192);
3618        assert_eq!(
3619            heap.bytes_used(),
3620            before,
3621            "uncollected box must not charge the pacer"
3622        );
3623    }
3624
3625    #[test]
3626    fn collect_unreachable_frees_bytes() {
3627        let heap = Heap::new();
3628        heap.unpause();
3629        let _a = heap.allocate(Cell0 {
3630            next: Cell::new(None),
3631            marker_calls: Cell::new(0),
3632        });
3633        let bytes_before = heap.bytes_used();
3634        assert!(bytes_before > 0);
3635        // No roots — everything should sweep.
3636        heap.full_collect(&OneRoot(None));
3637        assert_eq!(heap.bytes_used(), 0);
3638        assert_eq!(heap.collections(), 1);
3639    }
3640
3641    #[test]
3642    fn collect_keeps_reachable() {
3643        let heap = Heap::new();
3644        heap.unpause();
3645        let root = heap.allocate(Cell0 {
3646            next: Cell::new(None),
3647            marker_calls: Cell::new(0),
3648        });
3649        let bytes_before = heap.bytes_used();
3650        heap.full_collect(&OneRoot(Some(root)));
3651        assert_eq!(heap.bytes_used(), bytes_before);
3652        assert_eq!(root.marker_calls.get(), 1);
3653    }
3654
3655    #[test]
3656    fn allocations_start_new_and_white() {
3657        let heap = Heap::new();
3658        heap.unpause();
3659        let obj = heap.allocate(Cell0 {
3660            next: Cell::new(None),
3661            marker_calls: Cell::new(0),
3662        });
3663        assert_eq!(obj.age(), GcAge::New);
3664        assert!(obj.color().is_white());
3665    }
3666
3667    #[test]
3668    fn allocation_tokens_track_exact_live_box() {
3669        let heap = Heap::new();
3670        heap.unpause();
3671        let obj = heap.allocate(Cell0 {
3672            next: Cell::new(None),
3673            marker_calls: Cell::new(0),
3674        });
3675        let id = obj.identity();
3676
3677        assert_eq!(
3678            heap.allocation_token(id),
3679            None,
3680            "lazy registration: a freshly allocated box has no token until it is downgraded"
3681        );
3682
3683        let token = heap.register_allocation_token(id);
3684        assert_eq!(
3685            heap.register_allocation_token(id),
3686            token,
3687            "registration is get-or-insert: re-registering a live identity returns the same token"
3688        );
3689        assert_eq!(heap.allocation_token(id), Some(token));
3690        assert!(heap.contains_allocation(id, token));
3691        assert!(!heap.contains_allocation(id, token + 1));
3692
3693        heap.full_collect(&OneRoot(None));
3694        assert_eq!(heap.allocation_token(id), None);
3695        assert!(!heap.contains_allocation(id, token));
3696    }
3697
3698    #[test]
3699    fn registering_after_sweep_yields_a_distinct_token_on_address_reuse() {
3700        let heap = Heap::new();
3701        heap.unpause();
3702        let first = heap.allocate(Cell0 {
3703            next: Cell::new(None),
3704            marker_calls: Cell::new(0),
3705        });
3706        let id = first.identity();
3707        let first_token = heap.register_allocation_token(id);
3708
3709        heap.full_collect(&OneRoot(None));
3710        assert_eq!(
3711            heap.allocation_token(id),
3712            None,
3713            "sweep removes the token for the dead identity"
3714        );
3715
3716        let second_token = heap.register_allocation_token(id);
3717        assert_ne!(
3718            first_token, second_token,
3719            "monotonic tokens keep address reuse from resurrecting a stale weak handle"
3720        );
3721        assert!(!heap.contains_allocation(id, first_token));
3722        assert!(heap.contains_allocation(id, second_token));
3723    }
3724
3725    #[test]
3726    fn allocation_during_incremental_sweep_survives_current_cycle() {
3727        let heap = Heap::new();
3728        heap.unpause();
3729        let old_dead = heap.allocate(Cell0 {
3730            next: Cell::new(None),
3731            marker_calls: Cell::new(0),
3732        });
3733        let old_id = old_dead.identity();
3734        heap.register_allocation_token(old_id);
3735
3736        let outcome = heap.incremental_run_until_state_with_post_mark(
3737            &OneRoot(None),
3738            GcState::SweepAllGc,
3739            1024,
3740            |_| {},
3741        );
3742        assert_eq!(outcome, StepOutcome::InProgress);
3743        assert_eq!(heap.gc_state(), GcState::SweepAllGc);
3744
3745        let new_during_sweep = heap.allocate(Cell0 {
3746            next: Cell::new(None),
3747            marker_calls: Cell::new(0),
3748        });
3749        let new_id = new_during_sweep.identity();
3750        heap.register_allocation_token(new_id);
3751
3752        loop {
3753            let outcome = heap.incremental_step_with_post_mark(
3754                &OneRoot(None),
3755                StepBudget::from_work(64),
3756                |_| {},
3757            );
3758            if matches!(outcome, StepOutcome::Paused) {
3759                break;
3760            }
3761        }
3762
3763        assert_eq!(heap.allocation_token(old_id), None);
3764        assert!(heap.allocation_token(new_id).is_some());
3765        assert_eq!(heap.allgc_count(), 1);
3766
3767        heap.full_collect(&OneRoot(None));
3768        assert_eq!(heap.allocation_token(new_id), None);
3769        assert_eq!(heap.allgc_count(), 0);
3770    }
3771
3772    #[test]
3773    fn promote_and_reset_all_ages() {
3774        let heap = Heap::new();
3775        heap.unpause();
3776        let a = heap.allocate(Cell0 {
3777            next: Cell::new(None),
3778            marker_calls: Cell::new(0),
3779        });
3780        let b = heap.allocate(Cell0 {
3781            next: Cell::new(None),
3782            marker_calls: Cell::new(0),
3783        });
3784
3785        heap.promote_all_to_old();
3786        assert_eq!(a.age(), GcAge::Old);
3787        assert_eq!(b.age(), GcAge::Old);
3788        assert_eq!(a.color(), Color::Black);
3789        assert_eq!(b.color(), Color::Black);
3790
3791        heap.reset_all_ages();
3792        assert_eq!(a.age(), GcAge::New);
3793        assert_eq!(b.age(), GcAge::New);
3794        assert!(a.color().is_white());
3795        assert!(b.color().is_white());
3796    }
3797
3798    #[test]
3799    fn generational_barriers_update_ages() {
3800        let heap = Heap::new();
3801        heap.unpause();
3802        let parent = heap.allocate(Cell0 {
3803            next: Cell::new(None),
3804            marker_calls: Cell::new(0),
3805        });
3806        let child = heap.allocate(Cell0 {
3807            next: Cell::new(None),
3808            marker_calls: Cell::new(0),
3809        });
3810
3811        parent.set_age(GcAge::Old);
3812        parent.set_color(Color::Black);
3813        heap.generational_backward_barrier(parent);
3814        assert_eq!(parent.age(), GcAge::Touched1);
3815        assert_eq!(parent.color(), Color::Gray);
3816
3817        heap.generational_forward_barrier(parent, child);
3818        assert_eq!(child.age(), GcAge::Old0);
3819    }
3820
3821    #[test]
3822    fn minor_collect_frees_young_and_keeps_old() {
3823        let heap = Heap::new();
3824        heap.unpause();
3825        let old_unreachable = heap.allocate(Cell0 {
3826            next: Cell::new(None),
3827            marker_calls: Cell::new(0),
3828        });
3829        old_unreachable.set_age(GcAge::Old);
3830        old_unreachable.set_color(Color::Black);
3831        let _young_unreachable = heap.allocate(Cell0 {
3832            next: Cell::new(None),
3833            marker_calls: Cell::new(0),
3834        });
3835        let young_survivor = heap.allocate(Cell0 {
3836            next: Cell::new(None),
3837            marker_calls: Cell::new(0),
3838        });
3839
3840        heap.minor_collect_with_post_mark(&OneRoot(Some(young_survivor)), |_| {});
3841
3842        assert_eq!(heap.allgc_count(), 2);
3843        assert_eq!(old_unreachable.age(), GcAge::Old);
3844        assert_eq!(young_survivor.age(), GcAge::Survival);
3845        assert!(young_survivor.color().is_white());
3846    }
3847
3848    #[test]
3849    fn minor_collect_skips_untouched_old_root_scan_work() {
3850        let heap = Heap::new();
3851        heap.unpause();
3852        let old_root = heap.allocate(Cell0 {
3853            next: Cell::new(None),
3854            marker_calls: Cell::new(0),
3855        });
3856        old_root.set_age(GcAge::Old);
3857        old_root.set_color(Color::Black);
3858        let young_root = heap.allocate(Cell0 {
3859            next: Cell::new(None),
3860            marker_calls: Cell::new(0),
3861        });
3862
3863        heap.minor_collect_with_post_mark(
3864            &TwoRoots {
3865                first: Some(old_root),
3866                second: Some(young_root),
3867            },
3868            |_| {},
3869        );
3870
3871        let stats = heap.last_mark_stats();
3872        assert_eq!(stats.marked, 2);
3873        assert_eq!(stats.marked_old, 1);
3874        assert_eq!(stats.marked_young, 1);
3875        assert_eq!(stats.traced, 1);
3876        assert_eq!(stats.traced_old, 0);
3877        assert_eq!(stats.traced_young, 1);
3878        assert_eq!(old_root.marker_calls.get(), 0);
3879        assert_eq!(young_root.marker_calls.get(), 1);
3880        assert_eq!(old_root.age(), GcAge::Old);
3881        assert_eq!(young_root.age(), GcAge::Survival);
3882    }
3883
3884    #[test]
3885    fn minor_collect_traces_touched_old_parent() {
3886        let heap = Heap::new();
3887        heap.unpause();
3888        let old_root = heap.allocate(Cell0 {
3889            next: Cell::new(None),
3890            marker_calls: Cell::new(0),
3891        });
3892        old_root.set_age(GcAge::Old);
3893        old_root.set_color(Color::Black);
3894        let young_child = heap.allocate(Cell0 {
3895            next: Cell::new(None),
3896            marker_calls: Cell::new(0),
3897        });
3898        old_root.next.set(Some(young_child));
3899        heap.generational_backward_barrier(old_root);
3900
3901        heap.minor_collect_with_post_mark(&OneRoot(Some(old_root)), |_| {});
3902
3903        let stats = heap.last_mark_stats();
3904        assert_eq!(stats.marked, 2);
3905        assert_eq!(stats.marked_old, 1);
3906        assert_eq!(stats.marked_young, 1);
3907        assert_eq!(stats.traced, 2);
3908        assert_eq!(stats.traced_old, 1);
3909        assert_eq!(stats.traced_young, 1);
3910        assert_eq!(old_root.marker_calls.get(), 1);
3911        assert_eq!(young_child.marker_calls.get(), 1);
3912        assert_eq!(old_root.age(), GcAge::Touched2);
3913        assert_eq!(young_child.age(), GcAge::Survival);
3914    }
3915
3916    #[test]
3917    fn minor_sweep_uses_generation_cursors_to_skip_old_tail() {
3918        let heap = Heap::new();
3919        heap.unpause();
3920        let mut old_objects = Vec::new();
3921        for _ in 0..64 {
3922            old_objects.push(heap.allocate(Cell0 {
3923                next: Cell::new(None),
3924                marker_calls: Cell::new(0),
3925            }));
3926        }
3927        heap.promote_all_to_old();
3928        let young_root = heap.allocate(Cell0 {
3929            next: Cell::new(None),
3930            marker_calls: Cell::new(0),
3931        });
3932
3933        heap.minor_collect_with_post_mark(&OneRoot(Some(young_root)), |_| {});
3934
3935        let stats = heap.last_sweep_stats();
3936        assert_eq!(stats.visited, 1);
3937        assert_eq!(stats.visited_young, 1);
3938        assert_eq!(stats.visited_old, 0);
3939        assert_eq!(heap.allgc_count(), old_objects.len() + 1);
3940        assert_eq!(young_root.age(), GcAge::Survival);
3941    }
3942
3943    #[test]
3944    fn full_sweep_corrects_generation_cursors_when_cursor_object_is_freed() {
3945        let heap = Heap::new();
3946        heap.unpause();
3947        let _old = heap.allocate(Cell0 {
3948            next: Cell::new(None),
3949            marker_calls: Cell::new(0),
3950        });
3951        heap.promote_all_to_old();
3952        assert!(heap.survival.get().is_some());
3953        assert!(heap.old1.get().is_some());
3954        assert!(heap.reallyold.get().is_some());
3955
3956        heap.full_collect(&OneRoot(None));
3957
3958        assert_eq!(heap.allgc_count(), 0);
3959        assert_eq!(heap.survival.get(), None);
3960        assert_eq!(heap.old1.get(), None);
3961        assert_eq!(heap.reallyold.get(), None);
3962        assert_eq!(heap.firstold1.get(), None);
3963        assert_eq!(heap.last_sweep_stats().freed, 1);
3964    }
3965
3966    /// W1 deletion invariant (test matrix G3, list-mechanics half): a
3967    /// cross-list move of a gray-listed object deletes its grayagain entry and
3968    /// clears `HDR_GRAY_LISTED`, via `unlink_from_list` ->
3969    /// `correct_generation_pointers` -> `unlink_grayagain`. Covers both the
3970    /// `allgc -> finobj` and `tobefnz -> allgc` transitions, and every move
3971    /// returns `true` (R1 item 7 sync note). Deterministic; identical on
3972    /// pristine and W1 code.
3973    #[test]
3974    fn cross_list_move_deletes_grayagain_entry() {
3975        let heap = Heap::new();
3976        heap.unpause();
3977        let obj = heap.allocate(Cell0 {
3978            next: Cell::new(None),
3979            marker_calls: Cell::new(0),
3980        });
3981        obj.set_age(GcAge::Old1);
3982        obj.set_color(Color::Gray);
3983        heap.remember_minor_revisit(obj.as_trace_ptr());
3984        assert_eq!(heap.grayagain_count(), 1);
3985        assert!(heap.header_from_ptr(obj.as_trace_ptr()).gray_listed());
3986        assert!(heap.move_allgc_to_finobj(obj.as_trace_ptr()));
3987        assert_eq!(
3988            heap.grayagain_count(),
3989            0,
3990            "allgc->finobj move deletes the grayagain entry"
3991        );
3992        assert!(!heap.header_from_ptr(obj.as_trace_ptr()).gray_listed());
3993
3994        let heap = Heap::new();
3995        heap.unpause();
3996        let obj = heap.allocate(Cell0 {
3997            next: Cell::new(None),
3998            marker_calls: Cell::new(0),
3999        });
4000        assert!(heap.move_allgc_to_finobj(obj.as_trace_ptr()));
4001        assert!(heap.move_finobj_to_tobefnz(obj.as_trace_ptr()));
4002        obj.set_age(GcAge::Old1);
4003        obj.set_color(Color::Gray);
4004        heap.remember_minor_revisit(obj.as_trace_ptr());
4005        assert_eq!(heap.grayagain_count(), 1);
4006        assert!(heap.move_tobefnz_to_allgc(obj.as_trace_ptr()));
4007        assert_eq!(
4008            heap.grayagain_count(),
4009            0,
4010            "tobefnz->allgc move deletes the grayagain entry"
4011        );
4012        assert!(!heap.header_from_ptr(obj.as_trace_ptr()).gray_listed());
4013    }
4014
4015    /// Test matrix G3 (graph-shaped, baseline-first). Root -> exact-`Old`
4016    /// parent (skipped by `Marker::should_trace_age` in a minor) -> a
4017    /// gray-listed `Old1` transitional object -> a `New` young child. Moving
4018    /// the transitional off `allgc` deletes its grayagain entry (the only
4019    /// revisit rooting the child), then a minor runs. This is a pure
4020    /// generational-liveness property of *today's* collector that W1 inherits
4021    /// unchanged — `correct_generation_pointers`' deletion, `should_trace_age`,
4022    /// and the minor mark/sweep are all untouched by W1.
4023    ///
4024    /// BASELINE (recorded against pristine origin/main, via a throwaway
4025    /// worktree, 2026-07-13): the young child is FREED by the minor
4026    /// (`allgc_count` 3 -> 2). With the transitional's grayagain entry deleted
4027    /// by the move and the parent exact-`Old` (so the minor marker never
4028    /// descends into it), nothing re-marks the transitional and its `New`
4029    /// child is swept. This is the "reachable young child dying" case the spec
4030    /// anticipates (§"Every owner-list move"): a pre-existing latent hole in
4031    /// the generational collector, filed as issue #263, NOT a W1 regression and
4032    /// NOT something W1 fixes. W1 reproduces the baseline exactly. The test is
4033    /// `#[ignore]`d because it asserts the *correct* invariant (child should
4034    /// survive), which the pre-existing baseline violates; running it
4035    /// (`cargo test -p lua-gc -- --ignored g3_`) documents the live gap. This
4036    /// test is the acceptance test for the #263 fix — un-`#[ignore]` it then.
4037    #[test]
4038    #[ignore = "pre-existing generational-liveness gap (issue #263): reachable \
4039                young child dies when a gray-listed transitional is moved \
4040                cross-list; identical on pristine and W1 — not a W1 bug"]
4041    fn g3_moved_transitional_keeps_young_child_reachable() {
4042        let heap = Heap::new();
4043        heap.unpause();
4044        let child = heap.allocate(Cell0 {
4045            next: Cell::new(None),
4046            marker_calls: Cell::new(0),
4047        });
4048        let transitional = heap.allocate(Cell0 {
4049            next: Cell::new(Some(child)),
4050            marker_calls: Cell::new(0),
4051        });
4052        let parent = heap.allocate(Cell0 {
4053            next: Cell::new(Some(transitional)),
4054            marker_calls: Cell::new(0),
4055        });
4056        parent.set_age(GcAge::Old);
4057        parent.set_color(Color::Black);
4058        transitional.set_age(GcAge::Old1);
4059        transitional.set_color(Color::Gray);
4060        heap.remember_minor_revisit(transitional.as_trace_ptr());
4061        assert_eq!(heap.grayagain_count(), 1);
4062        assert_eq!(heap.allgc_count(), 3);
4063
4064        assert!(heap.move_allgc_to_finobj(transitional.as_trace_ptr()));
4065        assert_eq!(
4066            heap.grayagain_count(),
4067            0,
4068            "cross-list move deletes the grayagain entry"
4069        );
4070
4071        heap.minor_collect_with_post_mark(&OneRoot(Some(parent)), |_| {});
4072
4073        assert_eq!(
4074            heap.allgc_count(),
4075            3,
4076            "reachable young child should survive the minor"
4077        );
4078    }
4079
4080    /// Baseline-preservation pin for the #263 scenario (codex W1 review,
4081    /// finding 1): the SAME graph as `g3_moved_transitional_keeps_young_child_
4082    /// reachable`, but asserting what the collector does TODAY — the
4083    /// reachable young child is freed. Runs in normal CI so any W1-era
4084    /// divergence in this scenario is caught immediately. When #263 is fixed,
4085    /// this test must be inverted/removed together with un-ignoring G3.
4086    #[test]
4087    fn g3_baseline_child_freed_after_move_allgc_to_finobj() {
4088        let heap = Heap::new();
4089        heap.unpause();
4090        let child = heap.allocate(Cell0 {
4091            next: Cell::new(None),
4092            marker_calls: Cell::new(0),
4093        });
4094        let transitional = heap.allocate(Cell0 {
4095            next: Cell::new(Some(child)),
4096            marker_calls: Cell::new(0),
4097        });
4098        let parent = heap.allocate(Cell0 {
4099            next: Cell::new(Some(transitional)),
4100            marker_calls: Cell::new(0),
4101        });
4102        parent.set_age(GcAge::Old);
4103        parent.set_color(Color::Black);
4104        transitional.set_age(GcAge::Old1);
4105        transitional.set_color(Color::Gray);
4106        heap.remember_minor_revisit(transitional.as_trace_ptr());
4107
4108        assert!(heap.move_allgc_to_finobj(transitional.as_trace_ptr()));
4109        assert_eq!(heap.grayagain_count(), 0);
4110
4111        heap.minor_collect_with_post_mark(&OneRoot(Some(parent)), |_| {});
4112
4113        assert_eq!(
4114            heap.allgc_count(),
4115            2,
4116            "baseline (pre-#263-fix): the young child is freed; if this \
4117             changes, either #263 was fixed (invert this test and un-ignore \
4118             G3) or minor liveness regressed"
4119        );
4120    }
4121
4122    /// Baseline-preservation pin, `move_tobefnz_to_allgc` variant (codex W1
4123    /// review, finding 1): the transitional travels allgc -> finobj ->
4124    /// tobefnz, is re-remembered for revisit there, and the move BACK to
4125    /// allgc deletes the entry; the following minor frees the reachable
4126    /// young child, same as the finobj variant. Invert together with the
4127    /// #263 fix.
4128    #[test]
4129    fn g3_baseline_child_freed_after_move_tobefnz_to_allgc() {
4130        let heap = Heap::new();
4131        heap.unpause();
4132        let child = heap.allocate(Cell0 {
4133            next: Cell::new(None),
4134            marker_calls: Cell::new(0),
4135        });
4136        let transitional = heap.allocate(Cell0 {
4137            next: Cell::new(Some(child)),
4138            marker_calls: Cell::new(0),
4139        });
4140        let parent = heap.allocate(Cell0 {
4141            next: Cell::new(Some(transitional)),
4142            marker_calls: Cell::new(0),
4143        });
4144        parent.set_age(GcAge::Old);
4145        parent.set_color(Color::Black);
4146        transitional.set_age(GcAge::Old1);
4147        transitional.set_color(Color::Gray);
4148
4149        assert!(heap.move_allgc_to_finobj(transitional.as_trace_ptr()));
4150        assert!(heap.move_finobj_to_tobefnz(transitional.as_trace_ptr()));
4151        heap.remember_minor_revisit(transitional.as_trace_ptr());
4152        assert_eq!(heap.grayagain_count(), 1);
4153
4154        assert!(heap.move_tobefnz_to_allgc(transitional.as_trace_ptr()));
4155        assert_eq!(
4156            heap.grayagain_count(),
4157            0,
4158            "tobefnz->allgc move deletes the grayagain entry"
4159        );
4160
4161        heap.minor_collect_with_post_mark(&OneRoot(Some(parent)), |_| {});
4162
4163        assert_eq!(
4164            heap.allgc_count(),
4165            2,
4166            "baseline (pre-#263-fix): the young child is freed after the \
4167             tobefnz->allgc move deletes the revisit entry; invert together \
4168             with the #263 fix"
4169        );
4170    }
4171
4172    /// W1 teardown ordering (test matrix G3 teardown case / drop_all). A
4173    /// gray-listed object is present when `drop_all` runs;
4174    /// `clear_generation_cursors` -> `clear_grayagain` empties the grayagain
4175    /// `Vec` (and clears flags) *before* any `drop_list` frees a box, so no
4176    /// grayagain entry can point at a freed box. After teardown the set is
4177    /// empty and the heap holds nothing.
4178    #[test]
4179    fn drop_all_clears_grayagain_before_freeing() {
4180        let heap = Heap::new();
4181        heap.unpause();
4182        let obj = heap.allocate(Cell0 {
4183            next: Cell::new(None),
4184            marker_calls: Cell::new(0),
4185        });
4186        obj.set_age(GcAge::Old);
4187        heap.generational_backward_barrier(obj);
4188        assert_eq!(heap.grayagain_count(), 1);
4189
4190        heap.drop_all();
4191
4192        assert_eq!(heap.grayagain_count(), 0);
4193        assert_eq!(heap.allgc_count(), 0);
4194    }
4195
4196    /// Header-size regression for #113 Wave 1. Removing the `gray_next`
4197    /// grayagain fat pointer shrinks `GcHeader` from **40 B to 24 B on
4198    /// 64-bit** (`color + age + flags + pad + size(u32) + next(16)`), align 8.
4199    /// The wasm32 figure is 24 -> 16 B (an 8-B fat pointer), but that is NOT
4200    /// asserted here — a native test cannot observe the wasm32 layout, so the
4201    /// assertion is gated to 64-bit targets; the wasm32 size is pinned by the
4202    /// W2 `const` assert per the spec.
4203    #[test]
4204    #[cfg(target_pointer_width = "64")]
4205    fn gcheader_is_24_bytes_after_grayagain_diet() {
4206        assert_eq!(std::mem::size_of::<GcHeader>(), 24);
4207    }
4208
4209    #[test]
4210    fn full_sweep_unlinks_freed_grayagain_entries() {
4211        let heap = Heap::new();
4212        heap.unpause();
4213        let parent = heap.allocate(Cell0 {
4214            next: Cell::new(None),
4215            marker_calls: Cell::new(0),
4216        });
4217        heap.promote_all_to_old();
4218        heap.generational_backward_barrier(parent);
4219        assert_eq!(heap.grayagain_count(), 1);
4220
4221        heap.full_collect(&OneRoot(None));
4222
4223        assert_eq!(heap.allgc_count(), 0);
4224        assert_eq!(heap.grayagain_count(), 0);
4225
4226        let young = heap.allocate(Cell0 {
4227            next: Cell::new(None),
4228            marker_calls: Cell::new(0),
4229        });
4230        heap.minor_collect_with_post_mark(&OneRoot(Some(young)), |_| {});
4231        assert_eq!(young.age(), GcAge::Survival);
4232    }
4233
4234    #[test]
4235    fn grayagain_links_object_once() {
4236        let heap = Heap::new();
4237        heap.unpause();
4238        let parent = heap.allocate(Cell0 {
4239            next: Cell::new(None),
4240            marker_calls: Cell::new(0),
4241        });
4242        parent.set_age(GcAge::Old);
4243        parent.set_color(Color::Black);
4244
4245        heap.generational_backward_barrier(parent);
4246        heap.generational_backward_barrier(parent);
4247
4248        assert_eq!(heap.grayagain_count(), 1);
4249    }
4250
4251    #[test]
4252    fn grayagain_list_carries_old1_until_old() {
4253        let heap = Heap::new();
4254        heap.unpause();
4255        let survivor = heap.allocate(Cell0 {
4256            next: Cell::new(None),
4257            marker_calls: Cell::new(0),
4258        });
4259
4260        heap.minor_collect_with_post_mark(&OneRoot(Some(survivor)), |_| {});
4261        assert_eq!(survivor.age(), GcAge::Survival);
4262
4263        heap.minor_collect_with_post_mark(&OneRoot(Some(survivor)), |_| {});
4264        assert_eq!(survivor.age(), GcAge::Old1);
4265
4266        heap.minor_collect_with_post_mark(&OneRoot(None), |_| {});
4267        assert_eq!(survivor.age(), GcAge::Old);
4268        assert_eq!(heap.allgc_count(), 1);
4269    }
4270
4271    #[test]
4272    fn grayagain_list_carries_touched2_until_old() {
4273        let heap = Heap::new();
4274        heap.unpause();
4275        let parent = heap.allocate(Cell0 {
4276            next: Cell::new(None),
4277            marker_calls: Cell::new(0),
4278        });
4279        parent.set_age(GcAge::Old);
4280        parent.set_color(Color::Black);
4281        heap.generational_backward_barrier(parent);
4282
4283        heap.minor_collect_with_post_mark(&OneRoot(None), |_| {});
4284        assert_eq!(parent.age(), GcAge::Touched2);
4285
4286        heap.minor_collect_with_post_mark(&OneRoot(None), |_| {});
4287        assert_eq!(parent.age(), GcAge::Old);
4288        assert_eq!(parent.marker_calls.get(), 2);
4289    }
4290
4291    #[test]
4292    fn collect_traverses_cycles() {
4293        let heap = Heap::new();
4294        heap.unpause();
4295        let a = heap.allocate(Cell0 {
4296            next: Cell::new(None),
4297            marker_calls: Cell::new(0),
4298        });
4299        let b = heap.allocate(Cell0 {
4300            next: Cell::new(Some(a)),
4301            marker_calls: Cell::new(0),
4302        });
4303        a.next.set(Some(b)); // cycle
4304                             // With a as root, both should survive.
4305        heap.full_collect(&OneRoot(Some(a)));
4306        assert_eq!(a.marker_calls.get(), 1);
4307        assert_eq!(b.marker_calls.get(), 1);
4308        // Drop the only root path; cycle should now be collected.
4309        // (Note: `a` and `b` are still on the stack as Gc<Cell0> handles, but
4310        // they're not in a Trace-visible position.)
4311        heap.full_collect(&OneRoot(None));
4312        assert_eq!(heap.bytes_used(), 0);
4313    }
4314
4315    #[test]
4316    fn heap_guard_stacks() {
4317        assert!(
4318            with_current_heap(|heap| heap.is_none()),
4319            "no guard initially"
4320        );
4321        let h1 = Heap::new();
4322        h1.unpause();
4323        {
4324            let _g1 = HeapGuard::push(&h1);
4325            assert!(with_current_heap(|heap| heap.is_some()));
4326            let h2 = Heap::new();
4327            h2.unpause();
4328            {
4329                let _g2 = HeapGuard::push(&h2);
4330                // top of stack is h2
4331                with_current_heap(|heap| {
4332                    assert!(std::rc::Rc::ptr_eq(heap.unwrap(), &h2));
4333                });
4334            }
4335            // _g2 dropped — top is back to h1
4336            with_current_heap(|heap| {
4337                assert!(std::rc::Rc::ptr_eq(heap.unwrap(), &h1));
4338            });
4339        }
4340        assert!(
4341            with_current_heap(|heap| heap.is_none()),
4342            "stack empty after all guards drop"
4343        );
4344    }
4345
4346    #[test]
4347    fn step_threshold_triggers_collect() {
4348        let heap = Heap::new();
4349        heap.unpause();
4350        // Allocate enough boxes to exceed the default 64KB threshold.
4351        let mut keeps = Vec::new();
4352        for _ in 0..64 {
4353            // ~1KB per box (Cell0 is small, but allocating many headers
4354            // accumulates). For the threshold test we'd typically allocate
4355            // larger objects; this is a smoke test.
4356            keeps.push(heap.allocate(Cell0 {
4357                next: Cell::new(None),
4358                marker_calls: Cell::new(0),
4359            }));
4360        }
4361        // Build roots that retain all of keeps via individual marks.
4362        struct ManyRoots<'a>(&'a [Gc<Cell0>]);
4363        impl<'a> Trace for ManyRoots<'a> {
4364            fn trace(&self, m: &mut Marker) {
4365                for g in self.0.iter() {
4366                    m.mark(*g);
4367                }
4368            }
4369        }
4370        heap.step(&ManyRoots(&keeps));
4371        // step is a no-op below threshold; all roots retained.
4372        assert!(heap.bytes_used() > 0);
4373    }
4374
4375    #[test]
4376    fn threshold_floored_after_collecting_tiny_heap() {
4377        let heap = Heap::new();
4378        heap.unpause();
4379        struct NoRoots;
4380        impl Trace for NoRoots {
4381            fn trace(&self, _m: &mut Marker) {}
4382        }
4383        for _ in 0..200 {
4384            heap.allocate(Cell0 {
4385                next: Cell::new(None),
4386                marker_calls: Cell::new(0),
4387            });
4388        }
4389        heap.full_collect(&NoRoots);
4390        assert!(
4391            heap.threshold_bytes() >= GC_MIN_THRESHOLD,
4392            "threshold {} collapsed below floor {}; a churning program would full-collect per allocation",
4393            heap.threshold_bytes(),
4394            GC_MIN_THRESHOLD
4395        );
4396    }
4397
4398    fn build_chain(heap: &Heap, len: usize) -> Gc<Cell0> {
4399        let head = heap.allocate(Cell0 {
4400            next: Cell::new(None),
4401            marker_calls: Cell::new(0),
4402        });
4403        let mut cur = head;
4404        for _ in 1..len {
4405            let n = heap.allocate(Cell0 {
4406                next: Cell::new(None),
4407                marker_calls: Cell::new(0),
4408            });
4409            cur.next.set(Some(n));
4410            cur = n;
4411        }
4412        head
4413    }
4414
4415    #[test]
4416    fn budget_zero_does_some_work() {
4417        let heap = Heap::new();
4418        heap.unpause();
4419        let head = build_chain(&heap, 8);
4420        let roots = OneRoot(Some(head));
4421        let budget = StepBudget::from_work(0);
4422        let outcome = heap.incremental_step_with_post_mark(&roots, budget, |_| {});
4423        assert_ne!(outcome, StepOutcome::SkippedStopped);
4424        assert_ne!(heap.gc_state(), GcState::Pause);
4425    }
4426
4427    #[test]
4428    fn run_until_state_stops_before_next_phase_work() {
4429        let heap = Heap::new();
4430        heap.unpause();
4431        let head = build_chain(&heap, 8);
4432        let roots = OneRoot(Some(head));
4433        let atomic_calls = Cell::new(0);
4434
4435        let outcome =
4436            heap.incremental_run_until_state_with_post_mark(&roots, GcState::Atomic, 1024, |_| {
4437                atomic_calls.set(atomic_calls.get() + 1)
4438            });
4439        assert_eq!(outcome, StepOutcome::InProgress);
4440        assert_eq!(heap.gc_state(), GcState::Atomic);
4441        assert_eq!(
4442            atomic_calls.get(),
4443            0,
4444            "atomic hook must not run before inspection"
4445        );
4446
4447        let outcome = heap.incremental_run_until_state_with_post_mark(
4448            &roots,
4449            GcState::SweepAllGc,
4450            1024,
4451            |_| atomic_calls.set(atomic_calls.get() + 1),
4452        );
4453        assert_eq!(outcome, StepOutcome::InProgress);
4454        assert_eq!(heap.gc_state(), GcState::SweepAllGc);
4455        assert_eq!(
4456            atomic_calls.get(),
4457            1,
4458            "entering sweep must run the atomic hook once"
4459        );
4460    }
4461
4462    #[test]
4463    fn larger_budget_drains_more_gray_than_smaller() {
4464        let small_heap = Heap::new();
4465        small_heap.unpause();
4466        let h1 = build_chain(&small_heap, 64);
4467        let r1 = OneRoot(Some(h1));
4468        let mut small_calls = 0;
4469        loop {
4470            small_calls += 1;
4471            let outcome =
4472                small_heap.incremental_step_with_post_mark(&r1, StepBudget::from_work(2), |_| {});
4473            if outcome == StepOutcome::Paused {
4474                break;
4475            }
4476            assert!(small_calls < 10_000, "did not converge");
4477        }
4478
4479        let big_heap = Heap::new();
4480        big_heap.unpause();
4481        let h2 = build_chain(&big_heap, 64);
4482        let r2 = OneRoot(Some(h2));
4483        let mut big_calls = 0;
4484        loop {
4485            big_calls += 1;
4486            let outcome =
4487                big_heap.incremental_step_with_post_mark(&r2, StepBudget::from_work(64), |_| {});
4488            if outcome == StepOutcome::Paused {
4489                break;
4490            }
4491            assert!(big_calls < 10_000, "did not converge");
4492        }
4493
4494        assert!(
4495            big_calls < small_calls,
4496            "expected big_calls={} < small_calls={}",
4497            big_calls,
4498            small_calls
4499        );
4500    }
4501
4502    #[test]
4503    fn sweep_can_pause_and_resume() {
4504        let heap = Heap::new();
4505        heap.unpause();
4506        for _ in 0..16 {
4507            let _ = heap.allocate(Cell0 {
4508                next: Cell::new(None),
4509                marker_calls: Cell::new(0),
4510            });
4511        }
4512        let roots = OneRoot(None);
4513        let bytes_before = heap.bytes_used();
4514        assert!(bytes_before > 0);
4515        let mut step_count = 0;
4516        let mut saw_in_progress_during_sweep = false;
4517        loop {
4518            step_count += 1;
4519            let outcome =
4520                heap.incremental_step_with_post_mark(&roots, StepBudget::from_work(2), |_| {});
4521            if heap.gc_state().is_sweep() && outcome == StepOutcome::InProgress {
4522                saw_in_progress_during_sweep = true;
4523            }
4524            if outcome == StepOutcome::Paused {
4525                break;
4526            }
4527            assert!(step_count < 10_000, "did not converge");
4528        }
4529        assert!(saw_in_progress_during_sweep, "sweep never paused mid-list");
4530        assert_eq!(heap.bytes_used(), 0);
4531    }
4532
4533    #[test]
4534    fn post_mark_runs_once_per_atomic() {
4535        let heap = Heap::new();
4536        heap.unpause();
4537        for _ in 0..32 {
4538            let _ = heap.allocate(Cell0 {
4539                next: Cell::new(None),
4540                marker_calls: Cell::new(0),
4541            });
4542        }
4543        let roots = OneRoot(None);
4544        let call_count = std::cell::Cell::new(0);
4545        let mut step_count = 0;
4546        loop {
4547            step_count += 1;
4548            let outcome =
4549                heap.incremental_step_with_post_mark(&roots, StepBudget::from_work(2), |_| {
4550                    call_count.set(call_count.get() + 1);
4551                });
4552            if outcome == StepOutcome::Paused {
4553                break;
4554            }
4555            assert!(step_count < 10_000, "did not converge");
4556        }
4557        assert_eq!(
4558            call_count.get(),
4559            1,
4560            "post_mark must run exactly once per cycle"
4561        );
4562    }
4563
4564    #[test]
4565    fn full_collect_equivalent_to_incremental_to_pause() {
4566        let h1 = Heap::new();
4567        h1.unpause();
4568        let head1 = h1.allocate(Cell0 {
4569            next: Cell::new(None),
4570            marker_calls: Cell::new(0),
4571        });
4572        let _orphan1 = h1.allocate(Cell0 {
4573            next: Cell::new(None),
4574            marker_calls: Cell::new(0),
4575        });
4576        let _orphan2 = h1.allocate(Cell0 {
4577            next: Cell::new(None),
4578            marker_calls: Cell::new(0),
4579        });
4580        let roots1 = OneRoot(Some(head1));
4581        h1.full_collect(&roots1);
4582        let bytes_full = h1.bytes_used();
4583
4584        let h2 = Heap::new();
4585        h2.unpause();
4586        let head2 = h2.allocate(Cell0 {
4587            next: Cell::new(None),
4588            marker_calls: Cell::new(0),
4589        });
4590        let _orphan3 = h2.allocate(Cell0 {
4591            next: Cell::new(None),
4592            marker_calls: Cell::new(0),
4593        });
4594        let _orphan4 = h2.allocate(Cell0 {
4595            next: Cell::new(None),
4596            marker_calls: Cell::new(0),
4597        });
4598        let roots2 = OneRoot(Some(head2));
4599        loop {
4600            let outcome =
4601                h2.incremental_step_with_post_mark(&roots2, StepBudget::from_work(1), |_| {});
4602            if outcome == StepOutcome::Paused {
4603                break;
4604            }
4605        }
4606        assert_eq!(h2.bytes_used(), bytes_full);
4607    }
4608
4609    // ── issue #249: guard-less/bootstrap allocations must not outlive Heap::drop ──
4610
4611    /// Sets an `Rc<Cell<bool>>` flag when the value it wraps drops, so a test
4612    /// can observe whether a box was actually freed.
4613    struct DropFlag(std::rc::Rc<Cell<bool>>);
4614    impl Drop for DropFlag {
4615        fn drop(&mut self) {
4616            self.0.set(true);
4617        }
4618    }
4619    struct Tracked(#[allow(dead_code)] DropFlag);
4620    impl Trace for Tracked {
4621        fn trace(&self, _m: &mut Marker) {}
4622    }
4623
4624    #[test]
4625    fn allocate_uncollected_survives_collection_but_is_freed_on_heap_drop() {
4626        let heap = Heap::new();
4627        heap.unpause();
4628        let dropped = std::rc::Rc::new(Cell::new(false));
4629        let _gc = heap.allocate_uncollected(Tracked(DropFlag(dropped.clone())));
4630
4631        // Not on head/finobj/tobefnz, so a full collection with no roots at
4632        // all must not touch it.
4633        heap.full_collect(&OneRoot(None));
4634        assert!(
4635            !dropped.get(),
4636            "allocate_uncollected box must survive a full collection while the heap is alive"
4637        );
4638
4639        drop(heap);
4640        assert!(
4641            dropped.get(),
4642            "allocate_uncollected box must be freed once its heap drops (issue #249)"
4643        );
4644    }
4645
4646    #[test]
4647    fn bootstrapping_routes_allocate_to_the_uncollected_list() {
4648        let heap = Heap::new();
4649        heap.unpause();
4650        assert!(!heap.is_bootstrapping());
4651
4652        heap.begin_bootstrap();
4653        let dropped = std::rc::Rc::new(Cell::new(false));
4654        let _gc = heap.allocate(Tracked(DropFlag(dropped.clone())));
4655
4656        // Routed through allocate_uncollected: invisible to the normal
4657        // collectable count and immune to a same-cycle collection.
4658        assert_eq!(
4659            heap.allgc_count(),
4660            0,
4661            "a bootstrap allocation must not join the normal collectable list"
4662        );
4663        heap.full_collect(&OneRoot(None));
4664        assert!(
4665            !dropped.get(),
4666            "a bootstrap allocation must survive collection while bootstrapping is active"
4667        );
4668
4669        heap.end_bootstrap();
4670        drop(heap);
4671        assert!(
4672            dropped.get(),
4673            "a bootstrap allocation must still be freed when the heap drops"
4674        );
4675    }
4676
4677    /// Pins the ownership-flag semantics the strict-guard checks key on
4678    /// (`GcRef::downgrade`/`account_buffer` panic for guard-less operations
4679    /// on any heap-owned box): a bootstrap box is heap-owned but not
4680    /// sweepable, so treating "not sweepable" as "not hazardous" would let
4681    /// a weak handle to it dangle after `drop_all` frees the box at heap
4682    /// teardown.
4683    #[test]
4684    fn ownership_flags_distinguish_all_three_allocation_paths() {
4685        let heap = Heap::new();
4686        let tracked = heap.allocate(Tracked(DropFlag(std::rc::Rc::new(Cell::new(false)))));
4687        assert!(tracked.is_heap_owned());
4688        assert!(tracked.is_heap_tracked());
4689
4690        let bootstrap =
4691            heap.allocate_uncollected(Tracked(DropFlag(std::rc::Rc::new(Cell::new(false)))));
4692        assert!(
4693            bootstrap.is_heap_owned(),
4694            "bootstrap boxes die in drop_all — strict-guard hazard checks must see them"
4695        );
4696        assert!(!bootstrap.is_heap_tracked());
4697
4698        let detached = Gc::new_uncollected(Tracked(DropFlag(std::rc::Rc::new(Cell::new(false)))));
4699        assert!(!detached.is_heap_owned());
4700        assert!(!detached.is_heap_tracked());
4701    }
4702
4703    #[test]
4704    fn end_bootstrap_restores_normal_sweepable_allocation() {
4705        let heap = Heap::new();
4706        heap.unpause();
4707        heap.begin_bootstrap();
4708        heap.end_bootstrap();
4709
4710        let dropped = std::rc::Rc::new(Cell::new(false));
4711        let _gc = heap.allocate(Tracked(DropFlag(dropped.clone())));
4712        assert_eq!(heap.allgc_count(), 1);
4713
4714        heap.full_collect(&OneRoot(None));
4715        // Not a drop-flag assertion: under LUA_RS_GC_QUARANTINE=1, sweep
4716        // unlinks the box and parks it (poisoned) instead of freeing it
4717        // immediately, so its value's Drop doesn't run until heap teardown.
4718        // `allgc_count` reflects the unlink either way — quarantine mode
4719        // settles owner-list accounting the same as a real free.
4720        assert_eq!(
4721            heap.allgc_count(),
4722            0,
4723            "after end_bootstrap, an unreachable allocation must be swept normally"
4724        );
4725    }
4726
4727    // ── issue #260: deterministic close — drain-until-stable drop_all + closed flag ──
4728
4729    /// A `Trace` payload whose `Drop` flips its flag and then allocates a
4730    /// fresh drop-flag box into the currently-active heap. Exercises
4731    /// `drop_all`'s drain-until-stable loop: a destructor that allocates
4732    /// mid-teardown must have its box freed by a later pass, not stranded.
4733    struct AllocOnDrop {
4734        flag: std::rc::Rc<Cell<bool>>,
4735        inner: std::rc::Rc<Cell<bool>>,
4736    }
4737    impl Trace for AllocOnDrop {
4738        fn trace(&self, _m: &mut Marker) {}
4739    }
4740    impl Drop for AllocOnDrop {
4741        fn drop(&mut self) {
4742            self.flag.set(true);
4743            with_current_heap(|heap| {
4744                if let Some(heap) = heap {
4745                    let _ = heap.allocate(Tracked(DropFlag(self.inner.clone())));
4746                }
4747            });
4748        }
4749    }
4750
4751    #[test]
4752    fn close_frees_objects_while_outer_guard_alive() {
4753        let heap = Heap::new();
4754        heap.unpause();
4755        let guard = HeapGuard::push(&heap);
4756
4757        let dropped = std::rc::Rc::new(Cell::new(false));
4758        let _gc = heap.allocate(Tracked(DropFlag(dropped.clone())));
4759        assert!(!dropped.get());
4760
4761        heap.drop_all();
4762        assert!(
4763            dropped.get(),
4764            "drop_all must run the object's destructor during the close call, \
4765             while the outer guard is still alive"
4766        );
4767        assert!(heap.is_closed());
4768        assert_eq!(heap.bytes_used(), 0);
4769        drop(guard);
4770    }
4771
4772    #[test]
4773    #[should_panic(expected = "closed heap")]
4774    fn allocate_into_closed_heap_panics() {
4775        let heap = Heap::new();
4776        heap.unpause();
4777        let _guard = HeapGuard::push(&heap);
4778        heap.drop_all();
4779        let _ = heap.allocate(Tracked(DropFlag(std::rc::Rc::new(Cell::new(false)))));
4780    }
4781
4782    #[test]
4783    fn destructor_allocating_during_teardown_does_not_leak() {
4784        let heap = Heap::new();
4785        heap.unpause();
4786        let _guard = HeapGuard::push(&heap);
4787
4788        let outer = std::rc::Rc::new(Cell::new(false));
4789        let inner = std::rc::Rc::new(Cell::new(false));
4790        let _gc = heap.allocate(AllocOnDrop {
4791            flag: outer.clone(),
4792            inner: inner.clone(),
4793        });
4794
4795        heap.drop_all();
4796        assert!(outer.get(), "the destructor itself must have run");
4797        assert!(
4798            inner.get(),
4799            "the box the destructor allocated mid-teardown must be freed by a \
4800             later drain pass, not leaked"
4801        );
4802        assert_eq!(heap.bytes_used(), 0);
4803    }
4804
4805    #[test]
4806    fn weak_handles_dead_immediately_after_close() {
4807        let heap = Heap::new();
4808        heap.unpause();
4809        let _guard = HeapGuard::push(&heap);
4810
4811        let gc = heap.allocate(Tracked(DropFlag(std::rc::Rc::new(Cell::new(false)))));
4812        let identity = gc.identity();
4813        let token = heap.register_allocation_token(identity);
4814        let weak = HeapRef::from_heap(&heap);
4815        assert!(
4816            weak.contains_allocation(identity, token),
4817            "weak handle upgrades while the box is live"
4818        );
4819
4820        heap.drop_all();
4821
4822        assert!(
4823            !weak.contains_allocation(identity, token),
4824            "weak handle must fail to upgrade the instant close clears the \
4825             allocation tokens, while the heap Rc is still alive"
4826        );
4827    }
4828
4829    /// A `Trace` payload whose `Drop` allocates a fresh box and fires the
4830    /// generational-barrier path (`remember_minor_revisit`) on it, mid-drain.
4831    /// Codex finding 1 on #260: before the closed-heap gate, that pushed a
4832    /// pointer into grayagain which a later drain pass freed — a second
4833    /// `drop_all` (or `Heap::drop` after an explicit close) then dereferenced
4834    /// the freed header in `clear_grayagain`.
4835    struct BarrierOnDrop {
4836        flag: std::rc::Rc<Cell<bool>>,
4837        inner: std::rc::Rc<Cell<bool>>,
4838    }
4839    impl Trace for BarrierOnDrop {
4840        fn trace(&self, _m: &mut Marker) {}
4841    }
4842    impl Drop for BarrierOnDrop {
4843        fn drop(&mut self) {
4844            self.flag.set(true);
4845            with_current_heap(|heap| {
4846                if let Some(heap) = heap {
4847                    let fresh = heap.allocate(Tracked(DropFlag(self.inner.clone())));
4848                    heap.remember_minor_revisit(fresh.as_trace_ptr());
4849                }
4850            });
4851        }
4852    }
4853
4854    #[test]
4855    fn teardown_barrier_cannot_repopulate_grayagain() {
4856        let heap = Heap::new();
4857        heap.unpause();
4858        let _guard = HeapGuard::push(&heap);
4859
4860        let outer = std::rc::Rc::new(Cell::new(false));
4861        let inner = std::rc::Rc::new(Cell::new(false));
4862        let _gc = heap.allocate(BarrierOnDrop {
4863            flag: outer.clone(),
4864            inner: inner.clone(),
4865        });
4866
4867        heap.drop_all();
4868        assert!(outer.get(), "the destructor itself must have run");
4869        assert!(
4870            inner.get(),
4871            "the box the destructor allocated must still be drained"
4872        );
4873        assert_eq!(
4874            heap.grayagain_count(),
4875            0,
4876            "a barrier fired during teardown must not park a soon-freed \
4877             pointer in grayagain"
4878        );
4879
4880        heap.drop_all();
4881        assert_eq!(heap.grayagain_count(), 0);
4882        assert_eq!(heap.bytes_used(), 0);
4883    }
4884
4885    /// A `Trace` payload whose `Drop` allocates a fresh box, roots it, and
4886    /// invokes minor + full collections mid-drain. Codex round 2 finding 1 on
4887    /// #260: with only the barrier path gated, a destructor-run
4888    /// `minor_collect` reaches `sweep_young` → `replace_grayagain` and
4889    /// re-installs grayagain entries a later drain pass frees — recreating
4890    /// the second-`drop_all` UAF. Every collection entry point must be inert
4891    /// on a closed heap.
4892    struct CollectOnDrop {
4893        flag: std::rc::Rc<Cell<bool>>,
4894        inner: std::rc::Rc<Cell<bool>>,
4895    }
4896    struct TrackedRoot(Option<Gc<Tracked>>);
4897    impl Trace for TrackedRoot {
4898        fn trace(&self, m: &mut Marker) {
4899            if let Some(g) = self.0 {
4900                m.mark(g);
4901            }
4902        }
4903    }
4904    impl Trace for CollectOnDrop {
4905        fn trace(&self, _m: &mut Marker) {}
4906    }
4907    impl Drop for CollectOnDrop {
4908        fn drop(&mut self) {
4909            self.flag.set(true);
4910            with_current_heap(|heap| {
4911                if let Some(heap) = heap {
4912                    let fresh = heap.allocate(Tracked(DropFlag(self.inner.clone())));
4913                    let root = TrackedRoot(Some(fresh));
4914                    heap.minor_collect_with_post_mark(&root, |_: &mut Marker| {});
4915                    heap.full_collect(&root);
4916                    heap.step(&root);
4917                }
4918            });
4919        }
4920    }
4921
4922    #[test]
4923    fn collections_invoked_by_teardown_destructor_are_inert() {
4924        let heap = Heap::new();
4925        heap.unpause();
4926        let _guard = HeapGuard::push(&heap);
4927
4928        let outer = std::rc::Rc::new(Cell::new(false));
4929        let inner = std::rc::Rc::new(Cell::new(false));
4930        let _gc = heap.allocate(CollectOnDrop {
4931            flag: outer.clone(),
4932            inner: inner.clone(),
4933        });
4934
4935        heap.drop_all();
4936        assert!(outer.get(), "the destructor itself must have run");
4937        assert!(
4938            inner.get(),
4939            "the box the destructor allocated must still be drained"
4940        );
4941        assert_eq!(
4942            heap.minor_collections(),
4943            0,
4944            "a minor collection invoked from a teardown destructor must be inert"
4945        );
4946        assert_eq!(
4947            heap.full_collections(),
4948            0,
4949            "a full collection invoked from a teardown destructor must be inert"
4950        );
4951        assert_eq!(heap.grayagain_count(), 0);
4952
4953        heap.drop_all();
4954        assert_eq!(heap.grayagain_count(), 0);
4955        assert_eq!(heap.bytes_used(), 0);
4956        assert!(!heap.would_collect());
4957    }
4958
4959    /// Codex finding 2 on #260, heap-level half: after close, the token
4960    /// machinery must refuse to mint (0 is never a valid token) and
4961    /// `contains_allocation` must be unconditionally false, so a post-close
4962    /// downgrade cannot resurrect a freed box as an upgradable weak target.
4963    #[test]
4964    fn post_close_token_minting_is_refused() {
4965        let heap = Heap::new();
4966        heap.unpause();
4967        let _guard = HeapGuard::push(&heap);
4968
4969        let gc = heap.allocate(Tracked(DropFlag(std::rc::Rc::new(Cell::new(false)))));
4970        let identity = gc.identity();
4971
4972        heap.drop_all();
4973
4974        let token = heap.register_allocation_token(identity);
4975        assert_eq!(token, 0, "a closed heap must refuse to mint a token");
4976        assert!(
4977            !heap.contains_allocation(identity, token),
4978            "the refused token must never validate"
4979        );
4980        assert!(
4981            heap.allocation_token(identity).is_none(),
4982            "the refusal must not have touched the token map"
4983        );
4984    }
4985}