Skip to main content

lua_gc/
heap.rs

1//! Phase D mark-and-sweep garbage collector.
2//!
3//! This module is the production GC for the Lua runtime, replacing the
4//! `Rc<T>`-backed `GcRef<T>` placeholder used through Phase B/C. It is a
5//! single-threaded, stop-the-world, precise tracing collector with a
6//! forward write barrier. Incremental marking is a future enhancement;
7//! the current implementation does a full collect each time `step` decides
8//! it's time.
9//!
10//! # Vocabulary
11//!
12//! - **Gc<T>**: a pointer-sized handle. `Copy + Clone`. Replaces `GcRef<T>`.
13//! - **GcBox<T>**: the heap allocation; contains a header and the value.
14//! - **GcHeader**: per-object metadata (color, finalized flag, intrusive
15//!   `next` pointer for the allgc list).
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 list head, byte counters, GC state machine.
20//!
21//! # Safety model
22//!
23//! All `unsafe` is confined to this crate (per `harness/unsafe-budgets.toml`).
24//! The invariants are:
25//!
26//! 1. Every `Gc<T>` points to a valid, allocated, not-yet-swept `GcBox<T>`.
27//! 2. The allgc intrusive list is consistent: traversing `header.next` from
28//!    `Heap.head` reaches every live `GcBox` exactly once.
29//! 3. After `Heap::full_collect(roots)`, every `Gc<T>` reachable from `roots`
30//!    is still valid; unreachable boxes are dropped and deallocated.
31//!
32//! # Migration shape
33//!
34//! Existing code holds `GcRef<T>` (which after Phase D is a type alias for
35//! `Gc<T>`). Legacy call sites like `GcRef::new(value)` route through
36//! `Gc::new_uncollected` which allocates a `GcBox` but does NOT register it
37//! in any heap. Phase D-1b agent work converts these to
38//! `state.heap().allocate(value)` so the new box joins the allgc chain.
39
40use std::cell::{Cell, RefCell};
41use std::marker::PhantomData;
42use std::ptr::NonNull;
43
44// ──────────────────────────────────────────────────────────────────────────
45// Phase D-1c — scoped thread-local HeapGuard
46//
47// Lua's C API supports multiple `lua_State`s on one OS thread (sandbox-per-
48// state is a real embedding pattern). We honor that by stacking the
49// currently-active heap rather than holding a single slot. `HeapGuard::push`
50// activates a heap; drop pops it.
51//
52// `with_current_heap(...)` exposes the top of the stack only for the dynamic
53// extent of a closure — used by `GcRef::new` call sites that don't have
54// `&mut LuaState` in scope.
55// ──────────────────────────────────────────────────────────────────────────
56
57thread_local! {
58    static CURRENT_HEAP_STACK: RefCell<Vec<NonNull<Heap>>> = const { RefCell::new(Vec::new()) };
59}
60
61/// A scoped guard for the currently-active heap. Pushed at entry to
62/// `state.run()` / `state.protected_call()` / `state.load()`; popped on
63/// drop. Supports nesting (multiple LuaStates on one thread).
64pub struct HeapGuard {
65    // Anchor a NonNull so the user can't accidentally drop the guard while
66    // an inner Lua state is still active. We rely on RAII.
67    _private: (),
68}
69
70impl HeapGuard {
71    /// Push `heap` onto the active stack. Returns a guard; dropping it pops.
72    ///
73    /// # Safety
74    ///
75    /// The pointer must remain valid for the lifetime of the guard. Callers
76    /// typically pass `&state.global.heap`, which lives as long as the
77    /// `GlobalState` (an `Rc<RefCell<_>>`); the guard must drop before the
78    /// state is dropped.
79    pub fn push(heap: &Heap) -> Self {
80        let ptr = NonNull::from(heap);
81        CURRENT_HEAP_STACK.with(|stack| stack.borrow_mut().push(ptr));
82        HeapGuard { _private: () }
83    }
84}
85
86impl Drop for HeapGuard {
87    fn drop(&mut self) {
88        CURRENT_HEAP_STACK.with(|stack| {
89            let popped = stack.borrow_mut().pop();
90            debug_assert!(popped.is_some(), "HeapGuard::drop with empty stack");
91        });
92    }
93}
94
95/// Runs `f` with a reference to the currently-active heap, or `None` if no
96/// `HeapGuard` is in scope.
97///
98/// The heap reference is deliberately scoped to the closure. This avoids the
99/// previous `current_heap() -> Option<&'static Heap>` lifetime lie while still
100/// supporting legacy `GcRef::new` call sites that do not receive `&mut LuaState`.
101pub fn with_current_heap<R>(f: impl for<'a> FnOnce(Option<&'a Heap>) -> R) -> R {
102    CURRENT_HEAP_STACK.with(|stack| {
103        let ptr = stack.borrow().last().copied();
104        // SAFETY: the top NonNull was produced from a live `&Heap` whose
105        // lifetime is bounded by the corresponding `HeapGuard`. The reference
106        // is only handed to `f`, and cannot escape through the return type.
107        let heap = ptr.map(|ptr| unsafe { &*ptr.as_ptr() });
108        f(heap)
109    })
110}
111
112/// A traced color in the tri-color invariant.
113#[derive(Copy, Clone, PartialEq, Eq, Debug)]
114pub enum Color {
115    /// Not yet visited this cycle. Candidate for sweep.
116    White,
117    /// Visited; outgoing references not yet traced.
118    Gray,
119    /// Fully traced; no outgoing pointers to white objects.
120    Black,
121}
122
123/// Per-object GC metadata. Lives at the start of every `GcBox`.
124#[repr(C)]
125pub struct GcHeader {
126    color: Cell<Color>,
127    /// Set true after this object's finalizer (`__gc` metamethod) has run.
128    /// Phase D defers finalizers; this is reserved.
129    finalized: Cell<bool>,
130    /// True iff this box is linked into a heap's allgc chain, so it will be
131    /// swept and its `size` refunded. `new_uncollected` boxes leave this
132    /// false: they never join a chain, are never swept, and so must never
133    /// have buffer bytes charged against the pacer (the charge would never be
134    /// refunded). [`Gc::account_buffer`] is a no-op when this is false.
135    ///
136    /// Placed adjacent to the other byte-sized flags (before `next`) so it
137    /// packs into existing alignment padding: the header stays the same size
138    /// it was before this field existed, so per-object allocation accounting
139    /// — and therefore collection timing — is unchanged by the plumbing.
140    collected: Cell<bool>,
141    /// Intrusive link into the heap's allgc chain.
142    next: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
143    /// Rough byte size charged to the pacer for this object. Starts at the
144    /// `GcBox<T>` size and is adjusted in place by [`Gc::account_buffer`] when
145    /// the value's owned heap buffers (table array/node Vecs) grow or shrink.
146    /// Invariant: this is always exactly the amount sweep will refund to the
147    /// heap's byte counter when this object is freed.
148    size: Cell<usize>,
149}
150
151impl GcHeader {
152    fn new_white(size: usize) -> Self {
153        Self {
154            color: Cell::new(Color::White),
155            finalized: Cell::new(false),
156            collected: Cell::new(false),
157            next: Cell::new(None),
158            size: Cell::new(size),
159        }
160    }
161}
162
163/// A heap-allocated, GC-tracked value plus its header.
164#[repr(C)]
165pub struct GcBox<T: ?Sized> {
166    header: GcHeader,
167    value: T,
168}
169
170impl<T: ?Sized> GcBox<T> {
171    pub fn header(&self) -> &GcHeader {
172        &self.header
173    }
174    pub fn value(&self) -> &T {
175        &self.value
176    }
177}
178
179/// A GC-managed pointer. Copy + Clone (one-machine-word). Replaces `GcRef<T>`.
180pub struct Gc<T: ?Sized> {
181    ptr: NonNull<GcBox<T>>,
182    /// Marker so `Gc<T>` is treated as if it owns `T` for variance.
183    _marker: PhantomData<T>,
184}
185
186// SAFETY: Gc is just a pointer. The Cell-based interior mutability of the
187// header is single-threaded (the entire Lua runtime is single-threaded), so
188// no Send/Sync impls are needed and we don't provide them.
189impl<T: ?Sized> Copy for Gc<T> {}
190impl<T: ?Sized> Clone for Gc<T> {
191    fn clone(&self) -> Self {
192        *self
193    }
194}
195
196impl<T: ?Sized> PartialEq for Gc<T> {
197    fn eq(&self, other: &Self) -> bool {
198        std::ptr::addr_eq(self.ptr.as_ptr(), other.ptr.as_ptr())
199    }
200}
201impl<T: ?Sized> Eq for Gc<T> {}
202
203impl<T: ?Sized> std::hash::Hash for Gc<T> {
204    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
205        self.ptr.as_ptr().hash(state)
206    }
207}
208
209impl<T: ?Sized> std::fmt::Debug for Gc<T> {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        write!(f, "Gc({:p})", self.ptr.as_ptr())
212    }
213}
214
215impl<T: Trace + 'static> Gc<T> {
216    /// Allocate a `GcBox<T>` outside any heap registry. Used by legacy
217    /// `GcRef::new` call sites until Phase D-1b migrates them. The returned
218    /// `Gc<T>` is reachable only through the caller's own retention path;
219    /// without joining a heap's allgc chain, it will never be swept (so
220    /// effectively leaks until process exit — same as Rc behavior).
221    pub fn new_uncollected(value: T) -> Self {
222        let size = std::mem::size_of::<T>();
223        let boxed = Box::new(GcBox {
224            header: GcHeader::new_white(size),
225            value,
226        });
227        Gc {
228            ptr: NonNull::new(Box::into_raw(boxed)).expect("Box::into_raw is non-null"),
229            _marker: PhantomData,
230        }
231    }
232}
233
234impl<T: ?Sized> Gc<T> {
235    /// Two `Gc<T>`s are identity-equal iff they point at the same box.
236    pub fn ptr_eq(a: Self, b: Self) -> bool {
237        std::ptr::addr_eq(a.ptr.as_ptr(), b.ptr.as_ptr())
238    }
239
240    /// Identity as a usize — usable as a hash table key for "is the *same
241    /// object*" lookups.
242    pub fn identity(self) -> usize {
243        self.ptr.as_ptr() as *const () as usize
244    }
245
246    /// Access the underlying value. Returns `&T` so callers can read fields
247    /// without taking the `Gc` apart. Interior mutability lives inside T's
248    /// own fields (Cell, RefCell, etc.).
249    fn as_box(&self) -> &GcBox<T> {
250        // SAFETY: A Gc<T> is constructed only by allocate() or
251        // new_uncollected(), both of which produce a valid GcBox. The box
252        // outlives the Gc until sweep, which only frees boxes not reachable
253        // from any root — so as long as this Gc is on the stack or in a
254        // traced field, the box is live.
255        unsafe { self.ptr.as_ref() }
256    }
257
258    fn header(&self) -> &GcHeader {
259        &self.as_box().header
260    }
261
262    /// Charge (`delta > 0`) or refund (`delta < 0`) `delta` bytes of this
263    /// object's owned heap buffers against the pacer, keeping `header.size`
264    /// as the single source of truth for what sweep will refund.
265    ///
266    /// No-op when `delta == 0` or when this box is not on a heap allgc chain
267    /// (`collected == false`): an uncollected box is never swept, so charging
268    /// it would permanently inflate the byte counter. On the collected path,
269    /// `header.size` and the heap's byte counter move together, so after sweep
270    /// frees this box it refunds exactly the bytes that were charged here.
271    pub fn account_buffer(&self, heap: &Heap, delta: isize) {
272        if delta == 0 {
273            return;
274        }
275        let header = self.header();
276        if !header.collected.get() {
277            return;
278        }
279        if delta >= 0 {
280            header.size.set(header.size.get().saturating_add(delta as usize));
281        } else {
282            header
283                .size
284                .set(header.size.get().saturating_sub((-delta) as usize));
285        }
286        heap.adjust_bytes(delta);
287    }
288}
289
290impl<T: ?Sized> std::ops::Deref for Gc<T> {
291    type Target = T;
292    fn deref(&self) -> &T {
293        &self.as_box().value
294    }
295}
296
297impl<T: ?Sized> AsRef<T> for Gc<T> {
298    fn as_ref(&self) -> &T {
299        &self.as_box().value
300    }
301}
302
303/// Every GC-rooted type implements `Trace` to expose its `Gc<_>` fields.
304///
305/// The `trace` method visits every reachable `Gc<_>` and calls
306/// `Marker::mark` on it. For container fields (`Vec<LuaValue>`, etc.) call
307/// `field.trace(m)` to delegate.
308///
309/// # Mechanical pattern
310///
311/// ```ignore
312/// impl Trace for LuaTable {
313///     fn trace(&self, m: &mut Marker) {
314///         for v in self.array.iter() { v.trace(m); }
315///         if let Some(mt) = self.metatable { m.mark(mt); }
316///     }
317/// }
318/// ```
319pub trait Trace {
320    fn trace(&self, m: &mut Marker);
321}
322
323// Common blanket impls so most container types Just Work.
324impl<T: Trace> Trace for Vec<T> {
325    fn trace(&self, m: &mut Marker) {
326        for item in self.iter() {
327            item.trace(m);
328        }
329    }
330}
331
332impl<T: Trace> Trace for Option<T> {
333    fn trace(&self, m: &mut Marker) {
334        if let Some(v) = self {
335            v.trace(m);
336        }
337    }
338}
339
340impl<T: Trace + ?Sized> Trace for Box<T> {
341    fn trace(&self, m: &mut Marker) {
342        (**self).trace(m);
343    }
344}
345
346impl<T: Trace + ?Sized> Trace for std::rc::Rc<T> {
347    fn trace(&self, m: &mut Marker) {
348        (**self).trace(m);
349    }
350}
351
352impl<T: Trace> Trace for RefCell<T> {
353    fn trace(&self, m: &mut Marker) {
354        self.borrow().trace(m);
355    }
356}
357
358/// `Gc<T>` is itself traceable: marking it forwards to the contained `T`.
359impl<T: Trace + 'static> Trace for Gc<T> {
360    fn trace(&self, m: &mut Marker) {
361        m.mark(*self);
362    }
363}
364
365// Trivially-traceable primitive types: visiting does nothing.
366macro_rules! trace_noop {
367    ($($t:ty),*) => {
368        $(impl Trace for $t {
369            fn trace(&self, _m: &mut Marker) {}
370        })*
371    };
372}
373trace_noop!(
374    bool, u8, u16, u32, u64, u128, usize,
375    i8, i16, i32, i64, i128, isize,
376    f32, f64, char, String, str
377);
378
379impl<T> Trace for std::marker::PhantomData<T> {
380    fn trace(&self, _m: &mut Marker) {}
381}
382
383/// Holds the gray queue during a mark phase. Passed to `Trace::trace`.
384pub struct Marker {
385    gray_queue: Vec<NonNull<GcBox<dyn Trace>>>,
386    visited: std::collections::HashSet<usize>,
387}
388
389impl Marker {
390    fn new() -> Self {
391        Self {
392            gray_queue: Vec::with_capacity(256),
393            visited: std::collections::HashSet::new(),
394        }
395    }
396
397    /// Mark a `Gc<T>` as gray (reachable, but its outgoing edges not yet
398    /// traced). Called by `Trace::trace` implementations.
399    ///
400    /// Per-cycle dedup uses `visited` (a HashSet of box identities) rather
401    /// than the color flag. Color-based dedup would silently skip
402    /// `new_uncollected` boxes left Black by the previous cycle — those
403    /// allocations are NOT on the heap's allgc chain, so the start-of-mark
404    /// "reset all allgc to White" loop does not reach them, and a Black
405    /// uncollected box would be skipped without re-tracing its children
406    /// (causing reachable allgc descendants to be swept). The visited set
407    /// is rebuilt every `full_collect` (Marker::new), so this dedup is
408    /// always per-cycle.
409    pub fn mark<T: Trace + 'static>(&mut self, gc: Gc<T>) {
410        let id = gc.identity();
411        if self.visited.insert(id) {
412            let header = gc.header();
413            header.color.set(Color::Gray);
414            let ptr: NonNull<GcBox<dyn Trace>> = gc.ptr;
415            self.gray_queue.push(ptr);
416        }
417    }
418
419    /// Record that an Rc-backed object (`GcRef<T>` in Phase A-D-0) has been
420    /// visited and return whether this is the first visit. Used by recursive
421    /// `Trace` impls to break cycles while the real `Gc<T>` gray-queue path is
422    /// not yet wired (e.g. `_G._G == _G` would otherwise infinitely recurse).
423    pub fn try_visit(&mut self, addr: usize) -> bool {
424        self.visited.insert(addr)
425    }
426
427    /// True iff `id` was reached during the mark phase. Used by the
428    /// post-mark hook (`Heap::full_collect_with_post_mark`) to decide whether
429    /// a weak-table entry's target is still strongly reachable. Read-only —
430    /// callers cannot add entries.
431    pub fn is_visited(&self, id: usize) -> bool {
432        self.visited.contains(&id)
433    }
434
435    /// Number of objects marked so far. Used by the post-mark hook's
436    /// ephemeron-convergence fixed-point loop to detect when an iteration
437    /// added no new reachable objects and the loop can terminate.
438    pub fn visited_count(&self) -> usize {
439        self.visited.len()
440    }
441
442    /// Drain the gray queue, transitively marking children. Each gray box
443    /// becomes black; its `Trace::trace` is called so the children it points
444    /// at get pushed onto the queue. Repeats until the queue is empty.
445    ///
446    /// Exposed for the post-mark hook so it can run ephemeron convergence:
447    /// after marking new values via [`Marker::mark`] (or `value.trace(self)`),
448    /// the hook calls `drain_gray_queue` to propagate the new reachability,
449    /// then re-checks for fixed-point.
450    pub fn drain_gray_queue(&mut self) {
451        while let Some(gray_ptr) = self.gray_queue.pop() {
452            unsafe {
453                let bx = gray_ptr.as_ref();
454                bx.header.color.set(Color::Black);
455                bx.value.trace(self);
456            }
457        }
458    }
459}
460
461/// Phases of the incremental collection cycle.
462///
463/// The state machine matches a simplified subset of C-Lua's `lgc.c` FSM and
464/// is driven by [`Heap::incremental_step_with_post_mark`].
465///
466/// Transitions:
467/// - `Pause` → `Propagate` (on first step: reset colors, trace roots).
468/// - `Propagate` → `Atomic` (when the gray queue empties).
469/// - `Atomic` → `Sweep` (post-mark hook has run; sweep cursor is initialized).
470/// - `Sweep` → `Finalize` (sweep cursor reached the end of allgc).
471/// - `Finalize` → `Pause` (any pending finalize work has been drained).
472///
473/// `Collecting` is kept as a compatibility alias for the old API (used by
474/// `barrier`) — it means "anything but Pause."
475#[derive(Copy, Clone, PartialEq, Eq, Debug)]
476pub enum GcState {
477    Pause,
478    Propagate,
479    Atomic,
480    Sweep,
481    Finalize,
482}
483
484impl GcState {
485    pub fn is_pause(self) -> bool {
486        matches!(self, GcState::Pause)
487    }
488    pub fn is_propagate(self) -> bool {
489        matches!(self, GcState::Propagate)
490    }
491    pub fn is_sweep(self) -> bool {
492        matches!(self, GcState::Sweep)
493    }
494}
495
496/// Outcome of one call to [`Heap::incremental_step_with_post_mark`].
497#[derive(Copy, Clone, PartialEq, Eq, Debug)]
498pub enum StepOutcome {
499    /// The step finished a cycle. The heap is back at `GcState::Pause`.
500    Paused,
501    /// The step performed work but the cycle is not finished. Caller may
502    /// step again.
503    InProgress,
504    /// The heap is paused (via [`Heap::pause`]) or the caller asked for zero
505    /// budget while no cycle was in progress and no work was needed.
506    SkippedStopped,
507}
508
509/// Work budget for one incremental step.
510///
511/// `remaining_work` counts down by one for each unit of work performed (one
512/// gray object traced, one swept node visited, one finalizer dispatched).
513/// `max_credit` caps how negative `remaining_work` may be allowed to go — a
514/// step that overshoots its budget rolls the overrun into the caller's debt
515/// rather than letting unbounded work happen in one call.
516#[derive(Copy, Clone, Debug)]
517pub struct StepBudget {
518    pub remaining_work: isize,
519    pub max_credit: isize,
520}
521
522impl StepBudget {
523    /// Build a budget from a number of allowed work units.
524    pub fn from_work(work: isize) -> Self {
525        Self { remaining_work: work.max(1), max_credit: work.max(1) }
526    }
527}
528
529/// The heap. One per `GlobalState`. Owns the intrusive allgc list of every
530/// allocated `GcBox`, tracks total bytes, and runs collections.
531/// Floor for the post-collection threshold. Without it, a tight
532/// allocation loop drives the live set near zero, so `bytes * pause/100`
533/// collapses toward zero and a full stop-the-world collection fires every
534/// few allocations, re-tracing all roots each time (issue #38, GC path).
535/// The floor bounds the wasted work: the collector waits until at least
536/// this many bytes of garbage accumulate before collecting a small heap.
537///
538/// Raised from 256 KB to 1 MB once table array/node buffer bytes became
539/// honestly accounted (see [`Gc::account_buffer`]): with real buffer bytes
540/// flowing into the pacer, a 256 KB floor fires too eagerly on table-heavy
541/// workloads, re-tracing all roots each time. 1 MB keeps the small-heap
542/// over-collection guard while letting honest pressure drive the threshold.
543const GC_MIN_THRESHOLD: usize = 1024 * 1024;
544
545pub struct Heap {
546    /// Head of the singly-linked allgc list (every live `GcBox`).
547    head: Cell<Option<NonNull<GcBox<dyn Trace>>>>,
548    /// Total bytes allocated (sum of header sizes; rough).
549    bytes: Cell<usize>,
550    /// Threshold above which `step` triggers a collection.
551    threshold: Cell<usize>,
552    /// Multiplier on bytes_used to set next threshold after collection.
553    pause_multiplier: Cell<usize>,
554    /// State machine for the incremental collector.
555    state: Cell<GcState>,
556    /// If true, `step` and `barrier` are no-ops (for bootstrap before the
557    /// world is consistent).
558    paused: Cell<bool>,
559    /// Counter of full collections performed (for diagnostics).
560    collections: Cell<usize>,
561    /// In-progress marker state for incremental cycles. `Some` between
562    /// `Propagate` start and `Sweep` start; `None` otherwise.
563    marker: RefCell<Option<Marker>>,
564    /// Sweep cursor. Points at the `Cell` whose `Option<NonNull>` is the
565    /// "current" link being inspected during the sweep phase. Encoded as a
566    /// raw pointer because the cell lives inside a `GcHeader` (Cell, not Cell<Cell>).
567    /// `None` means: sweep starts from `self.head`.
568    sweep_prev_next: Cell<Option<NonNull<Cell<Option<NonNull<GcBox<dyn Trace>>>>>>>,
569}
570
571impl Default for Heap {
572    fn default() -> Self {
573        Self::new()
574    }
575}
576
577impl Heap {
578    pub fn new() -> Self {
579        Self {
580            head: Cell::new(None),
581            bytes: Cell::new(0),
582            threshold: Cell::new(64 * 1024), // initial threshold: 64 KB
583            pause_multiplier: Cell::new(200), // 200% = collect when bytes 2x threshold
584            state: Cell::new(GcState::Pause),
585            paused: Cell::new(true), // start paused; caller enables when world is consistent
586            collections: Cell::new(0),
587            marker: RefCell::new(None),
588            sweep_prev_next: Cell::new(None),
589        }
590    }
591
592    /// Enable collection. Until this is called, `step` is a no-op (so the
593    /// runtime can bootstrap without prematurely freeing objects).
594    pub fn unpause(&self) {
595        self.paused.set(false);
596    }
597
598    pub fn is_paused(&self) -> bool {
599        self.paused.get()
600    }
601
602    /// Allocate a new `GcBox<T>` and prepend it to the allgc chain.
603    pub fn allocate<T: Trace + 'static>(&self, value: T) -> Gc<T> {
604        let size = std::mem::size_of::<GcBox<T>>();
605        let boxed = Box::new(GcBox {
606            header: GcHeader::new_white(size),
607            value,
608        });
609        boxed.header.next.set(self.head.get());
610        boxed.header.collected.set(true);
611        let raw: *mut GcBox<T> = Box::into_raw(boxed);
612        let ptr: NonNull<GcBox<T>> =
613            NonNull::new(raw).expect("Box::into_raw is non-null");
614        let dyn_ptr: NonNull<GcBox<dyn Trace>> = ptr;
615        self.head.set(Some(dyn_ptr));
616        self.bytes.set(self.bytes.get() + size);
617        Gc {
618            ptr,
619            _marker: PhantomData,
620        }
621    }
622
623    /// Bytes currently retained by GC-tracked objects (rough estimate).
624    pub fn bytes_used(&self) -> usize {
625        self.bytes.get()
626    }
627
628    /// Adjust the heap's pacer byte counter by a signed delta, saturating at
629    /// zero. Used by [`Gc::account_buffer`] to charge or refund the bytes of
630    /// an object's owned heap buffers (table array/node Vecs) so collections
631    /// fire at honest memory pressure rather than only on header sizes.
632    pub fn adjust_bytes(&self, delta: isize) {
633        if delta >= 0 {
634            self.bytes.set(self.bytes.get().saturating_add(delta as usize));
635        } else {
636            self.bytes
637                .set(self.bytes.get().saturating_sub((-delta) as usize));
638        }
639    }
640
641    /// Current collection threshold in bytes. When `bytes_used() >= threshold_bytes()`,
642    /// the next `step()` will run a full collection (unless paused). Used by
643    /// callers that want to short-circuit expensive prep work (e.g. snapshotting
644    /// weak tables / pending finalizers) when no collection will actually fire.
645    pub fn threshold_bytes(&self) -> usize {
646        self.threshold.get()
647    }
648
649    /// Cheap predicate: would a `step()` actually do work? Equivalent to
650    /// `!paused && bytes_used() >= threshold_bytes()`. Callers that build
651    /// snapshot state before invoking the heap should gate on this.
652    pub fn would_collect(&self) -> bool {
653        !self.paused.get() && self.bytes.get() >= self.threshold.get()
654    }
655
656    pub fn collections(&self) -> usize {
657        self.collections.get()
658    }
659
660    /// Forward write barrier: invoked when `parent` (already-traced black
661    /// object) gains a new reference to `child`. To preserve the tri-color
662    /// invariant ("no black points to white"), we mark the child gray
663    /// immediately. Cheap: one branch + maybe one queue push.
664    ///
665    /// During incremental mode this prevents the marking phase from missing
666    /// the new edge. In current stop-the-world mode it's still correct (a
667    /// no-op when the collection is idle), so call sites can be wired now
668    /// and the incremental upgrade is mechanical later.
669    pub fn barrier<P, C>(&self, parent: Gc<P>, child: Gc<C>)
670    where
671        P: Trace + 'static,
672        C: Trace + 'static,
673    {
674        if self.paused.get() || self.state.get().is_pause() {
675            return;
676        }
677        if parent.header().color.get() != Color::Black {
678            return;
679        }
680        if child.header().color.get() != Color::White {
681            return;
682        }
683        child.header().color.set(Color::Gray);
684        if let Ok(mut m_opt) = self.marker.try_borrow_mut() {
685            if let Some(m) = m_opt.as_mut() {
686                let ptr: NonNull<GcBox<dyn Trace>> = child.ptr;
687                m.gray_queue.push(ptr);
688                m.visited.insert(child.identity());
689            }
690        }
691    }
692
693    /// Possibly run a collection. Trigger: bytes_used > threshold.
694    /// Caller passes the root set (the runtime — typically `GlobalState`
695    /// implementing `Trace`).
696    pub fn step(&self, roots: &dyn Trace) {
697        self.step_with_post_mark(roots, |_: &mut Marker| {});
698    }
699
700    /// Like [`step`] but invokes a `post_mark` hook when a collection
701    /// actually fires (threshold reached). Hook is a no-op on the
702    /// short-circuit path. The runtime uses this to bridge weak-table
703    /// pruning into implicit GC steps fired from inside the VM loop.
704    pub fn step_with_post_mark<F: FnMut(&mut Marker)>(
705        &self,
706        roots: &dyn Trace,
707        post_mark: F,
708    ) {
709        if self.paused.get() {
710            return;
711        }
712        if self.bytes.get() < self.threshold.get() {
713            return;
714        }
715        self.full_collect_with_post_mark(roots, post_mark);
716    }
717
718    /// Stop-the-world full collect. Marks every reachable object from
719    /// `roots`, then sweeps white (unreachable) boxes from the allgc chain.
720    pub fn full_collect(&self, roots: &dyn Trace) {
721        self.full_collect_with_post_mark(roots, |_: &mut Marker| {});
722    }
723
724    /// Run only the mark/atomic hook portion of a collection, without sweeping.
725    ///
726    /// This is used by runtimes that need an atomic reachability snapshot for
727    /// weak-table cleanup while they are deliberately avoiding object freeing.
728    pub fn mark_only_with_post_mark<F: FnMut(&mut Marker)>(
729        &self,
730        roots: &dyn Trace,
731        mut post_mark: F,
732    ) {
733        if self.paused.get() {
734            return;
735        }
736        let mut cursor = self.head.get();
737        while let Some(ptr) = cursor {
738            let header = unsafe { &(*ptr.as_ptr()).header };
739            header.color.set(Color::White);
740            cursor = header.next.get();
741        }
742        let mut marker = Marker::new();
743        roots.trace(&mut marker);
744        marker.drain_gray_queue();
745        post_mark(&mut marker);
746        marker.drain_gray_queue();
747    }
748
749    /// Stop-the-world full collect with a post-mark hook.
750    ///
751    /// Internally drives the incremental state machine to completion with
752    /// an unbounded budget — equivalent to repeatedly calling
753    /// [`incremental_step_with_post_mark`] until it returns `Paused`. The
754    /// post-mark hook is invoked exactly once, during the atomic transition.
755    pub fn full_collect_with_post_mark<F: FnMut(&mut Marker)>(
756        &self,
757        roots: &dyn Trace,
758        mut post_mark: F,
759    ) {
760        if self.paused.get() {
761            return;
762        }
763        if !self.state.get().is_pause() {
764            self.abort_cycle();
765        }
766        let unlimited = StepBudget { remaining_work: isize::MAX, max_credit: isize::MAX };
767        loop {
768            let outcome = self.incremental_step_with_post_mark(roots, unlimited, &mut post_mark);
769            if matches!(outcome, StepOutcome::Paused | StepOutcome::SkippedStopped) {
770                break;
771            }
772        }
773    }
774
775    /// Run one budgeted step of the incremental collector.
776    ///
777    /// The state machine advances `Pause → Propagate → Atomic → Sweep →
778    /// Finalize → Pause`. Each phase consumes budget; the call returns when
779    /// the budget runs out or the cycle reaches `Pause`. The `post_mark`
780    /// hook is invoked exactly once per cycle, during the `Atomic`
781    /// transition (after the initial gray-queue drain, before sweep starts).
782    ///
783    /// Returns:
784    /// - [`StepOutcome::Paused`] — the cycle completed.
785    /// - [`StepOutcome::InProgress`] — budget exhausted before the cycle
786    ///   finished; caller may step again.
787    /// - [`StepOutcome::SkippedStopped`] — heap is paused; nothing happened.
788    pub fn incremental_step_with_post_mark<F: FnMut(&mut Marker)>(
789        &self,
790        roots: &dyn Trace,
791        mut budget: StepBudget,
792        mut post_mark: F,
793    ) -> StepOutcome {
794        if self.paused.get() {
795            return StepOutcome::SkippedStopped;
796        }
797        self.run_budgeted(roots, &mut budget, &mut post_mark);
798        if self.state.get().is_pause() {
799            StepOutcome::Paused
800        } else {
801            StepOutcome::InProgress
802        }
803    }
804
805    fn run_budgeted(
806        &self,
807        roots: &dyn Trace,
808        budget: &mut StepBudget,
809        post_mark: &mut dyn FnMut(&mut Marker),
810    ) -> bool {
811        let mut did_work = false;
812        loop {
813            if budget.remaining_work <= -budget.max_credit {
814                return did_work;
815            }
816            match self.state.get() {
817                GcState::Pause => {
818                    self.start_cycle(roots);
819                    self.state.set(GcState::Propagate);
820                    budget.remaining_work -= 1;
821                    did_work = true;
822                }
823                GcState::Propagate => {
824                    let work = self.drain_gray_budgeted(budget.remaining_work.max(1));
825                    budget.remaining_work -= work as isize;
826                    did_work = did_work || work > 0;
827                    let empty = {
828                        let m = self.marker.borrow();
829                        m.as_ref().map(|m| m.gray_queue.is_empty()).unwrap_or(true)
830                    };
831                    if empty {
832                        self.state.set(GcState::Atomic);
833                    } else if budget.remaining_work <= 0 {
834                        return did_work;
835                    }
836                }
837                GcState::Atomic => {
838                    self.run_atomic(post_mark);
839                    self.state.set(GcState::Sweep);
840                    budget.remaining_work -= 1;
841                    did_work = true;
842                }
843                GcState::Sweep => {
844                    let work = self.sweep_budgeted(budget.remaining_work.max(1));
845                    budget.remaining_work -= work as isize;
846                    did_work = did_work || work > 0;
847                    if self.sweep_prev_next.get().is_none() {
848                        self.state.set(GcState::Finalize);
849                    } else if budget.remaining_work <= 0 {
850                        return did_work;
851                    }
852                }
853                GcState::Finalize => {
854                    self.finish_cycle();
855                    self.state.set(GcState::Pause);
856                    return did_work;
857                }
858            }
859        }
860    }
861
862    fn start_cycle(&self, roots: &dyn Trace) {
863        let mut cursor = self.head.get();
864        while let Some(ptr) = cursor {
865            let header = unsafe { &(*ptr.as_ptr()).header };
866            header.color.set(Color::White);
867            cursor = header.next.get();
868        }
869        let mut marker = Marker::new();
870        roots.trace(&mut marker);
871        *self.marker.borrow_mut() = Some(marker);
872        self.sweep_prev_next.set(None);
873    }
874
875    fn drain_gray_budgeted(&self, max_units: isize) -> usize {
876        let mut m_opt = self.marker.borrow_mut();
877        let marker = match m_opt.as_mut() {
878            Some(m) => m,
879            None => return 0,
880        };
881        let mut work = 0usize;
882        let mut budget = max_units;
883        while budget > 0 {
884            let next = match marker.gray_queue.pop() {
885                Some(p) => p,
886                None => break,
887            };
888            unsafe {
889                let bx = next.as_ref();
890                bx.header.color.set(Color::Black);
891                bx.value.trace(marker);
892            }
893            work += 1;
894            budget -= 1;
895        }
896        work
897    }
898
899    fn run_atomic(&self, post_mark: &mut dyn FnMut(&mut Marker)) {
900        let mut m_opt = self.marker.borrow_mut();
901        if let Some(marker) = m_opt.as_mut() {
902            marker.drain_gray_queue();
903            post_mark(marker);
904            marker.drain_gray_queue();
905        }
906        self.sweep_prev_next.set(Some(NonNull::from(&self.head)));
907    }
908
909    fn sweep_budgeted(&self, max_units: isize) -> usize {
910        let mut work = 0usize;
911        let mut budget = max_units;
912        let mut freed_bytes = 0usize;
913        let mut prev_next_ptr = match self.sweep_prev_next.get() {
914            Some(p) => p,
915            None => return 0,
916        };
917        while budget > 0 {
918            let prev_cell = unsafe { prev_next_ptr.as_ref() };
919            let cursor = prev_cell.get();
920            let ptr = match cursor {
921                Some(p) => p,
922                None => {
923                    self.sweep_prev_next.set(None);
924                    break;
925                }
926            };
927            let header = unsafe { &(*ptr.as_ptr()).header };
928            let next = header.next.get();
929            match header.color.get() {
930                Color::White => {
931                    prev_cell.set(next);
932                    freed_bytes += header.size.get();
933                    unsafe {
934                        let _ = Box::from_raw(ptr.as_ptr());
935                    }
936                }
937                Color::Black | Color::Gray => {
938                    header.color.set(Color::White);
939                    prev_next_ptr = unsafe {
940                        NonNull::from(&(*ptr.as_ptr()).header.next)
941                    };
942                    self.sweep_prev_next.set(Some(prev_next_ptr));
943                }
944            }
945            work += 1;
946            budget -= 1;
947        }
948        if freed_bytes > 0 {
949            self.bytes.set(self.bytes.get().saturating_sub(freed_bytes));
950        }
951        work
952    }
953
954    fn finish_cycle(&self) {
955        *self.marker.borrow_mut() = None;
956        self.sweep_prev_next.set(None);
957        let next = self
958            .bytes
959            .get()
960            .saturating_mul(self.pause_multiplier.get())
961            / 100;
962        self.threshold.set(next.max(GC_MIN_THRESHOLD));
963        self.collections.set(self.collections.get() + 1);
964    }
965
966    fn abort_cycle(&self) {
967        if !self.state.get().is_pause() {
968            *self.marker.borrow_mut() = None;
969            self.sweep_prev_next.set(None);
970            self.state.set(GcState::Pause);
971        }
972    }
973
974    /// Returns the current state of the incremental collector.
975    pub fn gc_state(&self) -> GcState {
976        self.state.get()
977    }
978
979    /// Approximate number of live GC boxes, computed by walking the allgc
980    /// chain. Linear in heap size; used for cycle-cost estimation and tests.
981    pub fn allgc_count(&self) -> usize {
982        let mut count = 0usize;
983        let mut cursor = self.head.get();
984        while let Some(ptr) = cursor {
985            count += 1;
986            let header = unsafe { &(*ptr.as_ptr()).header };
987            cursor = header.next.get();
988        }
989        count
990    }
991
992    /// Drop every allocation, ignoring reachability. Called at shutdown.
993    /// After this returns, every outstanding `Gc<T>` is dangling — callers
994    /// must ensure no `Gc<T>` outlives the `Heap`.
995    pub fn drop_all(&self) {
996        *self.marker.borrow_mut() = None;
997        self.sweep_prev_next.set(None);
998        self.state.set(GcState::Pause);
999        let mut cursor = self.head.get();
1000        self.head.set(None);
1001        while let Some(ptr) = cursor {
1002            // SAFETY: same chain invariant as full_collect's sweep.
1003            let next = unsafe { (*ptr.as_ptr()).header.next.get() };
1004            unsafe {
1005                let _ = Box::from_raw(ptr.as_ptr());
1006            }
1007            cursor = next;
1008        }
1009        self.bytes.set(0);
1010    }
1011}
1012
1013impl Drop for Heap {
1014    fn drop(&mut self) {
1015        self.drop_all();
1016    }
1017}
1018
1019// ──────────────────────────────────────────────────────────────────────────
1020// Tests — confirm the skeleton's invariants before agents ever touch it.
1021// ──────────────────────────────────────────────────────────────────────────
1022
1023#[cfg(test)]
1024mod tests {
1025    use super::*;
1026
1027    /// A tiny GC-tracked type for the smoke test.
1028    struct Cell0 {
1029        next: Cell<Option<Gc<Cell0>>>,
1030        marker_calls: Cell<usize>,
1031    }
1032
1033    impl Trace for Cell0 {
1034        fn trace(&self, m: &mut Marker) {
1035            self.marker_calls.set(self.marker_calls.get() + 1);
1036            if let Some(n) = self.next.get() {
1037                m.mark(n);
1038            }
1039        }
1040    }
1041
1042    /// Roots for tests: just a single Gc<Cell0>, or none.
1043    struct OneRoot(Option<Gc<Cell0>>);
1044    impl Trace for OneRoot {
1045        fn trace(&self, m: &mut Marker) {
1046            if let Some(g) = self.0 {
1047                m.mark(g);
1048            }
1049        }
1050    }
1051
1052    #[test]
1053    fn alloc_and_drop_all() {
1054        let heap = Heap::new();
1055        heap.unpause();
1056        let _a = heap.allocate(Cell0 {
1057            next: Cell::new(None),
1058            marker_calls: Cell::new(0),
1059        });
1060        let _b = heap.allocate(Cell0 {
1061            next: Cell::new(None),
1062            marker_calls: Cell::new(0),
1063        });
1064        assert!(heap.bytes_used() > 0);
1065        heap.drop_all();
1066        assert_eq!(heap.bytes_used(), 0);
1067    }
1068
1069    #[test]
1070    fn account_buffer_refunded_on_sweep() {
1071        let heap = Heap::new();
1072        heap.unpause();
1073        let baseline = heap.bytes_used();
1074        {
1075            let a = heap.allocate(Cell0 {
1076                next: Cell::new(None),
1077                marker_calls: Cell::new(0),
1078            });
1079            assert!(heap.bytes_used() > baseline);
1080            a.account_buffer(&heap, 4096);
1081            assert_eq!(a.header().size.get(), std::mem::size_of::<GcBox<Cell0>>() + 4096);
1082        }
1083        // Drop the only root path (a is no longer Trace-visible). The +4096
1084        // must be refunded via header.size when the box is swept.
1085        heap.full_collect(&OneRoot(None));
1086        assert_eq!(
1087            heap.bytes_used(),
1088            baseline,
1089            "account_buffer charge must be fully refunded by sweep"
1090        );
1091    }
1092
1093    #[test]
1094    fn account_buffer_noop_on_uncollected() {
1095        let heap = Heap::new();
1096        heap.unpause();
1097        let g = Gc::new_uncollected(Cell0 {
1098            next: Cell::new(None),
1099            marker_calls: Cell::new(0),
1100        });
1101        let before = heap.bytes_used();
1102        g.account_buffer(&heap, 8192);
1103        assert_eq!(heap.bytes_used(), before, "uncollected box must not charge the pacer");
1104    }
1105
1106    #[test]
1107    fn collect_unreachable_frees_bytes() {
1108        let heap = Heap::new();
1109        heap.unpause();
1110        let _a = heap.allocate(Cell0 {
1111            next: Cell::new(None),
1112            marker_calls: Cell::new(0),
1113        });
1114        let bytes_before = heap.bytes_used();
1115        assert!(bytes_before > 0);
1116        // No roots — everything should sweep.
1117        heap.full_collect(&OneRoot(None));
1118        assert_eq!(heap.bytes_used(), 0);
1119        assert_eq!(heap.collections(), 1);
1120    }
1121
1122    #[test]
1123    fn collect_keeps_reachable() {
1124        let heap = Heap::new();
1125        heap.unpause();
1126        let root = heap.allocate(Cell0 {
1127            next: Cell::new(None),
1128            marker_calls: Cell::new(0),
1129        });
1130        let bytes_before = heap.bytes_used();
1131        heap.full_collect(&OneRoot(Some(root)));
1132        assert_eq!(heap.bytes_used(), bytes_before);
1133        assert_eq!(root.marker_calls.get(), 1);
1134    }
1135
1136    #[test]
1137    fn collect_traverses_cycles() {
1138        let heap = Heap::new();
1139        heap.unpause();
1140        let a = heap.allocate(Cell0 {
1141            next: Cell::new(None),
1142            marker_calls: Cell::new(0),
1143        });
1144        let b = heap.allocate(Cell0 {
1145            next: Cell::new(Some(a)),
1146            marker_calls: Cell::new(0),
1147        });
1148        a.next.set(Some(b)); // cycle
1149        // With a as root, both should survive.
1150        heap.full_collect(&OneRoot(Some(a)));
1151        assert_eq!(a.marker_calls.get(), 1);
1152        assert_eq!(b.marker_calls.get(), 1);
1153        // Drop the only root path; cycle should now be collected.
1154        // (Note: `a` and `b` are still on the stack as Gc<Cell0> handles, but
1155        // they're not in a Trace-visible position.)
1156        heap.full_collect(&OneRoot(None));
1157        assert_eq!(heap.bytes_used(), 0);
1158    }
1159
1160    #[test]
1161    fn heap_guard_stacks() {
1162        assert!(with_current_heap(|heap| heap.is_none()), "no guard initially");
1163        let h1 = Heap::new();
1164        h1.unpause();
1165        {
1166            let _g1 = HeapGuard::push(&h1);
1167            assert!(with_current_heap(|heap| heap.is_some()));
1168            let h2 = Heap::new();
1169            h2.unpause();
1170            {
1171                let _g2 = HeapGuard::push(&h2);
1172                // top of stack is h2
1173                with_current_heap(|heap| {
1174                    assert!(std::ptr::addr_eq(
1175                        heap.unwrap() as *const _,
1176                        &h2 as *const _,
1177                    ));
1178                });
1179            }
1180            // _g2 dropped — top is back to h1
1181            with_current_heap(|heap| {
1182                assert!(std::ptr::addr_eq(
1183                    heap.unwrap() as *const _,
1184                    &h1 as *const _,
1185                ));
1186            });
1187        }
1188        assert!(
1189            with_current_heap(|heap| heap.is_none()),
1190            "stack empty after all guards drop"
1191        );
1192    }
1193
1194    #[test]
1195    fn step_threshold_triggers_collect() {
1196        let heap = Heap::new();
1197        heap.unpause();
1198        // Allocate enough boxes to exceed the default 64KB threshold.
1199        let mut keeps = Vec::new();
1200        for _ in 0..64 {
1201            // ~1KB per box (Cell0 is small, but allocating many headers
1202            // accumulates). For the threshold test we'd typically allocate
1203            // larger objects; this is a smoke test.
1204            keeps.push(heap.allocate(Cell0 {
1205                next: Cell::new(None),
1206                marker_calls: Cell::new(0),
1207            }));
1208        }
1209        // Build roots that retain all of keeps via individual marks.
1210        struct ManyRoots<'a>(&'a [Gc<Cell0>]);
1211        impl<'a> Trace for ManyRoots<'a> {
1212            fn trace(&self, m: &mut Marker) {
1213                for g in self.0.iter() {
1214                    m.mark(*g);
1215                }
1216            }
1217        }
1218        heap.step(&ManyRoots(&keeps));
1219        // step is a no-op below threshold; all roots retained.
1220        assert!(heap.bytes_used() > 0);
1221    }
1222
1223    #[test]
1224    fn threshold_floored_after_collecting_tiny_heap() {
1225        let heap = Heap::new();
1226        heap.unpause();
1227        struct NoRoots;
1228        impl Trace for NoRoots {
1229            fn trace(&self, _m: &mut Marker) {}
1230        }
1231        for _ in 0..200 {
1232            heap.allocate(Cell0 {
1233                next: Cell::new(None),
1234                marker_calls: Cell::new(0),
1235            });
1236        }
1237        heap.full_collect(&NoRoots);
1238        assert!(
1239            heap.threshold_bytes() >= GC_MIN_THRESHOLD,
1240            "threshold {} collapsed below floor {}; a churning program would full-collect per allocation",
1241            heap.threshold_bytes(),
1242            GC_MIN_THRESHOLD
1243        );
1244    }
1245
1246    fn build_chain(heap: &Heap, len: usize) -> Gc<Cell0> {
1247        let head = heap.allocate(Cell0 {
1248            next: Cell::new(None),
1249            marker_calls: Cell::new(0),
1250        });
1251        let mut cur = head;
1252        for _ in 1..len {
1253            let n = heap.allocate(Cell0 {
1254                next: Cell::new(None),
1255                marker_calls: Cell::new(0),
1256            });
1257            cur.next.set(Some(n));
1258            cur = n;
1259        }
1260        head
1261    }
1262
1263    #[test]
1264    fn budget_zero_does_some_work() {
1265        let heap = Heap::new();
1266        heap.unpause();
1267        let head = build_chain(&heap, 8);
1268        let roots = OneRoot(Some(head));
1269        let budget = StepBudget::from_work(0);
1270        let outcome = heap.incremental_step_with_post_mark(&roots, budget, |_| {});
1271        assert_ne!(outcome, StepOutcome::SkippedStopped);
1272        assert_ne!(heap.gc_state(), GcState::Pause);
1273    }
1274
1275    #[test]
1276    fn larger_budget_drains_more_gray_than_smaller() {
1277        let small_heap = Heap::new();
1278        small_heap.unpause();
1279        let h1 = build_chain(&small_heap, 64);
1280        let r1 = OneRoot(Some(h1));
1281        let mut small_calls = 0;
1282        loop {
1283            small_calls += 1;
1284            let outcome = small_heap.incremental_step_with_post_mark(
1285                &r1,
1286                StepBudget::from_work(2),
1287                |_| {},
1288            );
1289            if outcome == StepOutcome::Paused {
1290                break;
1291            }
1292            assert!(small_calls < 10_000, "did not converge");
1293        }
1294
1295        let big_heap = Heap::new();
1296        big_heap.unpause();
1297        let h2 = build_chain(&big_heap, 64);
1298        let r2 = OneRoot(Some(h2));
1299        let mut big_calls = 0;
1300        loop {
1301            big_calls += 1;
1302            let outcome = big_heap.incremental_step_with_post_mark(
1303                &r2,
1304                StepBudget::from_work(64),
1305                |_| {},
1306            );
1307            if outcome == StepOutcome::Paused {
1308                break;
1309            }
1310            assert!(big_calls < 10_000, "did not converge");
1311        }
1312
1313        assert!(
1314            big_calls < small_calls,
1315            "expected big_calls={} < small_calls={}",
1316            big_calls,
1317            small_calls
1318        );
1319    }
1320
1321    #[test]
1322    fn sweep_can_pause_and_resume() {
1323        let heap = Heap::new();
1324        heap.unpause();
1325        for _ in 0..16 {
1326            let _ = heap.allocate(Cell0 {
1327                next: Cell::new(None),
1328                marker_calls: Cell::new(0),
1329            });
1330        }
1331        let roots = OneRoot(None);
1332        let bytes_before = heap.bytes_used();
1333        assert!(bytes_before > 0);
1334        let mut step_count = 0;
1335        let mut saw_in_progress_during_sweep = false;
1336        loop {
1337            step_count += 1;
1338            let outcome = heap.incremental_step_with_post_mark(
1339                &roots,
1340                StepBudget::from_work(2),
1341                |_| {},
1342            );
1343            if heap.gc_state() == GcState::Sweep && outcome == StepOutcome::InProgress {
1344                saw_in_progress_during_sweep = true;
1345            }
1346            if outcome == StepOutcome::Paused {
1347                break;
1348            }
1349            assert!(step_count < 10_000, "did not converge");
1350        }
1351        assert!(saw_in_progress_during_sweep, "sweep never paused mid-list");
1352        assert_eq!(heap.bytes_used(), 0);
1353    }
1354
1355    #[test]
1356    fn post_mark_runs_once_per_atomic() {
1357        let heap = Heap::new();
1358        heap.unpause();
1359        for _ in 0..32 {
1360            let _ = heap.allocate(Cell0 {
1361                next: Cell::new(None),
1362                marker_calls: Cell::new(0),
1363            });
1364        }
1365        let roots = OneRoot(None);
1366        let call_count = std::cell::Cell::new(0);
1367        let mut step_count = 0;
1368        loop {
1369            step_count += 1;
1370            let outcome = heap.incremental_step_with_post_mark(
1371                &roots,
1372                StepBudget::from_work(2),
1373                |_| {
1374                    call_count.set(call_count.get() + 1);
1375                },
1376            );
1377            if outcome == StepOutcome::Paused {
1378                break;
1379            }
1380            assert!(step_count < 10_000, "did not converge");
1381        }
1382        assert_eq!(call_count.get(), 1, "post_mark must run exactly once per cycle");
1383    }
1384
1385    #[test]
1386    fn full_collect_equivalent_to_incremental_to_pause() {
1387        let h1 = Heap::new();
1388        h1.unpause();
1389        let head1 = h1.allocate(Cell0 {
1390            next: Cell::new(None),
1391            marker_calls: Cell::new(0),
1392        });
1393        let _orphan1 = h1.allocate(Cell0 {
1394            next: Cell::new(None),
1395            marker_calls: Cell::new(0),
1396        });
1397        let _orphan2 = h1.allocate(Cell0 {
1398            next: Cell::new(None),
1399            marker_calls: Cell::new(0),
1400        });
1401        let roots1 = OneRoot(Some(head1));
1402        h1.full_collect(&roots1);
1403        let bytes_full = h1.bytes_used();
1404
1405        let h2 = Heap::new();
1406        h2.unpause();
1407        let head2 = h2.allocate(Cell0 {
1408            next: Cell::new(None),
1409            marker_calls: Cell::new(0),
1410        });
1411        let _orphan3 = h2.allocate(Cell0 {
1412            next: Cell::new(None),
1413            marker_calls: Cell::new(0),
1414        });
1415        let _orphan4 = h2.allocate(Cell0 {
1416            next: Cell::new(None),
1417            marker_calls: Cell::new(0),
1418        });
1419        let roots2 = OneRoot(Some(head2));
1420        loop {
1421            let outcome = h2.incremental_step_with_post_mark(
1422                &roots2,
1423                StepBudget::from_work(1),
1424                |_| {},
1425            );
1426            if outcome == StepOutcome::Paused {
1427                break;
1428            }
1429        }
1430        assert_eq!(h2.bytes_used(), bytes_full);
1431    }
1432}
1433
1434// ──────────────────────────────────────────────────────────────────────────────
1435// PORT STATUS
1436//   source:        production Rust heap/collector substrate
1437//   target_crate:  lua-gc
1438//   confidence:    medium
1439//   todos:         0
1440//   port_notes:    0
1441//   unsafe_blocks: 13
1442//   notes:         Mark-and-sweep collector heap + the Gc<T> raw-pointer substrate. Uses
1443//                  NonNull<GcBox<T>> with linked-list allgc walking; unsafe is
1444//                  required for raw pointer ops and Box::into_raw/from_raw. See
1445//                  unsafe-budgets.toml for the per-crate ceiling.
1446// ──────────────────────────────────────────────────────────────────────────────