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// ──────────────────────────────────────────────────────────────────────────────