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