diff --git a/oscars/Cargo.toml b/oscars/Cargo.toml index 87c8265..0f693a7 100644 --- a/oscars/Cargo.toml +++ b/oscars/Cargo.toml @@ -34,4 +34,5 @@ std = [] mark_sweep = [] mark_sweep2 = ["mark_sweep"] mark_sweep_branded = ["mark_sweep"] +null_collector = ["mark_sweep"] thin-vec = ["dep:thin-vec", "mark_sweep"] diff --git a/oscars/src/collectors/mod.rs b/oscars/src/collectors/mod.rs index 6172a2b..6caf4af 100644 --- a/oscars/src/collectors/mod.rs +++ b/oscars/src/collectors/mod.rs @@ -3,6 +3,7 @@ pub mod common; pub mod mark_sweep; pub mod mark_sweep_arena2; +pub mod null_collector; #[cfg(feature = "mark_sweep_branded")] pub mod mark_sweep_branded; diff --git a/oscars/src/collectors/null_collector/mod.rs b/oscars/src/collectors/null_collector/mod.rs new file mode 100644 index 0000000..f835237 --- /dev/null +++ b/oscars/src/collectors/null_collector/mod.rs @@ -0,0 +1,242 @@ +//! A null (no-op) GC +//! +//! [`NullCollector`] uses the same arena as [`crate::collectors::mark_sweep::MarkSweepGarbageCollector`] +//! but never collects. Allocations are only freed when the collector drops. +//! +//! # Use Cases +//! * **Short-lived contexts**: Avoids GC overhead when the heap is discarded quickly. +//! * **Benchmarking**: Measures raw allocation costs without GC interference. +//! +//! # Limitations +//! * **No cycle collection**: Leaks memory in long running programs. +//! * **Weak pointers stay alive**: `WeakGc::upgrade` always succeeds. + +use core::cell::RefCell; +use core::ptr::NonNull; + +use crate::{ + alloc::mempool3::{PoolAllocError, PoolAllocator, PoolItem, PoolPointer}, + collectors::mark_sweep::{ + Collector, ErasedEphemeron, ErasedWeakMap, Gc, TraceColor, + internals::{Ephemeron, GcBox, NonTraceable}, + trace::Trace, + }, +}; +use rust_alloc::vec::Vec; + +/// Fixed trace color. +/// We never sweep, so objects always stay the same color. +const NULL_TRACE_COLOR: TraceColor = TraceColor::White; + +/// Type-erased root pointer. +/// Matches `MarkSweepGarbageCollector` to reuse vtable functions. +type GcErasedPointer = NonNull>>; + +/// A garbage collector that **never collects**. +/// +/// Objects are allocated into an arena and tracked. Their destructors +/// run when the collector is dropped. No mark or sweep passes happen +/// during normal execution. +pub struct NullCollector { + /// Backing pool allocator for accurate benchmarking. + pub(crate) allocator: RefCell>, + + /// All `GcBox` nodes in insertion order. + /// Used during drop to run finalizers and destructors. + root_queue: RefCell>, + + /// All `Ephemeron` nodes in insertion order. + ephemeron_queue: RefCell>, + + /// Heap allocations for `WeakMapInner`. + /// Tracked to allow safe drops and freed when the collector drops. + weak_maps: RefCell>>, +} + +impl Default for NullCollector { + fn default() -> Self { + Self { + allocator: RefCell::new(PoolAllocator::default()), + root_queue: RefCell::new(Vec::new()), + ephemeron_queue: RefCell::new(Vec::new()), + weak_maps: RefCell::new(Vec::new()), + } + } +} + +impl NullCollector { + /// Override the page size used by the underlying allocator. + /// + /// This is useful in tests and matches the `MarkSweepGarbageCollector` API. + #[must_use] + pub fn with_page_size(mut self, page_size: usize) -> Self { + self.allocator.get_mut().page_size = page_size; + self + } + + /// Override the heap threshold. + /// + /// The null collector never auto-collects, so this value is ignored. + /// It exists to match the `MarkSweepGarbageCollector` constructor exactly. + #[must_use] + pub fn with_heap_threshold(mut self, heap_threshold: usize) -> Self { + self.allocator.get_mut().heap_threshold = heap_threshold; + self + } + + /// Number of live slot-pool pages and bump pages. + /// + /// This mirrors `MarkSweepGarbageCollector::pools_len` for testing. + pub fn pools_len(&self) -> usize { + self.allocator.borrow().pools_len() + } +} + +impl NullCollector { + /// Finalize and free all tracked nodes. + /// + /// This uses two phases so finalizers can safely access other GC values + /// that are still in the heap: + /// + /// * Phase 1: call `finalize_fn` for all roots and ephemerons. + /// * Phase 2: call `drop_fn` for all roots and ephemerons, then + /// free the slots. + /// + /// This matches `MarkSweepGarbageCollector::sweep_all_queues`. + fn sweep_all_queues(&self) { + let roots = core::mem::take(&mut *self.root_queue.borrow_mut()); + let ephemerons = core::mem::take(&mut *self.ephemeron_queue.borrow_mut()); + + // Phase 1: finalize + for node in roots.iter().copied() { + // SAFETY: `node` is a live pool allocation with a valid vtable. + let gc_box = unsafe { node.as_ref().value() }; + unsafe { gc_box.finalize_fn()(node) }; + } + + for eph in ephemerons.iter().copied() { + // SAFETY: `eph` is a live pool allocation with a valid vtable. + let vtable = unsafe { eph.as_ref().value() }; + unsafe { vtable.finalize_fn()(eph) }; + } + + // Phase 2: drop + free + for node in roots { + // SAFETY: `drop_fn` is called exactly once before freeing the slot. + let drop_fn = unsafe { node.as_ref().value().drop_fn() }; + unsafe { drop_fn(node) }; + self.allocator.borrow_mut().free_slot(node.cast::()); + } + + for eph in ephemerons { + let drop_fn = unsafe { eph.as_ref().value().drop_fn() }; + unsafe { drop_fn(eph) }; + self.allocator.borrow_mut().free_slot(eph.cast::()); + } + } + + /// Free `Box` allocations from `track_weak_map`. + /// + /// These pointers come from `Box::into_raw` and must be rebuilt into + /// a `Box` to free them correctly. + fn drop_weak_maps(&self) { + for map_ptr in self.weak_maps.borrow_mut().drain(..) { + // SAFETY: `map_ptr` came from `Box::into_raw` in `WeakMap::new`. + unsafe { + let _ = rust_alloc::boxed::Box::from_raw(map_ptr.as_ptr()); + } + } + } +} + +impl Drop for NullCollector { + fn drop(&mut self) { + // If any rooted handles outlive the collector, skip teardown to + // avoid use-after-free. The pool pages will be freed by the allocator. + // This matches `MarkSweepGarbageCollector::drop`. + let has_rooted = self + .root_queue + .borrow() + .iter() + .any(|node| unsafe { node.as_ref().value().is_rooted() }); + + if self.pools_len() > 0 && has_rooted { + // Intentional leak: rooted handles outlive the collector. + } else { + self.sweep_all_queues(); + } + + self.drop_weak_maps(); + } +} + +impl Collector for NullCollector { + /// No-op: the null collector never triggers a collection cycle. + /// + /// Calling `collect` does nothing, regardless of heap size or pressure. + #[inline] + fn collect(&self) {} + + /// Returns the fixed trace-color epoch. + /// + /// The null collector never flips the epoch. We always return a constant + /// color since we don't use it for sweeping. + #[inline] + fn gc_color(&self) -> TraceColor { + NULL_TRACE_COLOR + } + + /// Allocate a `GcBox` and register it for teardown. + /// + /// Unlike `MarkSweepGarbageCollector`, this never triggers collections. + /// The node goes on the root queue for finalization when the collector drops. + /// + /// The lifetime `'gc` ties the returned pointer to `self`, ensuring the + /// pointer cannot outlive the pool that backs it. + fn alloc_gc_node<'gc, T: Trace + 'static>( + &'gc self, + value: T, + ) -> Result>, PoolAllocError> { + let gc_box = GcBox::new_in(value, NULL_TRACE_COLOR); + let arena_ptr = self.allocator.borrow_mut().try_alloc(gc_box)?; + + let erased: GcErasedPointer = arena_ptr.as_ptr().cast(); + self.root_queue.borrow_mut().push(erased); + + Ok(arena_ptr) + } + + /// Allocate an `Ephemeron` and register it for teardown. + /// + /// No collection is ever triggered. Because the collector never sweeps, + /// the ephemeron key is never invalidated. `WeakGc::upgrade` always succeeds. + fn alloc_ephemeron_node<'gc, K: Trace + 'static, V: Trace + 'static>( + &'gc self, + key: &Gc, + value: V, + ) -> Result>, PoolAllocError> { + let ephemeron = Ephemeron::new(key, value, NULL_TRACE_COLOR); + let inner_ptr = self.allocator.borrow_mut().try_alloc(ephemeron)?; + + let eph_ptr = inner_ptr + .as_ptr() + .cast::>>(); + self.ephemeron_queue.borrow_mut().push(eph_ptr); + + Ok(inner_ptr) + } + + /// Register a `WeakMap` with the collector. + /// + /// We never prune dead entries, so weak map entries stay alive. + /// We accept the registration so `WeakMap::drop` can mark itself dead + /// without panicking. The memory is reclaimed in `drop_weak_maps`. + #[doc(hidden)] + #[inline] + fn track_weak_map(&self, map: NonNull) { + self.weak_maps.borrow_mut().push(map); + } +} + +#[cfg(test)] +mod tests; diff --git a/oscars/src/collectors/null_collector/tests.rs b/oscars/src/collectors/null_collector/tests.rs new file mode 100644 index 0000000..cfe863e --- /dev/null +++ b/oscars/src/collectors/null_collector/tests.rs @@ -0,0 +1,199 @@ +use super::NullCollector; +use crate::collectors::mark_sweep::{ + Collector, Finalize, Gc, Trace, TraceColor, WeakGc, WeakMap, cell::GcRefCell, +}; + +#[test] +fn basic_alloc_and_read() { + let nc = NullCollector::default(); + let gc = Gc::new_in(42u32, &nc); + assert_eq!( + *gc, 42u32, + "value should be readable immediately after alloc" + ); +} + +#[test] +fn collect_is_noop() { + let nc = NullCollector::default(); + let gc = Gc::new_in(GcRefCell::new(7u64), &nc); + + nc.collect(); + nc.collect(); + nc.collect(); + + assert_eq!(*gc.borrow(), 7u64, "value must survive collect() calls"); +} + +#[test] +fn gc_color_is_stable() { + let nc = NullCollector::default(); + let color1 = nc.gc_color(); + nc.collect(); + let color2 = nc.gc_color(); + assert!( + matches!((color1, color2), (TraceColor::White, TraceColor::White)), + "gc_color must never flip on a NullCollector" + ); +} + +#[test] +fn multiple_allocs() { + let nc = NullCollector::default() + .with_page_size(64) + .with_heap_threshold(512); + + let gcs: rust_alloc::vec::Vec<_> = (0u64..8) + .map(|i| Gc::new_in(GcRefCell::new(i), &nc)) + .collect(); + + for (i, gc) in gcs.iter().enumerate() { + assert_eq!(*gc.borrow(), i as u64, "value {i} changed unexpectedly"); + } +} + +#[test] +fn drop_signals_finalizer() { + use core::sync::atomic::{AtomicBool, Ordering}; + use rust_alloc::sync::Arc; + + let dropped = Arc::new(AtomicBool::new(false)); + + struct Spy(Arc); + impl Drop for Spy { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + impl Finalize for Spy {} + // SAFETY: `Spy` has no GC children. + unsafe impl Trace for Spy { + crate::empty_trace!(); + } + + { + let nc = NullCollector::default(); + let _gc = Gc::new_in(Spy(Arc::clone(&dropped)), &nc); + assert!(!dropped.load(Ordering::SeqCst), "dropped too early"); + } + + assert!( + dropped.load(Ordering::SeqCst), + "Spy::drop must run when the NullCollector is dropped" + ); +} + +#[test] +fn mutation_persists() { + let nc = NullCollector::default(); + let gc = Gc::new_in(GcRefCell::new(0u64), &nc); + + *gc.borrow_mut() = 99; + nc.collect(); + assert_eq!(*gc.borrow(), 99u64, "mutation lost after collect()"); +} + +#[test] +fn clone_shares_allocation() { + let nc = NullCollector::default(); + let gc = Gc::new_in(GcRefCell::new(1u32), &nc); + let gc2 = gc.clone(); + + assert!( + Gc::ptr_eq(&gc, &gc2), + "clone must alias the same allocation" + ); + *gc.borrow_mut() = 2; + assert_eq!(*gc2.borrow(), 2u32, "clone must observe mutation"); +} + +#[test] +fn nested_gc_value() { + use oscars_derive::{Finalize, Trace}; + + #[derive(Finalize, Trace)] + struct Wrapper { + inner: Gc, + } + + let nc = NullCollector::default() + .with_page_size(128) + .with_heap_threshold(1024); + + let inner = Gc::new_in(42u64, &nc); + let outer = Gc::new_in( + Wrapper { + inner: inner.clone(), + }, + &nc, + ); + + nc.collect(); + + assert_eq!(*inner, 42u64); + assert_eq!(*outer.inner, 42u64); +} + +#[test] +fn weak_gc_always_alive_while_collector_lives() { + let nc = NullCollector::default(); + let strong = Gc::new_in(10u32, &nc); + let weak = WeakGc::new_in(&strong, &nc); + + assert!( + weak.upgrade().is_some(), + "WeakGc must be upgradeable on a NullCollector" + ); + + drop(strong); + nc.collect(); +} + +#[test] +fn weak_map_insert_and_get() { + let nc = NullCollector::default(); + let key = Gc::new_in(1u64, &nc); + let mut map = WeakMap::new(&nc); + + map.insert(&key, 100u64, &nc); + assert_eq!( + map.get(&key), + Some(&100u64), + "WeakMap::get must return the inserted value" + ); +} + +#[test] +fn weak_map_drops_cleanly() { + let nc = NullCollector::default(); + let key = Gc::new_in(7u64, &nc); + { + let mut map = WeakMap::new(&nc); + map.insert(&key, 42u64, &nc); + } + nc.collect(); + drop(key); +} + +#[test] +fn pools_len_reflects_allocations() { + let nc = NullCollector::default() + .with_page_size(64) + .with_heap_threshold(512); + + assert_eq!(nc.pools_len(), 0, "initially empty"); + + let _gc = Gc::new_in(1u64, &nc); + assert!(nc.pools_len() >= 1, "at least one pool after first alloc"); +} + +#[test] +fn builder_methods_compile_and_work() { + let nc = NullCollector::default() + .with_page_size(4096) + .with_heap_threshold(8192); + + let gc = Gc::new_in(GcRefCell::new(77u64), &nc); + nc.collect(); + assert_eq!(*gc.borrow(), 77u64); +} diff --git a/oscars/src/lib.rs b/oscars/src/lib.rs index 7786ea0..7add5cd 100644 --- a/oscars/src/lib.rs +++ b/oscars/src/lib.rs @@ -25,5 +25,9 @@ pub mod mark_sweep2 { #[cfg(feature = "mark_sweep")] pub use crate::collectors::mark_sweep::Collector; +pub mod null_collector { + pub use crate::collectors::null_collector::*; +} + pub mod alloc; pub mod collectors;