Skip to main content

cljrs_gc/
lib.rs

1//! Garbage collector (default) or region-based allocator (`no-gc` feature) for clojurust.
2
3#![allow(clippy::missing_safety_doc)]
4#![allow(private_interfaces)]
5
6use std::ptr::NonNull;
7
8pub mod governor;
9pub mod mutator;
10pub mod region;
11pub mod stats;
12
13#[cfg(not(feature = "no-gc"))]
14pub mod cancellation;
15#[cfg(not(feature = "no-gc"))]
16pub mod config;
17
18#[cfg(feature = "no-gc")]
19pub mod alloc_ctx;
20#[cfg(feature = "no-gc")]
21pub mod static_arena;
22
23pub use governor::{
24    Admission, CriticalSection, IsolateAccount, IsolateControl, MemoryCharge, MemoryClass,
25    MemoryConfig, MemoryConfigError, MemoryLimitExceeded, MemorySnapshot, PressureLevel,
26    ProcessMemoryGovernor, ShedHookId, enter_critical, governor, register_current_isolate,
27    resolve_runtime_fault, with_critical,
28};
29pub use stats::{
30    CLJRS_GC_STATS_ENV, GC_STATS, GcStats, GcStatsSnapshot, dump_stats_from_env,
31    report as stats_report,
32};
33
34// ── Re-exports from active implementation ─────────────────────────────────────
35
36#[cfg(not(feature = "no-gc"))]
37pub use cancellation::{
38    MutatorGuard, StwGuard, begin_stw, check_cancellation, gc_requested, park_thread,
39    register_mutator, registered_threads, request_gc, safepoint, take_gc_request, unpark_thread,
40    wait_for_threads_to_park,
41};
42#[cfg(not(feature = "no-gc"))]
43pub use config::{GC_CANCELLATION as CONFIG_CANCELLATION, GcConfig, GcParked};
44
45#[cfg(not(feature = "no-gc"))]
46pub use gc_full::{
47    AllocRootGuard, GcHeap, HEAP, HeapProxy, push_alloc_frame, trace_thread_alloc_roots,
48};
49#[cfg(feature = "no-gc")]
50pub use nogc_stubs::{
51    AllocRootGuard, CONFIG_CANCELLATION, GcConfig, GcHeap, GcParked, HEAP, MutatorGuard, StwGuard,
52    begin_stw, check_cancellation, gc_requested, park_thread, push_alloc_frame, register_mutator,
53    registered_threads, request_gc, safepoint, take_gc_request, unpark_thread,
54    wait_for_threads_to_park,
55};
56
57/// Return `true` if `addr` was allocated by the global `StaticArena`.
58///
59/// Available only in `no-gc` debug builds.  Downstream crates (`cljrs-value`)
60/// use this to implement write-site provenance assertions.
61#[cfg(all(feature = "no-gc", debug_assertions))]
62pub fn is_static_addr(addr: usize) -> bool {
63    static_arena::is_static_addr(addr)
64}
65
66// ── Trace trait ───────────────────────────────────────────────────────────────
67
68/// Implemented by every type that can be stored behind a [`GcPtr`].
69///
70/// The `gc_size_extra` method accounts for heap bytes owned by the value that
71/// are NOT captured by `size_of::<GcBox<T>>()` (e.g. `Vec` buffers, `String`
72/// capacity, `Form` AST trees stored inline).  The default returns 0, which is
73/// correct for primitives and types with no out-of-line heap.
74///
75/// Rules for implementors of `gc_size_extra`:
76/// - Count only bytes THIS value owns and will free when dropped.
77/// - Do NOT cross `GcPtr` boundaries — each pointed-to box is counted
78///   separately when it is allocated.
79pub trait Trace {
80    fn trace(&self, visitor: &mut MarkVisitor);
81
82    fn gc_size_extra(&self) -> usize {
83        0
84    }
85}
86
87// ── Leaf Trace impls ──────────────────────────────────────────────────────────
88
89impl Trace for String {
90    fn trace(&self, _: &mut MarkVisitor) {}
91
92    fn gc_size_extra(&self) -> usize {
93        self.capacity()
94    }
95}
96impl Trace for i64 {
97    fn trace(&self, _: &mut MarkVisitor) {}
98}
99impl Trace for f64 {
100    fn trace(&self, _: &mut MarkVisitor) {}
101}
102impl Trace for bool {
103    fn trace(&self, _: &mut MarkVisitor) {}
104}
105impl Trace for num_bigint::BigInt {
106    fn trace(&self, _: &mut MarkVisitor) {}
107}
108impl Trace for bigdecimal::BigDecimal {
109    fn trace(&self, _: &mut MarkVisitor) {}
110}
111impl Trace for num_rational::Ratio<num_bigint::BigInt> {
112    fn trace(&self, _: &mut MarkVisitor) {}
113}
114macro_rules! impl_trace_prim_array {
115    ($t:ty) => {
116        impl Trace for std::sync::Mutex<Vec<$t>> {
117            fn trace(&self, _: &mut MarkVisitor) {}
118            fn gc_size_extra(&self) -> usize {
119                self.lock().unwrap().capacity() * std::mem::size_of::<$t>()
120            }
121        }
122    };
123}
124impl_trace_prim_array!(i32);
125impl_trace_prim_array!(i64);
126impl_trace_prim_array!(i16);
127impl_trace_prim_array!(i8);
128impl_trace_prim_array!(char);
129impl_trace_prim_array!(f64);
130impl_trace_prim_array!(f32);
131impl_trace_prim_array!(bool);
132
133// ── GcVisitor ─────────────────────────────────────────────────────────────────
134
135pub trait GcVisitor {
136    fn visit<T: Trace + 'static>(&mut self, ptr: &GcPtr<T>);
137}
138
139// =============================================================================
140// GC build: GcBox with header, MarkVisitor with grey stack
141// =============================================================================
142
143#[cfg(not(feature = "no-gc"))]
144pub use self::gc_header::{GcBox, GcBoxHeader};
145
146#[cfg(not(feature = "no-gc"))]
147mod gc_header {
148    use crate::{MarkVisitor, Trace};
149    use std::cell::Cell;
150
151    // Objects start at lives = GC_INITIAL_LIVES - 1.  The mark phase sets
152    // lives = GC_INITIAL_LIVES for reachable objects; sweep frees objects
153    // whose lives reach 0.  A value of 2 gives exactly one cycle of grace:
154    // enough to cover the window between an alloc frame dropping and the
155    // next GC safepoint (where VALUE_ROOTS or a new alloc frame re-roots it).
156    // 10 was chosen conservatively but keeps 9× more garbage in RAM than
157    // necessary, worsening OOM pressure under test suites with many forms.
158    pub(crate) const GC_INITIAL_LIVES: u8 = 2;
159
160    #[cfg(debug_assertions)]
161    pub(crate) const GC_MAGIC_ALIVE: u64 = 0xCAFE_BABE_DEAD_BEEF;
162    #[cfg(debug_assertions)]
163    pub(crate) const GC_MAGIC_FREED: u64 = 0xDEAD_DEAD_DEAD_DEAD;
164
165    #[repr(C)]
166    pub struct GcBoxHeader {
167        #[cfg(debug_assertions)]
168        pub(crate) magic: Cell<u64>,
169        /// Exact size of the GcBox<T> allocation in bytes.
170        pub(crate) size: usize,
171        pub(crate) lives: Cell<u8>,
172        pub(crate) next: Cell<*mut GcBoxHeader>,
173        pub(crate) trace_fn: unsafe fn(*const GcBoxHeader, &mut MarkVisitor),
174        pub(crate) drop_fn: unsafe fn(*mut GcBoxHeader),
175    }
176
177    impl GcBoxHeader {
178        pub(crate) fn new<T: Trace + 'static>(heap_extra: usize) -> Self {
179            Self {
180                #[cfg(debug_assertions)]
181                magic: Cell::new(GC_MAGIC_ALIVE),
182                size: std::mem::size_of::<GcBox<T>>() + heap_extra,
183                lives: Cell::new(GC_INITIAL_LIVES - 1),
184                next: Cell::new(std::ptr::null_mut()),
185                trace_fn: trace_gc_box::<T>,
186                drop_fn: drop_gc_box::<T>,
187            }
188        }
189    }
190
191    unsafe impl Send for GcBoxHeader {}
192    unsafe impl Sync for GcBoxHeader {}
193
194    #[repr(C)]
195    pub struct GcBox<T: Trace + 'static> {
196        pub(crate) header: GcBoxHeader,
197        pub value: T,
198    }
199
200    pub(crate) unsafe fn trace_gc_box<T: Trace + 'static>(
201        header: *const GcBoxHeader,
202        visitor: &mut MarkVisitor,
203    ) {
204        unsafe {
205            let gc_box = header as *const GcBox<T>;
206            (*gc_box).value.trace(visitor);
207        }
208    }
209
210    pub(crate) unsafe fn drop_gc_box<T: Trace + 'static>(header: *mut GcBoxHeader) {
211        unsafe {
212            #[cfg(debug_assertions)]
213            {
214                (*header).magic.set(GC_MAGIC_FREED);
215            }
216            let gc_box = header as *mut GcBox<T>;
217            drop(Box::from_raw(gc_box));
218        }
219    }
220}
221
222// =============================================================================
223// no-gc build: GcBox without header
224// =============================================================================
225
226#[cfg(feature = "no-gc")]
227pub use self::nogc_box::GcBox;
228
229#[cfg(feature = "no-gc")]
230mod nogc_box {
231    use crate::Trace;
232
233    pub struct GcBox<T: Trace + 'static> {
234        pub value: T,
235    }
236}
237
238// =============================================================================
239// MarkVisitor: full under GC, stub under no-gc
240// =============================================================================
241
242#[cfg(not(feature = "no-gc"))]
243#[derive(Default)]
244pub struct MarkVisitor {
245    pub(crate) grey: Vec<*mut GcBoxHeader>,
246}
247
248#[cfg(feature = "no-gc")]
249pub struct MarkVisitor;
250
251#[cfg(not(feature = "no-gc"))]
252impl MarkVisitor {
253    pub fn new() -> Self {
254        Self::default()
255    }
256
257    pub fn grey_len(&self) -> usize {
258        self.grey.len()
259    }
260
261    pub unsafe fn mark_header(&mut self, header: *mut GcBoxHeader) {
262        use gc_header::GC_INITIAL_LIVES;
263        let h = unsafe { &*header };
264        if h.lives.get() < GC_INITIAL_LIVES {
265            h.lives.set(GC_INITIAL_LIVES);
266            self.grey.push(header);
267        }
268    }
269
270    pub(crate) fn drain(&mut self) {
271        let mut visited = 0usize;
272        while let Some(header) = self.grey.pop() {
273            visited += 1;
274            let h = unsafe { &*header };
275            unsafe { (h.trace_fn)(header as *const GcBoxHeader, self) };
276        }
277        tracing::debug!(target: "gc", "drain visited {} objects", visited);
278    }
279}
280
281#[cfg(not(feature = "no-gc"))]
282impl GcVisitor for MarkVisitor {
283    fn visit<T: Trace + 'static>(&mut self, ptr: &GcPtr<T>) {
284        use gc_header::GC_INITIAL_LIVES;
285        // Region-allocated objects are not on the GC heap; their lifetime is
286        // bounded by the enclosing `Region`, not the collector.  Never
287        // dereference one here: once the region's scope ends its memory is
288        // freed/reused, so the header would be garbage and we'd follow a
289        // dangling `trace_fn`.  Live regions are traced as roots separately
290        // (see `region::trace_active_regions`), so a region's heap-allocated
291        // children are still kept alive.
292        if ptr.is_region_alloc() {
293            return;
294        }
295        let raw = ptr.raw();
296        let header = unsafe { &(*raw).header };
297        if header.lives.get() < GC_INITIAL_LIVES {
298            header.lives.set(GC_INITIAL_LIVES);
299            self.grey.push(raw as *mut GcBoxHeader);
300        }
301    }
302}
303
304#[cfg(feature = "no-gc")]
305impl MarkVisitor {
306    pub fn grey_len(&self) -> usize {
307        0
308    }
309    pub unsafe fn mark_header(&mut self, _: *mut u8) {}
310}
311
312#[cfg(feature = "no-gc")]
313impl GcVisitor for MarkVisitor {
314    fn visit<T: Trace + 'static>(&mut self, _: &GcPtr<T>) {}
315}
316
317// =============================================================================
318// GcPtr — always present
319// =============================================================================
320
321pub struct GcPtr<T: Trace + 'static>(NonNull<GcBox<T>>);
322
323/// Low pointer bit reserved to mark region-allocated `GcPtr`s in GC builds.
324///
325/// `GcBox<T>` (a `GcBoxHeader` followed by the value) is always ≥8-byte
326/// aligned in GC builds, so bit 0 is free.  Region-allocated pointers set it;
327/// GC-heap pointers leave it clear.  This lets the mark phase distinguish a
328/// region object from a heap object **without dereferencing it** — essential,
329/// because a region whose scope has ended leaves dangling pointers whose
330/// headers point at freed (or reused) memory.
331#[cfg(not(feature = "no-gc"))]
332pub(crate) const REGION_PTR_TAG: usize = 1;
333
334/// A governed allocation the process governor refused.  Returns ownership of
335/// the value, so dropping the error releases whatever memory it owns.
336pub struct AllocError<T> {
337    value: T,
338    error: MemoryLimitExceeded,
339}
340
341impl<T> AllocError<T> {
342    /// The refusal.
343    pub fn error(&self) -> MemoryLimitExceeded {
344        self.error
345    }
346
347    /// Take back the value that was not allocated.
348    pub fn into_inner(self) -> T {
349        self.value
350    }
351
352    /// Split into the value and the refusal.
353    pub fn into_parts(self) -> (T, MemoryLimitExceeded) {
354        (self.value, self.error)
355    }
356}
357
358impl<T> std::fmt::Debug for AllocError<T> {
359    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
360        f.debug_struct("AllocError")
361            .field("error", &self.error)
362            .finish_non_exhaustive()
363    }
364}
365
366impl<T> std::fmt::Display for AllocError<T> {
367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368        self.error.fmt(f)
369    }
370}
371
372impl<T> std::error::Error for AllocError<T> {}
373
374impl<T> From<AllocError<T>> for MemoryLimitExceeded {
375    fn from(e: AllocError<T>) -> Self {
376        e.error
377    }
378}
379
380impl<T: Trace + 'static> GcPtr<T> {
381    /// Allocate `value`.  Infallible: in strict mode a refused allocation
382    /// uses the critical reserve and raises the thread's runtime fault,
383    /// which the next evaluation safepoint resolves (see
384    /// [`governor`](crate::governor) and [`Self::try_new`]).
385    #[cfg(not(feature = "no-gc"))]
386    pub fn new(value: T) -> Self {
387        gc_full::HEAP.alloc(value)
388    }
389
390    #[cfg(feature = "no-gc")]
391    pub fn new(value: T) -> Self {
392        alloc_ctx::alloc_in_ctx(value)
393    }
394
395    /// Allocate `value`, or return it with the refusal if the process
396    /// governor cannot admit its bytes.  The governor is consulted before
397    /// the object exists; a refusal changes no counter.  Observe-only mode
398    /// never refuses.
399    ///
400    /// In `no-gc` builds the governor is asked only when the active region
401    /// or the static arena must add a chunk; an object that fits in memory
402    /// already charged is never refused.
403    pub fn try_new(value: T) -> Result<Self, AllocError<T>> {
404        #[cfg(not(feature = "no-gc"))]
405        {
406            let bytes = std::mem::size_of::<GcBox<T>>() + value.gc_size_extra();
407            match governor::try_reserve_current(bytes) {
408                Ok(()) => Ok(Self::new(value)),
409                Err(error) => Err(AllocError { value, error }),
410            }
411        }
412        #[cfg(feature = "no-gc")]
413        {
414            alloc_ctx::try_alloc_in_ctx(value)
415        }
416    }
417
418    /// Allocate `value` for runtime control work (error reporting, shutdown):
419    /// the allocation may use the critical reserve and never raises a
420    /// runtime fault.
421    pub fn new_critical(value: T) -> Self {
422        let _section = governor::enter_critical();
423        Self::new(value)
424    }
425
426    /// The untagged `GcBox<T>` address.  In GC builds this masks off the
427    /// region-provenance tag bit; in no-gc builds pointers are never tagged.
428    #[inline]
429    fn raw(&self) -> *mut GcBox<T> {
430        #[cfg(not(feature = "no-gc"))]
431        {
432            (self.0.as_ptr() as usize & !REGION_PTR_TAG) as *mut GcBox<T>
433        }
434        #[cfg(feature = "no-gc")]
435        {
436            self.0.as_ptr()
437        }
438    }
439
440    /// `true` if this pointer was bump-allocated in a [`region::Region`]
441    /// rather than the GC heap.  Region objects are not GC-managed.
442    #[cfg(not(feature = "no-gc"))]
443    #[inline]
444    pub fn is_region_alloc(&self) -> bool {
445        (self.0.as_ptr() as usize & REGION_PTR_TAG) != 0
446    }
447
448    /// Construct a region-tagged pointer from a raw `GcBox<T>` allocated in a
449    /// bump region.
450    ///
451    /// # Safety
452    /// `raw` must be a valid, non-null, ≥8-aligned `GcBox<T>` whose header was
453    /// initialised by [`region::Region::alloc`].
454    #[cfg(not(feature = "no-gc"))]
455    #[inline]
456    pub(crate) unsafe fn from_region_raw(raw: *mut GcBox<T>) -> Self {
457        GcPtr(unsafe { NonNull::new_unchecked((raw as usize | REGION_PTR_TAG) as *mut GcBox<T>) })
458    }
459
460    pub fn get(&self) -> &T {
461        #[cfg(all(debug_assertions, not(feature = "no-gc")))]
462        {
463            use gc_header::GC_MAGIC_ALIVE;
464            let header = unsafe { &(*self.raw()).header };
465            assert_eq!(
466                header.magic.get(),
467                GC_MAGIC_ALIVE,
468                "GcPtr::get() on freed object! magic={:#x}",
469                header.magic.get(),
470            );
471        }
472        unsafe { &(*self.raw()).value }
473    }
474
475    pub fn get_mut(&mut self) -> &mut T {
476        #[cfg(all(debug_assertions, not(feature = "no-gc")))]
477        {
478            use gc_header::GC_MAGIC_ALIVE;
479            let header = unsafe { &(*self.raw()).header };
480            assert_eq!(
481                header.magic.get(),
482                GC_MAGIC_ALIVE,
483                "GcPtr::get_mut() on freed object! magic={:#x}",
484                header.magic.get(),
485            );
486        }
487        unsafe { &mut (*self.raw()).value }
488    }
489
490    pub fn ptr_eq(a: &Self, b: &Self) -> bool {
491        a.0 == b.0
492    }
493
494    /// Return `true` if this pointer was allocated by the global `StaticArena`.
495    ///
496    /// Only meaningful (and only compiled) in `no-gc` debug builds.  Used by
497    /// write-site assertions in `Atom::reset` / `Var::bind` to catch
498    /// region-local values being stored in program-lifetime containers.
499    #[cfg(all(feature = "no-gc", debug_assertions))]
500    pub fn is_static_alloc(&self) -> bool {
501        alloc_ctx::invocation_is_active() || static_arena::is_static_addr(self.0.as_ptr() as usize)
502    }
503}
504
505impl<T: Trace + 'static> Clone for GcPtr<T> {
506    fn clone(&self) -> Self {
507        GcPtr(self.0)
508    }
509}
510
511impl<T: Trace + 'static + std::fmt::Debug> std::fmt::Debug for GcPtr<T> {
512    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
513        unsafe { (*self.raw()).value.fmt(f) }
514    }
515}
516
517impl<T: Trace + 'static> Drop for GcPtr<T> {
518    fn drop(&mut self) {}
519}
520
521// =============================================================================
522// StaticGcPtr — Send+Sync pointer to program-lifetime data
523// =============================================================================
524
525/// A raw pointer to a value that lives for the entire program lifetime.
526///
527/// Backed by the global `StaticArena` (in `no-gc` builds) or by `Box::leak`
528/// (in GC builds).  Either way the pointee is never freed and never moved, so
529/// it is safe to share across isolate threads.
530///
531/// `StaticGcPtr<T>` wraps `*const T` — it does **not** involve a `GcBox`
532/// header — so it is independent of the GC build mode and carries no GC
533/// overhead.
534pub struct StaticGcPtr<T: 'static>(NonNull<T>);
535
536// SAFETY: program-lifetime allocations are never moved, freed, or mutated
537// after the initial write.  The stored types (Keyword, Symbol, …) are
538// themselves `Sync` (no unsynchronised interior mutability).
539unsafe impl<T: 'static> Send for StaticGcPtr<T> {}
540unsafe impl<T: 'static> Sync for StaticGcPtr<T> {}
541
542impl<T: 'static> StaticGcPtr<T> {
543    /// Borrow the contained value.
544    pub fn get(&self) -> &T {
545        // SAFETY: pointer is program-lifetime, always valid.
546        unsafe { self.0.as_ref() }
547    }
548
549    /// Pointer equality: `true` iff both `StaticGcPtr`s point to the exact
550    /// same allocation (i.e. the same interned entry).
551    pub fn ptr_eq(a: &Self, b: &Self) -> bool {
552        a.0 == b.0
553    }
554}
555
556impl<T: 'static> Clone for StaticGcPtr<T> {
557    fn clone(&self) -> Self {
558        StaticGcPtr(self.0)
559    }
560}
561
562impl<T: 'static + std::fmt::Debug> std::fmt::Debug for StaticGcPtr<T> {
563    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
564        unsafe { self.0.as_ref().fmt(f) }
565    }
566}
567
568/// Allocate `value` as program-lifetime memory and return a [`StaticGcPtr`].
569///
570/// In `no-gc` builds the allocation comes from the global bump-allocated
571/// `StaticArena` (never freed, no GC header overhead).  In GC builds
572/// `Box::leak` is used instead — the memory lives until the process exits.
573/// Either way the governor records the bytes under `MemoryClass::Static`
574/// (arena chunks in `no-gc` builds; `size_of::<T>()` in GC builds).
575pub fn static_alloc<T: 'static>(value: T) -> StaticGcPtr<T> {
576    static_alloc_inner(value)
577}
578
579/// Fallible [`static_alloc`]: return the value with the refusal if the
580/// governor cannot admit it.  In `no-gc` builds the governor is asked only
581/// when the arena must add a chunk.
582pub fn try_static_alloc<T: 'static>(value: T) -> Result<StaticGcPtr<T>, AllocError<T>> {
583    #[cfg(not(feature = "no-gc"))]
584    {
585        match governor::governor()
586            .try_charge_permanent(governor::MemoryClass::Static, std::mem::size_of::<T>())
587        {
588            Ok(()) => Ok(StaticGcPtr(NonNull::from(Box::leak(Box::new(value))))),
589            Err(error) => Err(AllocError { value, error }),
590        }
591    }
592    #[cfg(feature = "no-gc")]
593    {
594        static_arena::static_arena().try_alloc_val(value)
595    }
596}
597
598fn static_alloc_inner<T: 'static>(value: T) -> StaticGcPtr<T> {
599    #[cfg(feature = "no-gc")]
600    {
601        static_arena::static_alloc_val(value)
602    }
603    #[cfg(not(feature = "no-gc"))]
604    {
605        governor::governor()
606            .charge_permanent(governor::MemoryClass::Static, std::mem::size_of::<T>());
607        StaticGcPtr(NonNull::from(Box::leak(Box::new(value))))
608    }
609}
610
611// =============================================================================
612// Full GC implementation (default build)
613// =============================================================================
614
615#[cfg(not(feature = "no-gc"))]
616mod gc_full {
617    use std::cell::RefCell;
618    use std::ptr::NonNull;
619    use std::sync::atomic::{AtomicUsize, Ordering};
620    use std::sync::{Arc, Mutex};
621
622    use crate::config::GcConfig;
623    use crate::gc_header::GC_INITIAL_LIVES;
624    use crate::{GcBox, GcBoxHeader, GcPtr, MarkVisitor, Trace};
625
626    type RootTracer = Box<dyn Fn(&mut MarkVisitor)>;
627
628    pub struct GcHeap {
629        inner: Mutex<GcHeapInner>,
630        config: Mutex<Option<Arc<GcConfig>>>,
631        memory_in_use: AtomicUsize,
632        total_allocated_bytes: AtomicUsize,
633        root_tracers: Mutex<Vec<RootTracer>>,
634        gc_suppressed: std::sync::atomic::AtomicBool,
635        /// memory_in_use threshold above which GC is re-enabled after a
636        /// zero-yield collection.  The headroom doubles on each consecutive
637        /// zero-yield cycle (exponential backoff, capped at soft_limit) so a
638        /// long computation where all objects are live doesn't spin in a
639        /// constant GC storm of O(N) sweeps.  Resets to the base headroom
640        /// (soft_limit / 10) once GC actually frees something.
641        suppressed_threshold: AtomicUsize,
642        /// Current headroom used for exponential backoff after zero-yield cycles.
643        zero_yield_headroom: AtomicUsize,
644    }
645
646    struct GcHeapInner {
647        head: *mut GcBoxHeader,
648        count: usize,
649        total_allocated: usize,
650        total_freed: usize,
651    }
652
653    unsafe impl Send for GcHeapInner {}
654
655    impl GcHeapInner {
656        const fn new() -> Self {
657            Self {
658                head: std::ptr::null_mut(),
659                count: 0,
660                total_allocated: 0,
661                total_freed: 0,
662            }
663        }
664    }
665
666    unsafe impl Sync for GcHeap {}
667
668    impl Default for GcHeap {
669        fn default() -> Self {
670            Self::new()
671        }
672    }
673
674    /// Parse a megabyte limit from a (raw) environment value into a byte count,
675    /// falling back to `default` (and warning) on malformed input rather than
676    /// panicking on user misconfiguration. `value` is `None` when the variable
677    /// is unset. Saturates instead of overflowing on absurdly large values.
678    pub(crate) fn parse_limit_mb(var: &str, value: Option<&str>, default: usize) -> usize {
679        match value {
680            Some(s) => match s.trim().parse::<usize>() {
681                Ok(mb) => mb.saturating_mul(1024 * 1024),
682                Err(_) => {
683                    eprintln!(
684                        "[gc] warning: ignoring invalid {var}={s:?} (expected a number of megabytes)"
685                    );
686                    default
687                }
688            },
689            None => default,
690        }
691    }
692
693    impl GcHeap {
694        pub const fn new() -> Self {
695            Self {
696                inner: Mutex::new(GcHeapInner::new()),
697                config: Mutex::new(None),
698                memory_in_use: AtomicUsize::new(0),
699                total_allocated_bytes: AtomicUsize::new(0),
700                root_tracers: Mutex::new(Vec::new()),
701                gc_suppressed: std::sync::atomic::AtomicBool::new(false),
702                suppressed_threshold: AtomicUsize::new(0),
703                zero_yield_headroom: AtomicUsize::new(0),
704            }
705        }
706
707        pub fn set_config(&self, config: Arc<GcConfig>) {
708            *self.config.lock().unwrap() = Some(config);
709        }
710
711        pub fn set_config_from_env(&self) {
712            #[cfg(not(target_arch = "wasm32"))]
713            let default_soft_limit: usize = (system_memory::total() / 3) as usize;
714            #[cfg(target_arch = "wasm32")]
715            let default_soft_limit: usize = 64 * 1024 * 1024;
716
717            let soft_limit_mb = parse_limit_mb(
718                "CLJRS_GC_SOFT_LIMIT_MB",
719                std::env::var("CLJRS_GC_SOFT_LIMIT_MB").ok().as_deref(),
720                default_soft_limit,
721            );
722            let mut hard_limit_mb = parse_limit_mb(
723                "CLJRS_GC_HARD_LIMIT_MB",
724                std::env::var("CLJRS_GC_HARD_LIMIT_MB").ok().as_deref(),
725                soft_limit_mb,
726            );
727            if hard_limit_mb < soft_limit_mb {
728                eprintln!(
729                    "[gc] warning: CLJRS_GC_HARD_LIMIT_MB is below CLJRS_GC_SOFT_LIMIT_MB; using the soft limit for both"
730                );
731                hard_limit_mb = soft_limit_mb;
732            }
733            self.set_config(Arc::new(GcConfig::with_limits(
734                soft_limit_mb,
735                hard_limit_mb,
736            )));
737        }
738
739        pub fn register_root_tracer(&self, tracer: impl Fn(&mut MarkVisitor) + 'static) {
740            self.root_tracers.lock().unwrap().push(Box::new(tracer));
741        }
742
743        pub fn trace_registered_roots(&self, visitor: &mut MarkVisitor) {
744            let tracers = self.root_tracers.lock().unwrap();
745            for tracer in tracers.iter() {
746                tracer(visitor);
747            }
748        }
749
750        pub fn memory_in_use(&self) -> usize {
751            self.memory_in_use.load(Ordering::Relaxed)
752        }
753
754        #[cfg(test)]
755        pub fn set_memory_in_use(&self, bytes: usize) {
756            self.memory_in_use.store(bytes, Ordering::Relaxed);
757        }
758
759        pub fn alloc<T: Trace + 'static>(&self, value: T) -> GcPtr<T> {
760            crate::cancellation::safepoint();
761            let heap_extra = value.gc_size_extra();
762            let gc_box = Box::new(GcBox {
763                header: GcBoxHeader::new::<T>(heap_extra),
764                value,
765            });
766            let obj_size = gc_box.header.size; // GcBox<T> size + gc_size_extra()
767            let raw: *mut GcBox<T> = Box::into_raw(gc_box);
768            {
769                let mut inner = self.inner.lock().unwrap();
770                unsafe {
771                    (*raw).header.next.set(inner.head);
772                    inner.head = raw as *mut GcBoxHeader;
773                }
774                inner.count += 1;
775                inner.total_allocated += 1;
776            }
777            self.total_allocated_bytes
778                .fetch_add(obj_size, Ordering::Relaxed);
779            // The account batches the allocation statistics; record them
780            // directly only during thread-local teardown.
781            if crate::governor::with_current_account(|a| a.charge(obj_size)).is_none() {
782                crate::stats::GC_STATS.record_gc_alloc(obj_size);
783            }
784            let current_usage =
785                self.memory_in_use.fetch_add(obj_size, Ordering::Relaxed) + obj_size;
786
787            if let Some(config) = self.config.lock().unwrap().as_ref()
788                && config.soft_limit_exceeded(current_usage)
789            {
790                if self.gc_suppressed.load(Ordering::Relaxed) {
791                    // Suppression active: only re-enable GC once memory has
792                    // grown past the threshold set by the last zero-yield
793                    // collection (current_memory + soft_limit/10).
794                    let threshold = self.suppressed_threshold.load(Ordering::Relaxed);
795                    if current_usage > threshold {
796                        self.gc_suppressed.store(false, Ordering::Relaxed);
797                        crate::cancellation::request_gc();
798                    }
799                } else {
800                    crate::cancellation::request_gc();
801                }
802            }
803
804            register_alloc(raw as *mut GcBoxHeader);
805            GcPtr(unsafe { NonNull::new_unchecked(raw) })
806        }
807
808        pub fn collect<F: FnOnce(&mut MarkVisitor)>(&self, trace_roots: F) {
809            let pre_count = self.inner.lock().unwrap().count;
810            let pre_memory = self.memory_in_use.load(Ordering::Relaxed);
811            tracing::debug!(
812                target: "gc",
813                "starting collection: {} objects, ~{} bytes in use",
814                pre_count,
815                pre_memory
816            );
817            let mark_start = std::time::Instant::now();
818            let mut visitor = MarkVisitor::new();
819            trace_roots(&mut visitor);
820            crate::mutator::trace_suspended(&mut visitor);
821            // Active bump regions are additional roots: a live region object
822            // may hold `GcPtr`s into the heap, and those heap objects must not
823            // be collected while the region can still reach them.  The mark
824            // phase skips region objects themselves (they are not heap-managed),
825            // so we trace their children here instead.
826            crate::region::trace_active_regions(&mut visitor);
827            // Retired (poisoned) regions are immortal roots: their objects may
828            // still be referenced and may hold `GcPtr`s into the heap.
829            crate::region::trace_retired_regions(&mut visitor);
830            tracing::debug!(
831                target: "gc",
832                "starting drain with {} grey objects",
833                visitor.grey.len()
834            );
835            visitor.drain();
836            let mark_elapsed = mark_start.elapsed();
837
838            let sweep_start = std::time::Instant::now();
839            let mut inner = self.inner.lock().unwrap();
840            let mut live: Vec<*mut GcBoxHeader> = Vec::with_capacity(inner.count);
841            let mut dead: Vec<*mut GcBoxHeader> = Vec::new();
842            // Bytes of objects with lives==0 that will be freed now.
843            let mut freed_bytes: usize = 0;
844            // Bytes of unreachable objects whose grace period ends with this
845            // cycle: the next collection frees them unless they are re-rooted.
846            let mut pending_bytes: usize = 0;
847            let mut current = inner.head;
848            while !current.is_null() {
849                let header = unsafe { &*current };
850                let next = header.next.get();
851                let lives = header.lives.get();
852                let obj_size = header.size;
853                if lives >= GC_INITIAL_LIVES {
854                    // Marked reachable this cycle — reset grace counter.
855                    header.lives.set(GC_INITIAL_LIVES - 1);
856                    live.push(current);
857                } else if lives > 0 {
858                    // In grace period (unreachable but not yet freed).
859                    header.lives.set(lives - 1);
860                    if lives == 1 {
861                        pending_bytes += obj_size;
862                    }
863                    live.push(current);
864                } else {
865                    // Grace period exhausted — collect now.
866                    freed_bytes += obj_size;
867                    dead.push(current);
868                }
869                current = next;
870            }
871            let freed_count = dead.len();
872            for ptr in dead {
873                let header = unsafe { &*ptr };
874                unsafe { (header.drop_fn)(ptr) };
875                inner.count -= 1;
876                inner.total_freed += 1;
877            }
878            inner.head = std::ptr::null_mut();
879            for ptr in live {
880                let header = unsafe { &*ptr };
881                header.next.set(inner.head);
882                inner.head = ptr;
883            }
884            // Decrement memory_in_use by the bytes actually freed.  All heap
885            // objects (live + grace-period) remain counted; only physically
886            // freed objects are subtracted.  This keeps memory pressure
887            // accurate so GC fires again when the heap genuinely grows.
888            self.memory_in_use.fetch_sub(freed_bytes, Ordering::Relaxed);
889            let sweep_elapsed = sweep_start.elapsed();
890            let post_memory = self.memory_in_use.load(Ordering::Relaxed);
891            crate::governor::with_current_account(|a| {
892                a.release(freed_bytes);
893                a.record_collection(crate::governor::CollectionReport {
894                    bytes_before: pre_memory,
895                    bytes_after: post_memory,
896                    bytes_returned: freed_bytes,
897                    bytes_pending: pending_bytes,
898                    duration: mark_elapsed + sweep_elapsed,
899                });
900            });
901            crate::stats::GC_STATS.record_gc_pause(
902                mark_elapsed + sweep_elapsed,
903                freed_count as u64,
904                freed_bytes as u64,
905            );
906            tracing::debug!(
907                target: "gc",
908                "collection complete: freed {} (~{} bytes), {} remaining (~{} bytes, ~{} pending), mark={:.2?} sweep={:.2?}",
909                freed_count,
910                freed_bytes,
911                inner.count,
912                post_memory,
913                pending_bytes,
914                mark_elapsed,
915                sweep_elapsed
916            );
917            if freed_count == 0 && pending_bytes == 0 {
918                // Zero-yield collection: exponential-backoff suppression.
919                // Objects that only entered their grace period this cycle do
920                // not count as zero yield: the next collection frees them, and
921                // suppressing it would keep that garbage until the heap grows
922                // by another headroom.
923                // Each consecutive zero-yield cycle doubles the headroom before
924                // the next GC attempt (capped at soft_limit/4).  This prevents a
925                // GC storm during deep recursion where all objects are live —
926                // without backoff, GC fires every soft_limit/10 bytes, tracing
927                // the entire live set O(N) times to no benefit.
928                // The headroom resets to soft_limit/10 when GC frees something.
929                // Cap at soft_limit/4 (not soft_limit) so that GC still fires
930                // frequently enough to catch short-lived test allocations after
931                // a long namespace-loading phase of zero-yield cycles.
932                let soft_limit = self
933                    .config
934                    .lock()
935                    .unwrap()
936                    .as_ref()
937                    .map(|c| c.soft_limit())
938                    .unwrap_or(64 * 1024 * 1024);
939                let base_headroom = (soft_limit / 10).max(1);
940                let max_headroom = (soft_limit / 4).max(base_headroom);
941                let prev_headroom = self.zero_yield_headroom.load(Ordering::Relaxed);
942                let headroom = if prev_headroom == 0 {
943                    base_headroom
944                } else {
945                    prev_headroom.saturating_mul(2).min(max_headroom)
946                };
947                self.zero_yield_headroom.store(headroom, Ordering::Relaxed);
948                self.suppressed_threshold
949                    .store(post_memory + headroom, Ordering::Relaxed);
950                self.gc_suppressed.store(true, Ordering::Relaxed);
951            } else {
952                // GC freed something: reset exponential backoff.
953                self.zero_yield_headroom.store(0, Ordering::Relaxed);
954                self.gc_suppressed.store(false, Ordering::Relaxed);
955            }
956        }
957
958        pub fn count(&self) -> usize {
959            self.inner.lock().unwrap().count
960        }
961        pub fn total_allocated(&self) -> usize {
962            self.inner.lock().unwrap().total_allocated
963        }
964        pub fn total_freed(&self) -> usize {
965            self.inner.lock().unwrap().total_freed
966        }
967
968        pub fn collect_auto(&self) -> bool {
969            tracing::debug!(target: "gc", "automatic collection requested");
970            let Some(_stw_guard) = crate::cancellation::begin_stw() else {
971                tracing::debug!(target: "gc", "automatic collection skipped");
972                return false;
973            };
974            self.collect(|visitor| self.trace_registered_roots(visitor));
975            true
976        }
977    }
978
979    thread_local! {
980        static ISOLATE_HEAP: GcHeap = const { GcHeap::new() };
981    }
982
983    /// Zero-sized proxy that dispatches all heap operations to the calling
984    /// thread's [`GcHeap`] via the `ISOLATE_HEAP` thread-local.
985    ///
986    /// This means every isolate (OS thread) owns an independent heap; GC runs
987    /// fully in parallel on different threads with no cross-isolate coordination.
988    pub struct HeapProxy;
989
990    impl HeapProxy {
991        pub fn alloc<T: Trace + 'static>(&self, value: T) -> GcPtr<T> {
992            ISOLATE_HEAP.with(|h| h.alloc(value))
993        }
994
995        pub fn set_config(&self, config: Arc<GcConfig>) {
996            ISOLATE_HEAP.with(|h| h.set_config(config));
997        }
998
999        pub fn set_config_from_env(&self) {
1000            ISOLATE_HEAP.with(|h| h.set_config_from_env());
1001        }
1002
1003        pub fn register_root_tracer(&self, tracer: impl Fn(&mut MarkVisitor) + 'static) {
1004            ISOLATE_HEAP.with(|h| h.register_root_tracer(tracer));
1005        }
1006
1007        pub fn trace_registered_roots(&self, visitor: &mut MarkVisitor) {
1008            ISOLATE_HEAP.with(|h| h.trace_registered_roots(visitor));
1009        }
1010
1011        pub fn memory_in_use(&self) -> usize {
1012            ISOLATE_HEAP.with(|h| h.memory_in_use())
1013        }
1014
1015        pub fn count(&self) -> usize {
1016            ISOLATE_HEAP.with(|h| h.count())
1017        }
1018
1019        pub fn total_allocated(&self) -> usize {
1020            ISOLATE_HEAP.with(|h| h.total_allocated())
1021        }
1022
1023        pub fn total_freed(&self) -> usize {
1024            ISOLATE_HEAP.with(|h| h.total_freed())
1025        }
1026
1027        pub fn collect<F: FnOnce(&mut MarkVisitor)>(&self, trace_roots: F) {
1028            ISOLATE_HEAP.with(|h| h.collect(trace_roots));
1029        }
1030
1031        pub fn collect_auto(&self) -> bool {
1032            ISOLATE_HEAP.with(|h| h.collect_auto())
1033        }
1034
1035        #[cfg(test)]
1036        pub fn set_memory_in_use(&self, bytes: usize) {
1037            ISOLATE_HEAP.with(|h| h.set_memory_in_use(bytes));
1038        }
1039    }
1040
1041    // SAFETY: HeapProxy is zero-sized; all state lives in a thread-local GcHeap.
1042    // The Send + Sync impls are needed so `pub static HEAP: HeapProxy` is valid.
1043    unsafe impl Sync for HeapProxy {}
1044
1045    pub static HEAP: HeapProxy = HeapProxy;
1046
1047    thread_local! {
1048        pub(crate) static ALLOC_ROOTS: RefCell<Vec<*mut GcBoxHeader>> = const { RefCell::new(Vec::new()) };
1049    }
1050    crate::mutator_slot!("cljrs-gc/ALLOC_ROOTS", refcell ALLOC_ROOTS: Vec<*mut GcBoxHeader>, trace trace_saved_alloc_roots);
1051
1052    fn trace_saved_alloc_roots(roots: &Vec<*mut GcBoxHeader>, visitor: &mut MarkVisitor) {
1053        for &header in roots {
1054            // SAFETY: the detached mutator owns the still-live allocation frame.
1055            unsafe { visitor.mark_header(header) };
1056        }
1057    }
1058
1059    pub struct AllocRootGuard {
1060        saved_len: usize,
1061    }
1062
1063    impl AllocRootGuard {
1064        /// Unroot every allocation made since this frame was pushed, keeping
1065        /// the frame open for further allocations.  Equivalent to dropping
1066        /// the guard and pushing a new frame at the same depth.
1067        pub fn release(&self) {
1068            ALLOC_ROOTS.with(|roots| roots.borrow_mut().truncate(self.saved_len));
1069        }
1070    }
1071
1072    impl Drop for AllocRootGuard {
1073        fn drop(&mut self) {
1074            self.release();
1075        }
1076    }
1077
1078    pub fn push_alloc_frame() -> AllocRootGuard {
1079        let saved_len = ALLOC_ROOTS.with(|roots| roots.borrow().len());
1080        AllocRootGuard { saved_len }
1081    }
1082
1083    fn register_alloc(header: *mut GcBoxHeader) {
1084        ALLOC_ROOTS.with(|roots| roots.borrow_mut().push(header));
1085    }
1086
1087    pub fn trace_thread_alloc_roots(visitor: &mut MarkVisitor) {
1088        ALLOC_ROOTS.with(|roots| {
1089            let roots = roots.borrow();
1090            for &header in roots.iter() {
1091                unsafe { visitor.mark_header(header) };
1092            }
1093        });
1094    }
1095}
1096
1097// =============================================================================
1098// no-gc stubs
1099// =============================================================================
1100
1101#[cfg(feature = "no-gc")]
1102mod nogc_stubs {
1103    use crate::MarkVisitor;
1104    use std::sync::Arc;
1105
1106    #[derive(Debug, Clone)]
1107    pub struct GcConfig;
1108    impl GcConfig {
1109        pub fn new() -> Self {
1110            Self
1111        }
1112        pub fn with_hard_limit(_: usize) -> Self {
1113            Self
1114        }
1115        pub fn with_limits(_: usize, _: usize) -> Self {
1116            Self
1117        }
1118        pub fn with_soft_limit(_: usize) -> Self {
1119            Self
1120        }
1121        pub fn try_with_limits(
1122            soft_limit: usize,
1123            hard_limit: usize,
1124        ) -> Result<Self, crate::governor::MemoryConfigError> {
1125            crate::governor::MemoryConfig::with_limits(soft_limit, hard_limit).map(|_| Self)
1126        }
1127        pub fn validate(&self) -> Result<(), crate::governor::MemoryConfigError> {
1128            Ok(())
1129        }
1130    }
1131    impl Default for GcConfig {
1132        fn default() -> Self {
1133            Self::new()
1134        }
1135    }
1136
1137    pub struct GcHeap;
1138    impl Default for GcHeap {
1139        fn default() -> Self {
1140            Self::new()
1141        }
1142    }
1143    impl GcHeap {
1144        pub const fn new() -> Self {
1145            Self
1146        }
1147        pub fn set_config(&self, _: Arc<GcConfig>) {}
1148        /// No-op counterpart of the GC build's env-driven configuration.
1149        /// Region allocation has no soft limit to configure, but the runtime
1150        /// builder calls this unconditionally, so the method must exist in
1151        /// both builds.
1152        pub fn set_config_from_env(&self) {}
1153        pub fn register_root_tracer(&self, _: impl Fn(&mut MarkVisitor) + 'static) {}
1154        pub fn trace_registered_roots(&self, _: &mut MarkVisitor) {}
1155        pub fn memory_in_use(&self) -> usize {
1156            0
1157        }
1158        pub fn count(&self) -> usize {
1159            0
1160        }
1161        pub fn total_allocated(&self) -> usize {
1162            0
1163        }
1164        pub fn total_freed(&self) -> usize {
1165            0
1166        }
1167        pub fn collect<F: FnOnce(&mut MarkVisitor)>(&self, _: F) {}
1168        pub fn collect_auto(&self) -> bool {
1169            false
1170        }
1171    }
1172    unsafe impl Sync for GcHeap {}
1173    pub static HEAP: GcHeap = GcHeap::new();
1174
1175    pub struct MutatorGuard;
1176    impl Drop for MutatorGuard {
1177        fn drop(&mut self) {}
1178    }
1179    pub struct StwGuard;
1180    impl Drop for StwGuard {
1181        fn drop(&mut self) {}
1182    }
1183    pub struct GcParked;
1184
1185    pub struct GcCancellationStub;
1186    impl GcCancellationStub {
1187        pub const fn new() -> Self {
1188            Self
1189        }
1190        pub fn in_progress(&self) -> bool {
1191            false
1192        }
1193    }
1194    pub static CONFIG_CANCELLATION: GcCancellationStub = GcCancellationStub::new();
1195
1196    pub fn safepoint() {}
1197    pub fn gc_requested() -> bool {
1198        false
1199    }
1200    pub fn take_gc_request() -> bool {
1201        false
1202    }
1203    pub fn begin_stw() -> Option<StwGuard> {
1204        None
1205    }
1206    pub fn register_mutator() -> MutatorGuard {
1207        MutatorGuard
1208    }
1209    pub fn registered_threads() -> usize {
1210        0
1211    }
1212    pub fn request_gc() {}
1213    pub fn check_cancellation() -> Result<(), GcParked> {
1214        Ok(())
1215    }
1216    pub fn park_thread() {}
1217    pub fn unpark_thread() {}
1218    pub fn wait_for_threads_to_park() {}
1219
1220    pub struct AllocRootGuard;
1221    impl AllocRootGuard {
1222        pub fn release(&self) {}
1223    }
1224    impl Drop for AllocRootGuard {
1225        fn drop(&mut self) {}
1226    }
1227    pub fn push_alloc_frame() -> AllocRootGuard {
1228        AllocRootGuard
1229    }
1230}
1231
1232// =============================================================================
1233// Tests
1234// =============================================================================
1235
1236#[cfg(all(test, not(feature = "no-gc")))]
1237mod tests {
1238    use super::*;
1239    use std::sync::{Arc, Mutex};
1240
1241    #[derive(Debug)]
1242    #[allow(dead_code)]
1243    struct Tracked {
1244        value: i32,
1245        dropped: Arc<Mutex<bool>>,
1246    }
1247    impl Drop for Tracked {
1248        fn drop(&mut self) {
1249            *self.dropped.lock().unwrap() = true;
1250        }
1251    }
1252    impl Trace for Tracked {
1253        fn trace(&self, _: &mut MarkVisitor) {}
1254    }
1255
1256    #[derive(Debug)]
1257    #[allow(dead_code)]
1258    struct Parent {
1259        child: GcPtr<Tracked>,
1260    }
1261    impl Trace for Parent {
1262        fn trace(&self, visitor: &mut MarkVisitor) {
1263            visitor.visit(&self.child);
1264        }
1265    }
1266
1267    fn fresh_heap() -> gc_full::GcHeap {
1268        let heap = gc_full::GcHeap::new();
1269        heap.set_config(Arc::new(GcConfig::with_limits(10000, 50000)));
1270        heap
1271    }
1272
1273    #[test]
1274    fn alloc_and_get() {
1275        let heap = fresh_heap();
1276        let p = heap.alloc(42i64);
1277        assert_eq!(*p.get(), 42);
1278        assert_eq!(heap.count(), 1);
1279    }
1280
1281    #[test]
1282    fn clone_is_same_ptr() {
1283        let heap = fresh_heap();
1284        let p = heap.alloc(99i64);
1285        let q = p.clone();
1286        assert!(GcPtr::ptr_eq(&p, &q));
1287    }
1288
1289    #[test]
1290    fn parse_limit_mb_handles_valid_unset_and_malformed() {
1291        // Valid: converts megabytes to bytes.
1292        assert_eq!(gc_full::parse_limit_mb("X", Some("4"), 7), 4 * 1024 * 1024);
1293        // Surrounding whitespace is tolerated.
1294        assert_eq!(
1295            gc_full::parse_limit_mb("X", Some(" 4 "), 7),
1296            4 * 1024 * 1024
1297        );
1298        // Unset: falls back to the default.
1299        assert_eq!(gc_full::parse_limit_mb("X", None, 7), 7);
1300        // Malformed must NOT panic — it falls back to the default.
1301        assert_eq!(gc_full::parse_limit_mb("X", Some("foo"), 7), 7);
1302        assert_eq!(gc_full::parse_limit_mb("X", Some(""), 7), 7);
1303        assert_eq!(gc_full::parse_limit_mb("X", Some("-1"), 7), 7);
1304        // Absurdly large value saturates rather than overflowing.
1305        assert_eq!(
1306            gc_full::parse_limit_mb("X", Some(&usize::MAX.to_string()), 7),
1307            usize::MAX
1308        );
1309    }
1310
1311    #[test]
1312    fn collect_keeps_reachable() {
1313        let heap = fresh_heap();
1314        let dropped = Arc::new(Mutex::new(false));
1315        let p = heap.alloc(Tracked {
1316            value: 2,
1317            dropped: dropped.clone(),
1318        });
1319        heap.collect(|vis| vis.visit(&p));
1320        assert_eq!(heap.count(), 1);
1321        assert!(!*dropped.lock().unwrap());
1322    }
1323
1324    #[test]
1325    fn b1_two_isolates_independent_heaps() {
1326        use std::sync::{Arc, Barrier};
1327        // Each thread has its own ISOLATE_HEAP; allocations on one do not appear
1328        // in the other.
1329        let barrier = Arc::new(Barrier::new(2));
1330        let b1 = barrier.clone();
1331        let h1 = std::thread::Builder::new()
1332            .name("isolate-1".into())
1333            .spawn(move || {
1334                let _mutator = crate::register_mutator();
1335                // Allocate 100 objects on this isolate's heap
1336                let _ptrs: Vec<_> = (0_i64..100)
1337                    .map(|i| crate::gc_full::HEAP.alloc(i))
1338                    .collect();
1339                b1.wait(); // both threads are now at peak allocation
1340                // This isolate has exactly 100 live objects
1341                assert_eq!(
1342                    crate::gc_full::HEAP.count(),
1343                    100,
1344                    "isolate-1 heap count should be 100"
1345                );
1346            })
1347            .unwrap();
1348
1349        let b2 = barrier.clone();
1350        let h2 = std::thread::Builder::new()
1351            .name("isolate-2".into())
1352            .spawn(move || {
1353                let _mutator = crate::register_mutator();
1354                // Allocate 200 objects on this isolate's heap
1355                let _ptrs: Vec<_> = (0_i64..200)
1356                    .map(|i| crate::gc_full::HEAP.alloc(i))
1357                    .collect();
1358                b2.wait();
1359                // This isolate has exactly 200 live objects, unaffected by isolate-1
1360                assert_eq!(
1361                    crate::gc_full::HEAP.count(),
1362                    200,
1363                    "isolate-2 heap count should be 200"
1364                );
1365            })
1366            .unwrap();
1367
1368        h1.join().expect("isolate-1 panicked");
1369        h2.join().expect("isolate-2 panicked");
1370    }
1371
1372    #[test]
1373    fn b1_two_isolates_gc_independently() {
1374        // Two threads run allocation-heavy loops and GC their own heaps independently.
1375        let h1 = std::thread::Builder::new()
1376            .name("gc-isolate-1".into())
1377            .spawn(|| {
1378                let _mutator = crate::register_mutator();
1379                let heap = &crate::gc_full::HEAP;
1380                heap.set_config(Arc::new(GcConfig::with_limits(16_384, 65_536)));
1381                // Allocate in batches and collect; each collection touches only this heap
1382                for _ in 0..5 {
1383                    let _ptrs: Vec<_> = (0_i64..50).map(|i| heap.alloc(i)).collect();
1384                    // drive a manual collect with no roots so objects are freed
1385                    heap.collect(|_| {});
1386                    heap.collect(|_| {}); // second pass clears grace-period objects
1387                }
1388                // After all collections the heap should be empty (or close to it).
1389                // We don't assert an exact count because alloc_frame roots may keep
1390                // some alive; just assert we can collect without panicking.
1391            })
1392            .unwrap();
1393
1394        let h2 = std::thread::Builder::new()
1395            .name("gc-isolate-2".into())
1396            .spawn(|| {
1397                let _mutator = crate::register_mutator();
1398                let heap = &crate::gc_full::HEAP;
1399                heap.set_config(Arc::new(GcConfig::with_limits(16_384, 65_536)));
1400                for _ in 0..5 {
1401                    let _ptrs: Vec<_> = (0_i64..50).map(|i| heap.alloc(i)).collect();
1402                    heap.collect(|_| {});
1403                    heap.collect(|_| {});
1404                }
1405            })
1406            .unwrap();
1407
1408        h1.join().expect("gc-isolate-1 panicked");
1409        h2.join().expect("gc-isolate-2 panicked");
1410    }
1411}
1412
1413#[cfg(all(test, feature = "no-gc"))]
1414mod nogc_tests {
1415    use super::*;
1416    use alloc_ctx::{ScratchGuard, StaticCtxGuard};
1417
1418    #[test]
1419    fn alloc_in_static_context() {
1420        let _g = StaticCtxGuard::new();
1421        let p = GcPtr::new(42i64);
1422        assert_eq!(*p.get(), 42);
1423    }
1424
1425    #[test]
1426    fn alloc_in_scratch_region() {
1427        let mut scratch = ScratchGuard::new();
1428        let p = GcPtr::new(99i64);
1429        assert_eq!(*p.get(), 99);
1430        scratch.pop_for_return();
1431        assert_eq!(*p.get(), 99);
1432        // scratch drops here, resets the region
1433    }
1434
1435    #[test]
1436    fn ptr_eq() {
1437        let _g = StaticCtxGuard::new();
1438        let p = GcPtr::new(1i64);
1439        let q = p.clone();
1440        assert!(GcPtr::ptr_eq(&p, &q));
1441    }
1442}