cljrs-gc
Non-moving, stop-the-world mark-and-sweep garbage collector for clojurust;
or, with the no-gc Cargo feature, a region-based allocator with no GC pauses.
On wasm32 targets the system-memory crate is excluded (it brings in errno,
which does not build for wasm32-unknown-unknown). The GC heap defaults to a
fixed 64 MB soft limit instead of consulting total system RAM.
Phase: 8.1 (GcVisitor + Trace infrastructure) + 8.2 (GcBox/GcHeap
raw-pointer implementation) — implemented. no-gc mode (Phases 1–8 of
docs/archive/no-gc-plan.md) — implemented. B3 (StaticGcPtr, static_alloc) —
implemented.
Purpose
Manages all Clojure runtime values. GcPtr<T> is a raw pointer into either
the GC heap or a bump-allocated region; clone is O(1); drop is a no-op.
Default build (GC mode): memory is freed only during GcHeap::collect.
Region provenance tagging (GC mode): region-allocated GcPtrs carry a
low-bit tag (REGION_PTR_TAG; GcBox<T> is ≥8-aligned so bit 0 is free).
MarkVisitor::visit checks the tag without dereferencing and skips
region objects — a region whose scope has ended leaves dangling pointers whose
headers are freed/reused memory, so tracing them would follow a garbage
trace_fn. Because the mark phase no longer traces into region objects,
GcHeap::collect instead treats every live region on the thread's region
stack as a root (region::trace_active_regions), so heap objects reachable
only through a live region are still kept alive. GcPtr::raw() masks the tag
on every dereference; GcPtr::is_region_alloc() exposes it.
no-gc build: every function call and every loop iteration pushes a
scratch Region; intermediates are freed when the scope exits. Return values
and recur arguments are evaluated in the caller's context (the
"return-expression-in-caller" mechanism). Static-sink expressions (def,
defn, defmacro, atom, agent, volatile!, reset!, vreset!,
swap!, vswap!, alter-var-root, intern) go to the global StaticArena
and live for the program lifetime.
No GcHeap, no stop-the-world pauses, no Trace overhead at runtime.
An isolated invocation can instead install alloc_ctx::InvocationGuard. In
that profile every allocation uses one bounded region; nested ScratchGuard
and StaticCtxGuard values become no-ops, so arbitrary internal graphs share
the invocation lifetime and are reclaimed together at the boundary.
Phase 7 (debug provenance): in debug_assertions builds with no-gc,
StaticArena tracks chunk ranges and exposes is_static_addr(usize) -> bool.
GcPtr::is_static_alloc() uses this to check pointer provenance at O(chunks)
cost. Atom::reset, Var::bind, and Volatile::reset use debug_assert!
to catch region-local values being stored in program-lifetime containers.
File layout
src/
lib.rs — GcVisitor, Trace, GcBox<T>, GcPtr<T>, MarkVisitor, HEAP,
leaf Trace impls; conditional GC vs no-gc implementations
gc_header — (GC mode only) GcBoxHeader, drop/trace fns
gc_full — (GC mode only) GcHeap, HeapProxy, HEAP (per-isolate proxy),
ALLOC_ROOTS, AllocRootGuard
nogc_stubs — (no-gc mode) stub GcHeap, GcConfig, cancellation stubs
static_arena.rs — (no-gc mode) global program-lifetime bump allocator;
in debug builds, tracks chunk ranges for is_static_addr()
alloc_ctx.rs — (no-gc mode) thread-local allocation context stack;
ScratchGuard, StaticCtxGuard, InvocationGuard
region.rs — Region bump allocator, RegionGuard, thread-local region
stack; trace_active_regions() (GC-root scan of live regions);
poison/retire protocol (Phase 10.5 heap-promotion fallback):
poison_active_regions(), close_region(), retired-region
root tracing
cancellation.rs — (GC mode) STW coordination, MutatorGuard, safepoints
config.rs — (GC mode) GcConfig, GcCancellation (zero-sized proxy),
IsolateCancellation thread-local (per-isolate STW state), GcParked
stats.rs — process-global GcStats counters: GC allocations,
region (bump) allocations, GC pauses + freed bytes/objects,
isolate-boundary crossings (bytes copied + serialize time)
tests/
no_gc_alloc.rs — (no-gc mode) integration tests for the allocation context stack:
ScratchGuard, StaticCtxGuard, InvocationGuard,
pop_for_return protocol, nested guards, destructor ordering
Public API
GcVisitor
Implemented by [MarkVisitor]. Call visitor.visit(ptr) inside
Trace::trace for every GcPtr field.
Trace
Implemented by every type stored behind a GcPtr. Must call
visitor.visit(ptr) for every GcPtr reachable from self (directly or
through Arc/Mutex/etc.).
gc_size_extra returns heap bytes owned by the value that are NOT counted by
size_of::<GcBox<T>>() — Vec buffers, String capacity, Form AST trees stored
inline. The GC adds this to the tracked memory_in_use so collection fires at
the right threshold. Do NOT cross GcPtr boundaries — pointed-to boxes are
counted separately when allocated.
Built-in leaf impls: String (overrides gc_size_extra to return capacity()),
i64, f64, bool, num_bigint::BigInt, bigdecimal::BigDecimal,
num_rational::Ratio<BigInt>.
GcPtr<T: Trace + 'static>
;
StaticGcPtr<T: 'static> (always available — Phase B3)
Program-lifetime pointer safe to share across isolate threads. Backed by the
global StaticArena (in no-gc builds) or Box::leak (in GC builds).
Unlike GcPtr, it wraps *const T directly (no GcBox header) and is
Send + Sync.
;
/// Allocate `value` as program-lifetime memory.
/// no-gc: StaticArena bump-alloc; GC: Box::leak.
;
Free functions
// always:
;
// no-gc only:
;
// no-gc + debug_assertions only:
; // checks the StaticArena chunk registry
GcHeap
collect is stop-the-world: must only be called when no other thread is
creating or dereferencing GcPtr values.
MarkVisitor
Uses a grey stack (avoids recursion stack overflow) and handles cycles via already-marked check.
HeapProxy and HEAP
; // zero-sized; all state in ISOLATE_HEAP thread-local
pub static HEAP: HeapProxy;
HEAP is a zero-sized proxy that dispatches every operation to the calling
thread's ISOLATE_HEAP thread-local GcHeap. Each OS thread (isolate) owns
an independent heap; GC runs fully in parallel across threads with no
cross-isolate stop-the-world coordination. All GcPtr::new calls allocate
into the current thread's heap via this proxy.
region::Region
with_limit raises the typed RegionLimitExceeded panic payload when the
managed allocation budget is exhausted. It charges boxes plus
Trace::gc_size_extra; it is not a process-wide RSS limiter.
alloc_ctx::InvocationGuard (no-gc only)
;
All GcPtr::new calls within the guard use its single arena, including calls
made beneath evaluator scratch/static guards. Copy or serialize results before
the guard drops.
Bump allocator for short-lived objects. ~2.6x faster than GcHeap::alloc
(no mutex, no Box::new). Objects are NOT in the GC heap linked list.
Destructors run on reset() or drop.
region::RegionGuard
RAII guard that pushes a Region onto the thread-local stack. Use with
try_alloc_in_region() for opportunistic region allocation.
Region poisoning / retirement (Phase 10.5)
/// Mark every region currently active on this thread: each will be *retired*
/// (kept alive forever and traced as a GC root) instead of reset when its
/// scope closes. No-op when no region is active.
;
/// Close a region whose scope ended: pop the thread-local stack entry, then
/// reset/drop the region — or retire it if poisoned. All owners of
/// stack-registered regions (rt_abi, the IR interpreter) close through here.
;
The heap-promotion fallback: when a publish barrier
(cljrs_value::publish::publish_value) meets a value it can neither verify
nor deep-copy while regions are open, it poisons them. Retired regions are a
deliberate bounded leak (mirroring the JIT's pinned epochs) that can never
dangle; GcHeap::collect traces them as roots alongside the active stack.
stats::GcStats and GC_STATS
pub static GC_STATS: GcStats;
pub const CLJRS_GC_STATS_ENV: &str; // = "CLJRS_GC_STATS"
;
Process-global counters updated automatically by GcHeap::alloc,
GcHeap::collect, and Region::alloc. The cljrs --gc-stats [FILE] CLI
flag prints a snapshot of these counters at program exit.
record_boundary_crossing is the metered isolate-boundary seam required by
docs/isolate-boundary-plan.md: every value deep-copied across an isolate
boundary (the Phase B2 structured-clone in cljrs-async's IsolateSender::send)
records its estimated bytes copied and serialize time here, so a silent fan-out
copy shows up in --gc-stats as Boundary crossings: N (B bytes copied) rather
than as mystery latency.
dump_stats_from_env() is the AOT-binary equivalent: it reads the
CLJRS_GC_STATS environment variable and, if set, writes a snapshot to
stdout (when the value is empty or "-") or to the named file. AOT-compiled
programs and the AOT test harness call it once at exit.
Design notes
- Non-moving:
GcPtr<T>stores a stableNonNull<GcBox<T>>address. - Stop-the-world:
collectmust pause all other threads that holdGcPtrs. - Intrusive linked list: all
GcBoxes are linked viaGcBoxHeader::next. - Type erasure:
trace_fn/drop_fnin the header enable type-erased mark and sweep without a vtable pointer per allocation. - Accurate allocation accounting:
GcBoxHeader::sizestoressize_of::<GcBox<T>>() + value.gc_size_extra()at allocation time.memory_in_useis incremented by this total (not a flat estimate) and decremented by the same value when the object is freed. Types that own significant out-of-line heap (Form AST trees inCljxFn, String capacity) overridegc_size_extraso the GC threshold fires before the process OOMs. - Fixed-headroom GC suppression: after a zero-yield collection (nothing freed),
GC is suppressed until
memory_in_usegrows by anothersoft_limit/10bytes (a fixed additive headroom, not a percentage of current memory). Using a percentage of current memory as headroom would compound across consecutive zero-yield cycles (e.g. during deep recursion where all objects are live), causing the threshold to grow exponentially and GC to stop firing permanently after the computation finishes — leading to OOM on long test suites. A fixed headroom gives linear growth, which stays bounded. The old trigger — re-enabling on every alloc-frame drop — fired O(N-heap) sweeps on every eval-frame return, causing a GC storm with hundreds of useless traversals. - Minimal grace period (
GC_INITIAL_LIVES = 2): objects start atlives = 1. GC only fires at explicitgc_safepoint()calls, not at arbitrary Rust points. The single cycle of grace covers the narrow window between an alloc frame dropping and the next safepoint at whichVALUE_ROOTSor the new alloc frame re-roots the value. The old value of 10 kept 9× more garbage in RAM than necessary, worsening OOM pressure under long test suites. - Cycle collection: because
GcPtr::dropis a no-op, reference cycles do not prevent collection — any object unreachable from roots is freed.
Deferred to later phases
- Incremental/concurrent collection — Phase 10+
- Write barriers for generational GC — Phase 10+
- Weak references (
WeakGcPtr<T>) — deferred - Safepoint integration with JIT frames — Phase 10+
- Automatic collection trigger (threshold-based) — deferred