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