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 region;
9pub mod stats;
10
11#[cfg(not(feature = "no-gc"))]
12pub mod cancellation;
13#[cfg(not(feature = "no-gc"))]
14pub mod config;
15
16#[cfg(feature = "no-gc")]
17pub mod alloc_ctx;
18#[cfg(feature = "no-gc")]
19pub mod static_arena;
20
21pub use stats::{CLJRS_GC_STATS_ENV, GC_STATS, GcStats, GcStatsSnapshot, dump_stats_from_env};
22
23// ── Re-exports from active implementation ─────────────────────────────────────
24
25#[cfg(not(feature = "no-gc"))]
26pub use cancellation::{
27    CancellableGuard, MutatorGuard, StwGuard, begin_stw, check_cancellation, gc_requested,
28    park_thread, register_mutator, registered_threads, request_gc, safepoint, take_gc_request,
29    unpark_thread, wait_for_threads_to_park,
30};
31#[cfg(not(feature = "no-gc"))]
32pub use config::{GC_CANCELLATION as CONFIG_CANCELLATION, GcConfig, GcParked};
33
34#[cfg(not(feature = "no-gc"))]
35pub use gc_full::{
36    AllocRootGuard, GcHeap, HEAP, HeapProxy, push_alloc_frame, trace_thread_alloc_roots,
37};
38#[cfg(feature = "no-gc")]
39pub use nogc_stubs::{
40    AllocRootGuard, CONFIG_CANCELLATION, CancellableGuard, GcConfig, GcHeap, GcParked, HEAP,
41    MutatorGuard, StwGuard, begin_stw, check_cancellation, gc_requested, park_thread,
42    push_alloc_frame, register_mutator, registered_threads, request_gc, safepoint, take_gc_request,
43    unpark_thread, wait_for_threads_to_park,
44};
45
46/// Return `true` if `addr` was allocated by the global `StaticArena`.
47///
48/// Available only in `no-gc` debug builds.  Downstream crates (`cljrs-value`)
49/// use this to implement write-site provenance assertions.
50#[cfg(all(feature = "no-gc", debug_assertions))]
51pub fn is_static_addr(addr: usize) -> bool {
52    static_arena::is_static_addr(addr)
53}
54
55// ── Trace trait ───────────────────────────────────────────────────────────────
56
57/// Implemented by every type that can be stored behind a [`GcPtr`].
58///
59/// The `gc_size_extra` method accounts for heap bytes owned by the value that
60/// are NOT captured by `size_of::<GcBox<T>>()` (e.g. `Vec` buffers, `String`
61/// capacity, `Form` AST trees stored inline).  The default returns 0, which is
62/// correct for primitives and types with no out-of-line heap.
63///
64/// Rules for implementors of `gc_size_extra`:
65/// - Count only bytes THIS value owns and will free when dropped.
66/// - Do NOT cross `GcPtr` boundaries — each pointed-to box is counted
67///   separately when it is allocated.
68pub trait Trace {
69    fn trace(&self, visitor: &mut MarkVisitor);
70
71    fn gc_size_extra(&self) -> usize {
72        0
73    }
74}
75
76// ── Leaf Trace impls ──────────────────────────────────────────────────────────
77
78impl Trace for String {
79    fn trace(&self, _: &mut MarkVisitor) {}
80
81    fn gc_size_extra(&self) -> usize {
82        self.capacity()
83    }
84}
85impl Trace for i64 {
86    fn trace(&self, _: &mut MarkVisitor) {}
87}
88impl Trace for f64 {
89    fn trace(&self, _: &mut MarkVisitor) {}
90}
91impl Trace for bool {
92    fn trace(&self, _: &mut MarkVisitor) {}
93}
94impl Trace for num_bigint::BigInt {
95    fn trace(&self, _: &mut MarkVisitor) {}
96}
97impl Trace for bigdecimal::BigDecimal {
98    fn trace(&self, _: &mut MarkVisitor) {}
99}
100impl Trace for num_rational::Ratio<num_bigint::BigInt> {
101    fn trace(&self, _: &mut MarkVisitor) {}
102}
103impl Trace for regex::Regex {
104    fn trace(&self, _: &mut MarkVisitor) {}
105}
106macro_rules! impl_trace_prim_array {
107    ($t:ty) => {
108        impl Trace for std::sync::Mutex<Vec<$t>> {
109            fn trace(&self, _: &mut MarkVisitor) {}
110            fn gc_size_extra(&self) -> usize {
111                self.lock().unwrap().capacity() * std::mem::size_of::<$t>()
112            }
113        }
114    };
115}
116impl_trace_prim_array!(i32);
117impl_trace_prim_array!(i64);
118impl_trace_prim_array!(i16);
119impl_trace_prim_array!(i8);
120impl_trace_prim_array!(char);
121impl_trace_prim_array!(f64);
122impl_trace_prim_array!(f32);
123impl_trace_prim_array!(bool);
124
125// ── GcVisitor ─────────────────────────────────────────────────────────────────
126
127pub trait GcVisitor {
128    fn visit<T: Trace + 'static>(&mut self, ptr: &GcPtr<T>);
129}
130
131// =============================================================================
132// GC build: GcBox with header, MarkVisitor with grey stack
133// =============================================================================
134
135#[cfg(not(feature = "no-gc"))]
136pub use self::gc_header::{GcBox, GcBoxHeader};
137
138#[cfg(not(feature = "no-gc"))]
139mod gc_header {
140    use crate::{MarkVisitor, Trace};
141    use std::cell::Cell;
142
143    // Objects start at lives = GC_INITIAL_LIVES - 1.  The mark phase sets
144    // lives = GC_INITIAL_LIVES for reachable objects; sweep frees objects
145    // whose lives reach 0.  A value of 2 gives exactly one cycle of grace:
146    // enough to cover the window between an alloc frame dropping and the
147    // next GC safepoint (where VALUE_ROOTS or a new alloc frame re-roots it).
148    // 10 was chosen conservatively but keeps 9× more garbage in RAM than
149    // necessary, worsening OOM pressure under test suites with many forms.
150    pub(crate) const GC_INITIAL_LIVES: u8 = 2;
151
152    #[cfg(debug_assertions)]
153    pub(crate) const GC_MAGIC_ALIVE: u64 = 0xCAFE_BABE_DEAD_BEEF;
154    #[cfg(debug_assertions)]
155    pub(crate) const GC_MAGIC_FREED: u64 = 0xDEAD_DEAD_DEAD_DEAD;
156
157    #[repr(C)]
158    pub struct GcBoxHeader {
159        #[cfg(debug_assertions)]
160        pub(crate) magic: Cell<u64>,
161        /// Exact size of the GcBox<T> allocation in bytes.
162        pub(crate) size: usize,
163        pub(crate) lives: Cell<u8>,
164        pub(crate) next: Cell<*mut GcBoxHeader>,
165        pub(crate) trace_fn: unsafe fn(*const GcBoxHeader, &mut MarkVisitor),
166        pub(crate) drop_fn: unsafe fn(*mut GcBoxHeader),
167    }
168
169    impl GcBoxHeader {
170        pub(crate) fn new<T: Trace + 'static>(heap_extra: usize) -> Self {
171            Self {
172                #[cfg(debug_assertions)]
173                magic: Cell::new(GC_MAGIC_ALIVE),
174                size: std::mem::size_of::<GcBox<T>>() + heap_extra,
175                lives: Cell::new(GC_INITIAL_LIVES - 1),
176                next: Cell::new(std::ptr::null_mut()),
177                trace_fn: trace_gc_box::<T>,
178                drop_fn: drop_gc_box::<T>,
179            }
180        }
181    }
182
183    unsafe impl Send for GcBoxHeader {}
184    unsafe impl Sync for GcBoxHeader {}
185
186    #[repr(C)]
187    pub struct GcBox<T: Trace + 'static> {
188        pub(crate) header: GcBoxHeader,
189        pub value: T,
190    }
191
192    pub(crate) unsafe fn trace_gc_box<T: Trace + 'static>(
193        header: *const GcBoxHeader,
194        visitor: &mut MarkVisitor,
195    ) {
196        unsafe {
197            let gc_box = header as *const GcBox<T>;
198            (*gc_box).value.trace(visitor);
199        }
200    }
201
202    pub(crate) unsafe fn drop_gc_box<T: Trace + 'static>(header: *mut GcBoxHeader) {
203        unsafe {
204            #[cfg(debug_assertions)]
205            {
206                (*header).magic.set(GC_MAGIC_FREED);
207            }
208            let gc_box = header as *mut GcBox<T>;
209            drop(Box::from_raw(gc_box));
210        }
211    }
212}
213
214// =============================================================================
215// no-gc build: GcBox without header
216// =============================================================================
217
218#[cfg(feature = "no-gc")]
219pub use self::nogc_box::GcBox;
220
221#[cfg(feature = "no-gc")]
222mod nogc_box {
223    use crate::Trace;
224
225    pub struct GcBox<T: Trace + 'static> {
226        pub value: T,
227    }
228}
229
230// =============================================================================
231// MarkVisitor: full under GC, stub under no-gc
232// =============================================================================
233
234#[cfg(not(feature = "no-gc"))]
235#[derive(Default)]
236pub struct MarkVisitor {
237    pub(crate) grey: Vec<*mut GcBoxHeader>,
238}
239
240#[cfg(feature = "no-gc")]
241pub struct MarkVisitor;
242
243#[cfg(not(feature = "no-gc"))]
244impl MarkVisitor {
245    pub fn new() -> Self {
246        Self::default()
247    }
248
249    pub fn grey_len(&self) -> usize {
250        self.grey.len()
251    }
252
253    pub unsafe fn mark_header(&mut self, header: *mut GcBoxHeader) {
254        use gc_header::GC_INITIAL_LIVES;
255        let h = unsafe { &*header };
256        if h.lives.get() < GC_INITIAL_LIVES {
257            h.lives.set(GC_INITIAL_LIVES);
258            self.grey.push(header);
259        }
260    }
261
262    pub(crate) fn drain(&mut self) {
263        let mut visited = 0usize;
264        while let Some(header) = self.grey.pop() {
265            visited += 1;
266            let h = unsafe { &*header };
267            unsafe { (h.trace_fn)(header as *const GcBoxHeader, self) };
268        }
269        cljrs_logging::feat_debug!("gc", "drain visited {} objects", visited);
270    }
271}
272
273#[cfg(not(feature = "no-gc"))]
274impl GcVisitor for MarkVisitor {
275    fn visit<T: Trace + 'static>(&mut self, ptr: &GcPtr<T>) {
276        use gc_header::GC_INITIAL_LIVES;
277        let header = unsafe { &(*ptr.0.as_ptr()).header };
278        if header.lives.get() < GC_INITIAL_LIVES {
279            header.lives.set(GC_INITIAL_LIVES);
280            self.grey.push(ptr.0.as_ptr() as *mut GcBoxHeader);
281        }
282    }
283}
284
285#[cfg(feature = "no-gc")]
286impl MarkVisitor {
287    pub fn grey_len(&self) -> usize {
288        0
289    }
290    pub unsafe fn mark_header(&mut self, _: *mut u8) {}
291}
292
293#[cfg(feature = "no-gc")]
294impl GcVisitor for MarkVisitor {
295    fn visit<T: Trace + 'static>(&mut self, _: &GcPtr<T>) {}
296}
297
298// =============================================================================
299// GcPtr — always present
300// =============================================================================
301
302pub struct GcPtr<T: Trace + 'static>(NonNull<GcBox<T>>);
303
304impl<T: Trace + 'static> GcPtr<T> {
305    #[cfg(not(feature = "no-gc"))]
306    pub fn new(value: T) -> Self {
307        gc_full::HEAP.alloc(value)
308    }
309
310    #[cfg(feature = "no-gc")]
311    pub fn new(value: T) -> Self {
312        alloc_ctx::alloc_in_ctx(value)
313    }
314
315    pub fn get(&self) -> &T {
316        #[cfg(all(debug_assertions, not(feature = "no-gc")))]
317        {
318            use gc_header::GC_MAGIC_ALIVE;
319            let header = unsafe { &(*self.0.as_ptr()).header };
320            assert_eq!(
321                header.magic.get(),
322                GC_MAGIC_ALIVE,
323                "GcPtr::get() on freed object! magic={:#x}",
324                header.magic.get(),
325            );
326        }
327        unsafe { &(*self.0.as_ptr()).value }
328    }
329
330    pub fn get_mut(&mut self) -> &mut T {
331        #[cfg(all(debug_assertions, not(feature = "no-gc")))]
332        {
333            use gc_header::GC_MAGIC_ALIVE;
334            let header = unsafe { &(*self.0.as_ptr()).header };
335            assert_eq!(
336                header.magic.get(),
337                GC_MAGIC_ALIVE,
338                "GcPtr::get_mut() on freed object! magic={:#x}",
339                header.magic.get(),
340            );
341        }
342        unsafe { &mut (*self.0.as_ptr()).value }
343    }
344
345    pub fn ptr_eq(a: &Self, b: &Self) -> bool {
346        a.0 == b.0
347    }
348
349    /// Return `true` if this pointer was allocated by the global `StaticArena`.
350    ///
351    /// Only meaningful (and only compiled) in `no-gc` debug builds.  Used by
352    /// write-site assertions in `Atom::reset` / `Var::bind` to catch
353    /// region-local values being stored in program-lifetime containers.
354    #[cfg(all(feature = "no-gc", debug_assertions))]
355    pub fn is_static_alloc(&self) -> bool {
356        static_arena::is_static_addr(self.0.as_ptr() as usize)
357    }
358}
359
360impl<T: Trace + 'static> Clone for GcPtr<T> {
361    fn clone(&self) -> Self {
362        GcPtr(self.0)
363    }
364}
365
366impl<T: Trace + 'static + std::fmt::Debug> std::fmt::Debug for GcPtr<T> {
367    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
368        unsafe { (*self.0.as_ptr()).value.fmt(f) }
369    }
370}
371
372impl<T: Trace + 'static> Drop for GcPtr<T> {
373    fn drop(&mut self) {}
374}
375
376// =============================================================================
377// Full GC implementation (default build)
378// =============================================================================
379
380#[cfg(not(feature = "no-gc"))]
381mod gc_full {
382    use std::cell::RefCell;
383    use std::ptr::NonNull;
384    use std::sync::atomic::{AtomicUsize, Ordering};
385    use std::sync::{Arc, Mutex};
386
387    use crate::config::GcConfig;
388    use crate::gc_header::GC_INITIAL_LIVES;
389    use crate::{GcBox, GcBoxHeader, GcPtr, MarkVisitor, Trace};
390
391    type RootTracer = Box<dyn Fn(&mut MarkVisitor)>;
392
393    pub struct GcHeap {
394        inner: Mutex<GcHeapInner>,
395        config: Mutex<Option<Arc<GcConfig>>>,
396        memory_in_use: AtomicUsize,
397        total_allocated_bytes: AtomicUsize,
398        root_tracers: Mutex<Vec<RootTracer>>,
399        gc_suppressed: std::sync::atomic::AtomicBool,
400        /// memory_in_use threshold above which GC is re-enabled after a
401        /// zero-yield collection.  The headroom doubles on each consecutive
402        /// zero-yield cycle (exponential backoff, capped at soft_limit) so a
403        /// long computation where all objects are live doesn't spin in a
404        /// constant GC storm of O(N) sweeps.  Resets to the base headroom
405        /// (soft_limit / 10) once GC actually frees something.
406        suppressed_threshold: AtomicUsize,
407        /// Current headroom used for exponential backoff after zero-yield cycles.
408        zero_yield_headroom: AtomicUsize,
409    }
410
411    struct GcHeapInner {
412        head: *mut GcBoxHeader,
413        count: usize,
414        total_allocated: usize,
415        total_freed: usize,
416    }
417
418    unsafe impl Send for GcHeapInner {}
419
420    impl GcHeapInner {
421        const fn new() -> Self {
422            Self {
423                head: std::ptr::null_mut(),
424                count: 0,
425                total_allocated: 0,
426                total_freed: 0,
427            }
428        }
429    }
430
431    unsafe impl Sync for GcHeap {}
432
433    impl Default for GcHeap {
434        fn default() -> Self {
435            Self::new()
436        }
437    }
438
439    impl GcHeap {
440        pub const fn new() -> Self {
441            Self {
442                inner: Mutex::new(GcHeapInner::new()),
443                config: Mutex::new(None),
444                memory_in_use: AtomicUsize::new(0),
445                total_allocated_bytes: AtomicUsize::new(0),
446                root_tracers: Mutex::new(Vec::new()),
447                gc_suppressed: std::sync::atomic::AtomicBool::new(false),
448                suppressed_threshold: AtomicUsize::new(0),
449                zero_yield_headroom: AtomicUsize::new(0),
450            }
451        }
452
453        pub fn set_config(&self, config: Arc<GcConfig>) {
454            *self.config.lock().unwrap() = Some(config);
455        }
456
457        pub fn set_config_from_env(&self) {
458            #[cfg(not(target_arch = "wasm32"))]
459            let default_soft_limit: usize = (system_memory::total() / 3) as usize;
460            #[cfg(target_arch = "wasm32")]
461            let default_soft_limit: usize = 64 * 1024 * 1024;
462
463            let soft_limit_mb: usize = match std::env::var("CLJRS_GC_SOFT_LIMIT_MB").ok() {
464                Some(s) => s.parse::<usize>().unwrap() * (1024 * 1024),
465                None => default_soft_limit,
466            };
467            let hard_limit_mb: usize = match std::env::var("CLJRS_GC_HARD_LIMIT_MB").ok() {
468                Some(s) => s.parse::<usize>().unwrap() * (1024 * 1024),
469                None => soft_limit_mb,
470            };
471            self.set_config(Arc::new(GcConfig::with_limits(
472                soft_limit_mb,
473                hard_limit_mb,
474            )));
475        }
476
477        pub fn register_root_tracer(&self, tracer: impl Fn(&mut MarkVisitor) + 'static) {
478            self.root_tracers.lock().unwrap().push(Box::new(tracer));
479        }
480
481        pub fn trace_registered_roots(&self, visitor: &mut MarkVisitor) {
482            let tracers = self.root_tracers.lock().unwrap();
483            for tracer in tracers.iter() {
484                tracer(visitor);
485            }
486        }
487
488        pub fn memory_in_use(&self) -> usize {
489            self.memory_in_use.load(Ordering::Relaxed)
490        }
491
492        #[cfg(test)]
493        pub fn set_memory_in_use(&self, bytes: usize) {
494            self.memory_in_use.store(bytes, Ordering::Relaxed);
495        }
496
497        pub fn alloc<T: Trace + 'static>(&self, value: T) -> GcPtr<T> {
498            crate::cancellation::safepoint();
499            let heap_extra = value.gc_size_extra();
500            let gc_box = Box::new(GcBox {
501                header: GcBoxHeader::new::<T>(heap_extra),
502                value,
503            });
504            let obj_size = gc_box.header.size; // GcBox<T> size + gc_size_extra()
505            let raw: *mut GcBox<T> = Box::into_raw(gc_box);
506            {
507                let mut inner = self.inner.lock().unwrap();
508                unsafe {
509                    (*raw).header.next.set(inner.head);
510                    inner.head = raw as *mut GcBoxHeader;
511                }
512                inner.count += 1;
513                inner.total_allocated += 1;
514            }
515            self.total_allocated_bytes
516                .fetch_add(obj_size, Ordering::Relaxed);
517            crate::stats::GC_STATS.record_gc_alloc(obj_size);
518            let current_usage =
519                self.memory_in_use.fetch_add(obj_size, Ordering::Relaxed) + obj_size;
520
521            if let Some(config) = self.config.lock().unwrap().as_ref()
522                && config.soft_limit_exceeded(current_usage)
523            {
524                if self.gc_suppressed.load(Ordering::Relaxed) {
525                    // Suppression active: only re-enable GC once memory has
526                    // grown past the threshold set by the last zero-yield
527                    // collection (current_memory + soft_limit/10).
528                    let threshold = self.suppressed_threshold.load(Ordering::Relaxed);
529                    if current_usage > threshold {
530                        self.gc_suppressed.store(false, Ordering::Relaxed);
531                        crate::cancellation::request_gc();
532                    }
533                } else {
534                    crate::cancellation::request_gc();
535                }
536            }
537
538            register_alloc(raw as *mut GcBoxHeader);
539            GcPtr(unsafe { NonNull::new_unchecked(raw) })
540        }
541
542        pub fn collect<F: FnOnce(&mut MarkVisitor)>(&self, trace_roots: F) {
543            let pre_count = self.inner.lock().unwrap().count;
544            let pre_memory = self.memory_in_use.load(Ordering::Relaxed);
545            cljrs_logging::feat_debug!(
546                "gc",
547                "starting collection: {} objects, ~{} bytes in use",
548                pre_count,
549                pre_memory
550            );
551            let mark_start = std::time::Instant::now();
552            let mut visitor = MarkVisitor::new();
553            trace_roots(&mut visitor);
554            cljrs_logging::feat_debug!(
555                "gc",
556                "starting drain with {} grey objects",
557                visitor.grey.len()
558            );
559            visitor.drain();
560            let mark_elapsed = mark_start.elapsed();
561
562            let sweep_start = std::time::Instant::now();
563            let mut inner = self.inner.lock().unwrap();
564            let mut live: Vec<*mut GcBoxHeader> = Vec::with_capacity(inner.count);
565            let mut dead: Vec<*mut GcBoxHeader> = Vec::new();
566            // Bytes of objects with lives==0 that will be freed now.
567            let mut freed_bytes: usize = 0;
568            let mut current = inner.head;
569            while !current.is_null() {
570                let header = unsafe { &*current };
571                let next = header.next.get();
572                let lives = header.lives.get();
573                let obj_size = header.size;
574                if lives >= GC_INITIAL_LIVES {
575                    // Marked reachable this cycle — reset grace counter.
576                    header.lives.set(GC_INITIAL_LIVES - 1);
577                    live.push(current);
578                } else if lives > 0 {
579                    // In grace period (unreachable but not yet freed).
580                    header.lives.set(lives - 1);
581                    live.push(current);
582                } else {
583                    // Grace period exhausted — collect now.
584                    freed_bytes += obj_size;
585                    dead.push(current);
586                }
587                current = next;
588            }
589            let freed_count = dead.len();
590            for ptr in dead {
591                let header = unsafe { &*ptr };
592                unsafe { (header.drop_fn)(ptr) };
593                inner.count -= 1;
594                inner.total_freed += 1;
595            }
596            inner.head = std::ptr::null_mut();
597            for ptr in live {
598                let header = unsafe { &*ptr };
599                header.next.set(inner.head);
600                inner.head = ptr;
601            }
602            // Decrement memory_in_use by the bytes actually freed.  All heap
603            // objects (live + grace-period) remain counted; only physically
604            // freed objects are subtracted.  This keeps memory pressure
605            // accurate so GC fires again when the heap genuinely grows.
606            self.memory_in_use.fetch_sub(freed_bytes, Ordering::Relaxed);
607            let sweep_elapsed = sweep_start.elapsed();
608            crate::stats::GC_STATS.record_gc_pause(
609                mark_elapsed + sweep_elapsed,
610                freed_count as u64,
611                freed_bytes as u64,
612            );
613            let post_memory = self.memory_in_use.load(Ordering::Relaxed);
614            cljrs_logging::feat_debug!(
615                "gc",
616                "collection complete: freed {} (~{} bytes), {} remaining (~{} bytes), mark={:.2?} sweep={:.2?}",
617                freed_count,
618                freed_bytes,
619                inner.count,
620                post_memory,
621                mark_elapsed,
622                sweep_elapsed
623            );
624            if freed_count == 0 {
625                // Zero-yield collection: exponential-backoff suppression.
626                // Each consecutive zero-yield cycle doubles the headroom before
627                // the next GC attempt (capped at soft_limit/4).  This prevents a
628                // GC storm during deep recursion where all objects are live —
629                // without backoff, GC fires every soft_limit/10 bytes, tracing
630                // the entire live set O(N) times to no benefit.
631                // The headroom resets to soft_limit/10 when GC frees something.
632                // Cap at soft_limit/4 (not soft_limit) so that GC still fires
633                // frequently enough to catch short-lived test allocations after
634                // a long namespace-loading phase of zero-yield cycles.
635                let soft_limit = self
636                    .config
637                    .lock()
638                    .unwrap()
639                    .as_ref()
640                    .map(|c| c.soft_limit())
641                    .unwrap_or(64 * 1024 * 1024);
642                let base_headroom = (soft_limit / 10).max(1);
643                let max_headroom = (soft_limit / 4).max(base_headroom);
644                let prev_headroom = self.zero_yield_headroom.load(Ordering::Relaxed);
645                let headroom = if prev_headroom == 0 {
646                    base_headroom
647                } else {
648                    prev_headroom.saturating_mul(2).min(max_headroom)
649                };
650                self.zero_yield_headroom.store(headroom, Ordering::Relaxed);
651                self.suppressed_threshold
652                    .store(post_memory + headroom, Ordering::Relaxed);
653                self.gc_suppressed.store(true, Ordering::Relaxed);
654            } else {
655                // GC freed something: reset exponential backoff.
656                self.zero_yield_headroom.store(0, Ordering::Relaxed);
657                self.gc_suppressed.store(false, Ordering::Relaxed);
658            }
659        }
660
661        pub fn count(&self) -> usize {
662            self.inner.lock().unwrap().count
663        }
664        pub fn total_allocated(&self) -> usize {
665            self.inner.lock().unwrap().total_allocated
666        }
667        pub fn total_freed(&self) -> usize {
668            self.inner.lock().unwrap().total_freed
669        }
670
671        pub fn collect_auto(&self) -> bool {
672            cljrs_logging::feat_debug!("gc", "automatic collection requested");
673            let Some(_stw_guard) = crate::cancellation::begin_stw() else {
674                cljrs_logging::feat_debug!("gc", "automatic collection skipped");
675                return false;
676            };
677            self.collect(|visitor| self.trace_registered_roots(visitor));
678            true
679        }
680    }
681
682    thread_local! {
683        static ISOLATE_HEAP: GcHeap = const { GcHeap::new() };
684    }
685
686    /// Zero-sized proxy that dispatches all heap operations to the calling
687    /// thread's [`GcHeap`] via the `ISOLATE_HEAP` thread-local.
688    ///
689    /// This means every isolate (OS thread) owns an independent heap; GC runs
690    /// fully in parallel on different threads with no cross-isolate coordination.
691    pub struct HeapProxy;
692
693    impl HeapProxy {
694        pub fn alloc<T: Trace + 'static>(&self, value: T) -> GcPtr<T> {
695            ISOLATE_HEAP.with(|h| h.alloc(value))
696        }
697
698        pub fn set_config(&self, config: Arc<GcConfig>) {
699            ISOLATE_HEAP.with(|h| h.set_config(config));
700        }
701
702        pub fn set_config_from_env(&self) {
703            ISOLATE_HEAP.with(|h| h.set_config_from_env());
704        }
705
706        pub fn register_root_tracer(&self, tracer: impl Fn(&mut MarkVisitor) + 'static) {
707            ISOLATE_HEAP.with(|h| h.register_root_tracer(tracer));
708        }
709
710        pub fn trace_registered_roots(&self, visitor: &mut MarkVisitor) {
711            ISOLATE_HEAP.with(|h| h.trace_registered_roots(visitor));
712        }
713
714        pub fn memory_in_use(&self) -> usize {
715            ISOLATE_HEAP.with(|h| h.memory_in_use())
716        }
717
718        pub fn count(&self) -> usize {
719            ISOLATE_HEAP.with(|h| h.count())
720        }
721
722        pub fn total_allocated(&self) -> usize {
723            ISOLATE_HEAP.with(|h| h.total_allocated())
724        }
725
726        pub fn total_freed(&self) -> usize {
727            ISOLATE_HEAP.with(|h| h.total_freed())
728        }
729
730        pub fn collect<F: FnOnce(&mut MarkVisitor)>(&self, trace_roots: F) {
731            ISOLATE_HEAP.with(|h| h.collect(trace_roots));
732        }
733
734        pub fn collect_auto(&self) -> bool {
735            ISOLATE_HEAP.with(|h| h.collect_auto())
736        }
737
738        #[cfg(test)]
739        pub fn set_memory_in_use(&self, bytes: usize) {
740            ISOLATE_HEAP.with(|h| h.set_memory_in_use(bytes));
741        }
742    }
743
744    // SAFETY: HeapProxy is zero-sized; all state lives in a thread-local GcHeap.
745    // The Send + Sync impls are needed so `pub static HEAP: HeapProxy` is valid.
746    unsafe impl Sync for HeapProxy {}
747
748    pub static HEAP: HeapProxy = HeapProxy;
749
750    thread_local! {
751        pub(crate) static ALLOC_ROOTS: RefCell<Vec<*mut GcBoxHeader>> = const { RefCell::new(Vec::new()) };
752    }
753
754    pub struct AllocRootGuard {
755        saved_len: usize,
756    }
757
758    impl Drop for AllocRootGuard {
759        fn drop(&mut self) {
760            ALLOC_ROOTS.with(|roots| roots.borrow_mut().truncate(self.saved_len));
761        }
762    }
763
764    pub fn push_alloc_frame() -> AllocRootGuard {
765        let saved_len = ALLOC_ROOTS.with(|roots| roots.borrow().len());
766        AllocRootGuard { saved_len }
767    }
768
769    fn register_alloc(header: *mut GcBoxHeader) {
770        ALLOC_ROOTS.with(|roots| roots.borrow_mut().push(header));
771    }
772
773    pub fn trace_thread_alloc_roots(visitor: &mut MarkVisitor) {
774        ALLOC_ROOTS.with(|roots| {
775            let roots = roots.borrow();
776            for &header in roots.iter() {
777                unsafe { visitor.mark_header(header) };
778            }
779        });
780    }
781}
782
783// =============================================================================
784// no-gc stubs
785// =============================================================================
786
787#[cfg(feature = "no-gc")]
788mod nogc_stubs {
789    use crate::MarkVisitor;
790    use std::sync::Arc;
791
792    #[derive(Debug, Clone)]
793    pub struct GcConfig;
794    impl GcConfig {
795        pub fn new() -> Self {
796            Self
797        }
798        pub fn with_hard_limit(_: usize) -> Self {
799            Self
800        }
801        pub fn with_limits(_: usize, _: usize) -> Self {
802            Self
803        }
804    }
805    impl Default for GcConfig {
806        fn default() -> Self {
807            Self::new()
808        }
809    }
810
811    pub struct GcHeap;
812    impl Default for GcHeap {
813        fn default() -> Self {
814            Self::new()
815        }
816    }
817    impl GcHeap {
818        pub const fn new() -> Self {
819            Self
820        }
821        pub fn set_config(&self, _: Arc<GcConfig>) {}
822        pub fn register_root_tracer(&self, _: impl Fn(&mut MarkVisitor) + 'static) {}
823        pub fn trace_registered_roots(&self, _: &mut MarkVisitor) {}
824        pub fn memory_in_use(&self) -> usize {
825            0
826        }
827        pub fn count(&self) -> usize {
828            0
829        }
830        pub fn total_allocated(&self) -> usize {
831            0
832        }
833        pub fn total_freed(&self) -> usize {
834            0
835        }
836        pub fn collect<F: FnOnce(&mut MarkVisitor)>(&self, _: F) {}
837        pub fn collect_auto(&self) -> bool {
838            false
839        }
840    }
841    unsafe impl Sync for GcHeap {}
842    pub static HEAP: GcHeap = GcHeap::new();
843
844    pub struct MutatorGuard;
845    impl Drop for MutatorGuard {
846        fn drop(&mut self) {}
847    }
848    pub struct StwGuard;
849    impl Drop for StwGuard {
850        fn drop(&mut self) {}
851    }
852    pub struct GcParked;
853    pub struct CancellableGuard;
854
855    pub struct GcCancellationStub;
856    impl GcCancellationStub {
857        pub const fn new() -> Self {
858            Self
859        }
860        pub fn in_progress(&self) -> bool {
861            false
862        }
863    }
864    pub static CONFIG_CANCELLATION: GcCancellationStub = GcCancellationStub::new();
865
866    pub fn safepoint() {}
867    pub fn gc_requested() -> bool {
868        false
869    }
870    pub fn take_gc_request() -> bool {
871        false
872    }
873    pub fn begin_stw() -> Option<StwGuard> {
874        None
875    }
876    pub fn register_mutator() -> MutatorGuard {
877        MutatorGuard
878    }
879    pub fn registered_threads() -> usize {
880        0
881    }
882    pub fn request_gc() {}
883    pub fn check_cancellation() -> Result<(), GcParked> {
884        Ok(())
885    }
886    pub fn park_thread() {}
887    pub fn unpark_thread() {}
888    pub fn wait_for_threads_to_park() {}
889
890    pub struct AllocRootGuard;
891    impl Drop for AllocRootGuard {
892        fn drop(&mut self) {}
893    }
894    pub fn push_alloc_frame() -> AllocRootGuard {
895        AllocRootGuard
896    }
897}
898
899// =============================================================================
900// Tests
901// =============================================================================
902
903#[cfg(all(test, not(feature = "no-gc")))]
904mod tests {
905    use super::*;
906    use std::sync::{Arc, Mutex};
907
908    #[derive(Debug)]
909    #[allow(dead_code)]
910    struct Tracked {
911        value: i32,
912        dropped: Arc<Mutex<bool>>,
913    }
914    impl Drop for Tracked {
915        fn drop(&mut self) {
916            *self.dropped.lock().unwrap() = true;
917        }
918    }
919    impl Trace for Tracked {
920        fn trace(&self, _: &mut MarkVisitor) {}
921    }
922
923    #[derive(Debug)]
924    #[allow(dead_code)]
925    struct Parent {
926        child: GcPtr<Tracked>,
927    }
928    impl Trace for Parent {
929        fn trace(&self, visitor: &mut MarkVisitor) {
930            visitor.visit(&self.child);
931        }
932    }
933
934    fn fresh_heap() -> gc_full::GcHeap {
935        let heap = gc_full::GcHeap::new();
936        heap.set_config(Arc::new(GcConfig::with_limits(10000, 50000)));
937        heap
938    }
939
940    #[test]
941    fn alloc_and_get() {
942        let heap = fresh_heap();
943        let p = heap.alloc(42i64);
944        assert_eq!(*p.get(), 42);
945        assert_eq!(heap.count(), 1);
946    }
947
948    #[test]
949    fn clone_is_same_ptr() {
950        let heap = fresh_heap();
951        let p = heap.alloc(99i64);
952        let q = p.clone();
953        assert!(GcPtr::ptr_eq(&p, &q));
954    }
955
956    #[test]
957    fn collect_keeps_reachable() {
958        let heap = fresh_heap();
959        let dropped = Arc::new(Mutex::new(false));
960        let p = heap.alloc(Tracked {
961            value: 2,
962            dropped: dropped.clone(),
963        });
964        heap.collect(|vis| vis.visit(&p));
965        assert_eq!(heap.count(), 1);
966        assert!(!*dropped.lock().unwrap());
967    }
968
969    #[test]
970    fn b1_two_isolates_independent_heaps() {
971        use std::sync::{Arc, Barrier};
972        // Each thread has its own ISOLATE_HEAP; allocations on one do not appear
973        // in the other.
974        let barrier = Arc::new(Barrier::new(2));
975        let b1 = barrier.clone();
976        let h1 = std::thread::Builder::new()
977            .name("isolate-1".into())
978            .spawn(move || {
979                let _mutator = crate::register_mutator();
980                // Allocate 100 objects on this isolate's heap
981                let _ptrs: Vec<_> = (0_i64..100)
982                    .map(|i| crate::gc_full::HEAP.alloc(i))
983                    .collect();
984                b1.wait(); // both threads are now at peak allocation
985                // This isolate has exactly 100 live objects
986                assert_eq!(
987                    crate::gc_full::HEAP.count(),
988                    100,
989                    "isolate-1 heap count should be 100"
990                );
991            })
992            .unwrap();
993
994        let b2 = barrier.clone();
995        let h2 = std::thread::Builder::new()
996            .name("isolate-2".into())
997            .spawn(move || {
998                let _mutator = crate::register_mutator();
999                // Allocate 200 objects on this isolate's heap
1000                let _ptrs: Vec<_> = (0_i64..200)
1001                    .map(|i| crate::gc_full::HEAP.alloc(i))
1002                    .collect();
1003                b2.wait();
1004                // This isolate has exactly 200 live objects, unaffected by isolate-1
1005                assert_eq!(
1006                    crate::gc_full::HEAP.count(),
1007                    200,
1008                    "isolate-2 heap count should be 200"
1009                );
1010            })
1011            .unwrap();
1012
1013        h1.join().expect("isolate-1 panicked");
1014        h2.join().expect("isolate-2 panicked");
1015    }
1016
1017    #[test]
1018    fn b1_two_isolates_gc_independently() {
1019        // Two threads run allocation-heavy loops and GC their own heaps independently.
1020        let h1 = std::thread::Builder::new()
1021            .name("gc-isolate-1".into())
1022            .spawn(|| {
1023                let _mutator = crate::register_mutator();
1024                let heap = &crate::gc_full::HEAP;
1025                heap.set_config(Arc::new(GcConfig::with_limits(16_384, 65_536)));
1026                // Allocate in batches and collect; each collection touches only this heap
1027                for _ in 0..5 {
1028                    let _ptrs: Vec<_> = (0_i64..50).map(|i| heap.alloc(i)).collect();
1029                    // drive a manual collect with no roots so objects are freed
1030                    heap.collect(|_| {});
1031                    heap.collect(|_| {}); // second pass clears grace-period objects
1032                }
1033                // After all collections the heap should be empty (or close to it).
1034                // We don't assert an exact count because alloc_frame roots may keep
1035                // some alive; just assert we can collect without panicking.
1036            })
1037            .unwrap();
1038
1039        let h2 = std::thread::Builder::new()
1040            .name("gc-isolate-2".into())
1041            .spawn(|| {
1042                let _mutator = crate::register_mutator();
1043                let heap = &crate::gc_full::HEAP;
1044                heap.set_config(Arc::new(GcConfig::with_limits(16_384, 65_536)));
1045                for _ in 0..5 {
1046                    let _ptrs: Vec<_> = (0_i64..50).map(|i| heap.alloc(i)).collect();
1047                    heap.collect(|_| {});
1048                    heap.collect(|_| {});
1049                }
1050            })
1051            .unwrap();
1052
1053        h1.join().expect("gc-isolate-1 panicked");
1054        h2.join().expect("gc-isolate-2 panicked");
1055    }
1056}
1057
1058#[cfg(all(test, feature = "no-gc"))]
1059mod nogc_tests {
1060    use super::*;
1061    use alloc_ctx::{ScratchGuard, StaticCtxGuard};
1062
1063    #[test]
1064    fn alloc_in_static_context() {
1065        let _g = StaticCtxGuard::new();
1066        let p = GcPtr::new(42i64);
1067        assert_eq!(*p.get(), 42);
1068    }
1069
1070    #[test]
1071    fn alloc_in_scratch_region() {
1072        let mut scratch = ScratchGuard::new();
1073        let p = GcPtr::new(99i64);
1074        assert_eq!(*p.get(), 99);
1075        scratch.pop_for_return();
1076        assert_eq!(*p.get(), 99);
1077        // scratch drops here, resets the region
1078    }
1079
1080    #[test]
1081    fn ptr_eq() {
1082        let _g = StaticCtxGuard::new();
1083        let p = GcPtr::new(1i64);
1084        let q = p.clone();
1085        assert!(GcPtr::ptr_eq(&p, &q));
1086    }
1087}