sema_core/cycle.rs
1//! CORE-2 cycle collector (ADR #66, `docs/plans/2026-07-02-core2-gc.md`).
2//!
3//! Synchronous Bacon–Rajan trial deletion (Bacon & Rajan 2001: MarkRoots /
4//! ScanRoots / CollectRoots with markGray / scan / scanBlack / collectWhite)
5//! adapted to run *over* `std::rc::Rc`: no per-object headers, no color bits —
6//! all collection state lives in a transient side map keyed by the `Rc`
7//! allocation's data pointer. Cycles are reclaimed by *severing* the mutable
8//! cell every Sema cycle must pass through (invariant I1: env bindings,
9//! upvalue cells, `Thunk.forced`, promise state, channel buffers, multimethod
10//! tables, mutable-array elements, mutable-cell slots) and letting the
11//! ordinary `Rc` drop cascade free the memory.
12//!
13//! Candidate discovery is a creation-time registry of the only objects that
14//! can be *born into* cycles (plan §4 option B), not a decrement buffer —
15//! `Value::drop` and call dispatch stay untouched. Registry entries are
16//! `Weak`, so non-cyclic garbage self-prunes at zero cost.
17//!
18//! Thread-local, single-threaded, std-only (wasm32-compatible) — the same
19//! pattern as the interner and the eval callbacks.
20
21use std::any::{Any, TypeId};
22use std::cell::{Cell, RefCell};
23use std::rc::{Rc, Weak};
24
25use hashbrown::{hash_map, HashMap, HashSet};
26use lasso::Spur;
27
28use crate::runtime::{ChannelId, PromiseId};
29use crate::value::{AsyncPromise, Channel, Env, NativeFn, Value, ValueViewRef};
30use crate::value::{MultiMethod, MutableArray, MutableCell, Thunk};
31
32/// The shared bindings allocation of an [`Env`] — the env's *node identity*
33/// for the collector. `Env` is clone-by-value and multiple `Env` handles (and
34/// `Rc<Env>` wrappers) share one bindings `Rc`, so reachability is tracked on
35/// the bindings allocation, not on any particular handle.
36pub type EnvBindings = RefCell<hashbrown::HashMap<Spur, Value>>;
37
38// ── Node identity ─────────────────────────────────────────────────
39
40/// Identity of a traced heap allocation: the `Rc`'s data pointer.
41///
42/// Never dereferenced by the collector itself except through the typed
43/// handles it holds; opaque participants (sema-vm's `UpvalueCell`, test node
44/// types) recover their `&T` from it inside their own `trace`/`sever` fns.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct NodePtr(*const u8);
47
48impl std::hash::Hash for NodePtr {
49 fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
50 // One usize write, so PtrHasher::write_usize sees the raw address.
51 state.write_usize(self.0 as usize);
52 }
53}
54
55/// Hasher for [`NodePtr`]-keyed collector maps. Keys are unique allocation
56/// addresses, so a single Fibonacci multiply (splitmix/golden-ratio constant)
57/// spreads them across hashbrown's control bytes without running a
58/// general-purpose byte hasher per probe — the side map takes several probes
59/// per traced node, which makes this one of the hottest operations in a
60/// collection pass.
61#[derive(Default, Clone, Copy)]
62struct PtrHasher(u64);
63
64impl std::hash::Hasher for PtrHasher {
65 #[inline]
66 fn finish(&self) -> u64 {
67 self.0
68 }
69
70 fn write(&mut self, bytes: &[u8]) {
71 // Only pointer-sized keys are expected; fold anything else in so the
72 // hasher stays correct for arbitrary composite keys.
73 for &b in bytes {
74 self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x9E37_79B9_7F4A_7C15);
75 }
76 self.0 ^= self.0 >> 32;
77 }
78
79 #[inline]
80 fn write_usize(&mut self, n: usize) {
81 // The multiply pushes entropy toward the high bits; fold them back
82 // down because hashbrown takes the bucket index from the LOW bits
83 // (aligned pointers have zero low bits, and a bare multiply keeps
84 // them zero — every key would land in 1/8th of the buckets).
85 let h = (n as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
86 self.0 = h ^ (h >> 32);
87 }
88}
89
90type BuildPtrHasher = std::hash::BuildHasherDefault<PtrHasher>;
91type PtrMap<V> = HashMap<NodePtr, V, BuildPtrHasher>;
92type PtrSet = HashSet<NodePtr, BuildPtrHasher>;
93
94impl NodePtr {
95 /// The raw data pointer (for opaque trace/sever fns to recover `&T`).
96 pub fn raw(self) -> *const u8 {
97 self.0
98 }
99
100 /// Node identity of any `Rc` allocation (works for `Rc<dyn Any>` too).
101 pub fn of_rc<T: ?Sized>(rc: &Rc<T>) -> NodePtr {
102 NodePtr(Rc::as_ptr(rc).cast())
103 }
104
105 /// Node identity of a cycle-capable heap value. `None` for immediates and
106 /// leaf heap types (strings, bytevectors, numeric arrays, big ints,
107 /// prompts, messages, conversations, streams, bignums, rationals,
108 /// complex) — leaves cannot sit on a cycle and are never given nodes.
109 pub fn of_value(v: &Value) -> Option<NodePtr> {
110 value_node_ptr(v)
111 }
112
113 /// Node identity of an env: its shared bindings allocation. This is the
114 /// pointer to pass in `collect`'s pin set for session root envs.
115 pub fn of_env_bindings(env: &Env) -> NodePtr {
116 NodePtr(Rc::as_ptr(&env.bindings).cast())
117 }
118}
119
120// ── Registry ──────────────────────────────────────────────────────
121
122/// A registered cycle-birth candidate. Registered once at creation by the
123/// (cold) constructors of the only objects that can be born into cycles;
124/// upvalue cells and interior `Value` nodes are *discovered* during trace
125/// (via [`GcEdge`]), never registered.
126pub enum GcNode {
127 /// A VM closure's `NativeFn` wrapper (registered by `make_closure`).
128 ClosureFn(Weak<NativeFn>),
129 /// An env *wrapper* allocation, registered on first home-adoption. The
130 /// wrapper (not just its bindings) is the candidate so a pass can reach
131 /// the whole parent chain: a cycle that closes through an ANCESTOR env's
132 /// bindings (e.g. module code `set!`-ing a root binding to a closure
133 /// homed in the module) is reachable from the home wrapper via `parent`
134 /// edges even when no registered bindings map holds the closure.
135 EnvWrapper(Weak<Env>),
136 /// An env's bindings allocation (data-path registration; home adoption
137 /// registers the wrapper above, which reaches the bindings anyway).
138 EnvBindings(Weak<EnvBindings>),
139 /// `delay` thunk (data-only cycles via `forced`).
140 Thunk(Weak<Thunk>),
141 /// Channel handle (data-only cycles via the runtime's channel BUFFER, which
142 /// lives in the `ChannelRegistry`, reached via the runtime interior hooks).
143 /// The `id` is retained so a prune of a dead handle can also evict the
144 /// registry record — the handle `Weak` alone cannot recover it once dead.
145 Channel { weak: Weak<Channel>, id: ChannelId },
146 /// Promise handle (data-only cycles via the runtime's SETTLED value, which
147 /// lives in the `PromiseRegistry`, reached via the runtime interior hooks).
148 /// The `id` is retained for the same dead-handle eviction reason.
149 Promise {
150 weak: Weak<AsyncPromise>,
151 id: PromiseId,
152 },
153 /// Multimethod (data-only cycles via the method table).
154 MultiMethod(Weak<MultiMethod>),
155 /// Mutable array (data-only cycles via the element vector).
156 MutableArray(Weak<MutableArray>),
157 /// Mutable cell (data-only cycles via the value slot).
158 MutableCell(Weak<MutableCell>),
159}
160
161impl GcNode {
162 /// Current strong count of the registered allocation (0 = dead entry).
163 fn strong_count(&self) -> usize {
164 match self {
165 GcNode::ClosureFn(w) => w.strong_count(),
166 GcNode::EnvWrapper(w) => w.strong_count(),
167 GcNode::EnvBindings(w) => w.strong_count(),
168 GcNode::Thunk(w) => w.strong_count(),
169 GcNode::Channel { weak, .. } => weak.strong_count(),
170 GcNode::Promise { weak, .. } => weak.strong_count(),
171 GcNode::MultiMethod(w) => w.strong_count(),
172 GcNode::MutableArray(w) => w.strong_count(),
173 GcNode::MutableCell(w) => w.strong_count(),
174 }
175 }
176
177 /// Upgrade a live entry into (node identity, strong snapshot handle).
178 /// The handle's own +1 on the strong count is subtracted back out by the
179 /// collector's snapshot-adjust set.
180 fn upgrade_handle(&self) -> Option<(NodePtr, NodeHandle)> {
181 match self {
182 GcNode::ClosureFn(w) => w.upgrade().map(|rc| {
183 let ptr = NodePtr::of_rc(&rc);
184 (ptr, NodeHandle::Value(Value::native_fn_from_rc(rc)))
185 }),
186 GcNode::EnvWrapper(w) => w
187 .upgrade()
188 .map(|rc| (NodePtr::of_rc(&rc), NodeHandle::EnvWrapper(rc))),
189 GcNode::EnvBindings(w) => w
190 .upgrade()
191 .map(|rc| (NodePtr::of_rc(&rc), NodeHandle::Bindings(rc))),
192 GcNode::Thunk(w) => w.upgrade().map(|rc| {
193 let ptr = NodePtr::of_rc(&rc);
194 (ptr, NodeHandle::Value(Value::thunk_from_rc(rc)))
195 }),
196 GcNode::Channel { weak, .. } => weak.upgrade().map(|rc| {
197 let ptr = NodePtr::of_rc(&rc);
198 (ptr, NodeHandle::Value(Value::channel_from_rc(rc)))
199 }),
200 GcNode::Promise { weak, .. } => weak.upgrade().map(|rc| {
201 let ptr = NodePtr::of_rc(&rc);
202 (ptr, NodeHandle::Value(Value::async_promise_from_rc(rc)))
203 }),
204 GcNode::MultiMethod(w) => w.upgrade().map(|rc| {
205 let ptr = NodePtr::of_rc(&rc);
206 (ptr, NodeHandle::Value(Value::multimethod_from_rc(rc)))
207 }),
208 GcNode::MutableArray(w) => w.upgrade().map(|rc| {
209 let ptr = NodePtr::of_rc(&rc);
210 (ptr, NodeHandle::Value(Value::mutable_array_from_rc(rc)))
211 }),
212 GcNode::MutableCell(w) => w.upgrade().map(|rc| {
213 let ptr = NodePtr::of_rc(&rc);
214 (ptr, NodeHandle::Value(Value::mutable_cell_from_rc(rc)))
215 }),
216 }
217 }
218
219 /// When a channel/promise candidate is pruned because its handle `Weak` went
220 /// dead, the runtime's registry still holds the (now unreachable) record —
221 /// evict it so the registry stays O(live handles), not O(total births). No
222 /// other node kind owns runtime-registry state, so this is a no-op for them.
223 fn evict_dead_registry_record(&self, hooks: &RuntimeInteriorHooks) {
224 match self {
225 GcNode::Channel { id, .. } => (hooks.evict_channel)(*id),
226 GcNode::Promise { id, .. } => (hooks.evict_promise)(*id),
227 _ => {}
228 }
229 }
230}
231
232// ── Runtime interior hooks ────────────────────────────────────────
233
234/// Hooks into the async runtime's registries, letting the collector see, sever,
235/// and evict the interior of channel and promise HANDLES. Unlike every other
236/// cycle-capable value, a channel/promise handle's mutable state (buffer /
237/// settled value) does not live inline in the handle `Rc` — it lives in the
238/// runtime's `ChannelRegistry` / `PromiseRegistry`, keyed by id. Without these
239/// hooks a cycle routed through a channel buffer (a closure captured into a
240/// channel that reaches the channel again) is held alive by the registry and is
241/// invisible to trial deletion — a leak.
242///
243/// Registered by sema-vm at runtime construction (same seam as the eval
244/// callbacks, keeping sema-core independent of sema-vm). Every field is a
245/// non-capturing `fn` that reaches the driving runtime through a sema-vm
246/// thread-local, so invariant I2 holds (no captured `Value`/`Env` state). When
247/// no runtime is driving (or none is registered) the trace hooks report no
248/// interior edge — the handle is treated as a leaf, which is always safe.
249/// Enumerate the interior (registry-held) edges of a channel/promise handle id.
250/// Returns `false` if the registry `RefCell` was unavailable (aborts the pass).
251pub type ChannelTraceFn = fn(ChannelId, &mut dyn FnMut(GcEdge)) -> bool;
252pub type PromiseTraceFn = fn(PromiseId, &mut dyn FnMut(GcEdge)) -> bool;
253
254#[derive(Clone, Copy)]
255pub struct RuntimeInteriorHooks {
256 /// Emit one `GcEdge::Value` per value the channel's buffer holds (with exact
257 /// multiplicity — each is one strong `Rc` the registry owns). `false` aborts
258 /// the pass (the registry `RefCell` was unavailable).
259 pub trace_channel: ChannelTraceFn,
260 /// Drain the channel's buffer, returning its contents for deferred drop.
261 pub sever_channel: fn(ChannelId) -> Vec<Value>,
262 /// Remove a channel record whose handle is gone (only if it has no parked
263 /// senders/receivers — a waiter keeps the record until the task is reaped).
264 pub evict_channel: fn(ChannelId),
265 /// Emit the promise's settled value, if any, as a `GcEdge::Value`.
266 pub trace_promise: PromiseTraceFn,
267 /// Clear the promise's settled value, returning it for deferred drop.
268 pub sever_promise: fn(PromiseId) -> Vec<Value>,
269 /// Remove a settled promise record whose handle is gone (only if it has no
270 /// waiters).
271 pub evict_promise: fn(PromiseId),
272}
273
274/// Register (or clear, with `None`) the runtime interior hooks. Idempotent;
275/// sema-vm installs the same hook table on every `Runtime::new`.
276pub fn set_runtime_interior_hooks(hooks: Option<RuntimeInteriorHooks>) {
277 GC.with(|gc| gc.interior.set(hooks));
278}
279
280fn runtime_interior_hooks() -> Option<RuntimeInteriorHooks> {
281 GC.with(|gc| gc.interior.get())
282}
283
284// ── Edges ─────────────────────────────────────────────────────────
285
286/// Enumerates the children of an opaque node. `NodePtr` is the pointer the
287/// node was reported with; the collector guarantees the allocation is alive
288/// for the duration of the collection. Returns `false` if a `RefCell` it
289/// needed was unavailable (aborts the collection cleanly).
290pub type OpaqueTraceFn = fn(NodePtr, &mut dyn FnMut(GcEdge)) -> bool;
291
292/// Severs an opaque node's mutable cell, returning the extracted contents so
293/// the collector can defer the drop until all severing has completed (the
294/// `Rc` cascade must run on a fully-severed heap).
295pub type OpaqueSeverFn = fn(NodePtr) -> Option<Value>;
296
297/// One outgoing strong reference, reported once per strong `Rc` held — trial
298/// deletion is arithmetic on these, so multiplicity must be exact
299/// (undercount ⇒ a leak stays; overcount ⇒ frees live data).
300pub enum GcEdge<'a> {
301 /// A strong reference to the heap allocation behind a `Value`
302 /// (immediates and leaf heap types are ignored by the collector).
303 Value(&'a Value),
304 /// A strong reference to an `Rc<Env>` *wrapper* allocation (e.g.
305 /// `Env.parent`, `Closure.globals`). The wrapper is its own node whose
306 /// children are its bindings allocation and its parent wrapper.
307 Env(&'a Rc<Env>),
308 /// A strong reference to an env's shared bindings allocation, as held
309 /// directly by an `Env` handle embedded by value (e.g. `Lambda.env`).
310 EnvBindings(&'a Rc<EnvBindings>),
311 /// A sema-vm-owned node (`UpvalueCell`) sema-core cannot type: identity +
312 /// current strong count + how to enumerate its children and sever it.
313 Opaque {
314 ptr: NodePtr,
315 strong_count: usize,
316 trace: OpaqueTraceFn,
317 sever: OpaqueSeverFn,
318 },
319}
320
321/// Registered by sema-vm at startup (the standard function-pointer seam,
322/// keeping sema-core independent of sema-vm). Reports **all** heap edges
323/// owned by the whole `NativeFn` — its payload `Rc`s *including the payload
324/// allocation itself* (as an [`GcEdge::Opaque`], with the exact number of
325/// strong refs the `NativeFn` holds to it) and everything the boxed fn
326/// captures. Returns `false` to abort the collection (unborrowable cell).
327pub type PayloadTracer = fn(&Rc<dyn Any>, &mut dyn FnMut(GcEdge)) -> bool;
328
329// ── Stats ─────────────────────────────────────────────────────────
330
331/// Which safe point requested a collection pass. Purely observational —
332/// recorded on the [`GcPassEvent`] so telemetry can attribute collector work
333/// to the code path that triggered it; the pass itself runs identically for
334/// every trigger.
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336pub enum GcTrigger {
337 /// Registry growth crossed the collection threshold at a candidate birth
338 /// (`make_closure` or a data-cycle constructor).
339 Threshold,
340 /// Top-level eval return (REPL line, script form, embedded eval).
341 EvalReturn,
342 /// Interpreter teardown (`Interpreter::drop`).
343 InterpreterDrop,
344 /// Notebook cell eval return.
345 NotebookCell,
346 /// Notebook kernel reset mop-up.
347 NotebookReset,
348 /// Agent tool-loop turn boundary.
349 AgentTurn,
350 /// Cooperative scheduler went idle (all tasks done and reaped).
351 SchedulerIdle,
352 /// Explicit request: `(gc/collect)`, REPL `,gc`, or a host call.
353 Explicit,
354}
355
356impl GcTrigger {
357 /// Stable lowercase-kebab name, for span/metric attributes.
358 pub fn as_str(self) -> &'static str {
359 match self {
360 GcTrigger::Threshold => "threshold",
361 GcTrigger::EvalReturn => "eval-return",
362 GcTrigger::InterpreterDrop => "interpreter-drop",
363 GcTrigger::NotebookCell => "notebook-cell",
364 GcTrigger::NotebookReset => "notebook-reset",
365 GcTrigger::AgentTurn => "agent-turn",
366 GcTrigger::SchedulerIdle => "scheduler-idle",
367 GcTrigger::Explicit => "explicit",
368 }
369 }
370}
371
372/// One collector pass, as reported to the [`set_gc_observer`] observer. Fires
373/// for every pass that actually ran — including aborted ones (visible via
374/// `stats.aborted`) and prune-only fast passes — but never for a
375/// [`maybe_collect`] that stayed below the threshold.
376#[derive(Debug, Clone, Copy)]
377pub struct GcPassEvent {
378 /// The safe point that requested the pass.
379 pub trigger: GcTrigger,
380 /// The pass's result.
381 pub stats: GcStats,
382 /// Registry length (live + not-yet-pruned dead entries) when the pass
383 /// started.
384 pub registry_len_before: usize,
385 /// Wall-clock duration of the pass. Zero on wasm32 (no monotonic clock).
386 pub duration_ns: u64,
387}
388
389/// Register (or clear, with `None`) the per-pass observer. Thread-local, like
390/// all collector state; registered by the host's telemetry wiring (sema-otel
391/// via sema-llm — sema-core cannot depend on either, the same seam as the
392/// eval callbacks). The observer is a plain `fn` so it cannot capture
393/// `Value`/`Env` state (invariant I2 applies to it as it does to native fns);
394/// it runs after the pass has fully completed — the heap is never touched
395/// mid-callback — and must not call back into the collector. When no observer
396/// is registered a pass pays one thread-local `Option` load and nothing else;
397/// the no-pass path (`maybe_collect` below threshold) pays nothing.
398pub fn set_gc_observer(observer: Option<fn(&GcPassEvent)>) {
399 GC.with(|gc| gc.observer.set(observer));
400}
401
402/// Result of one collection pass.
403#[derive(Debug, Default, Clone, Copy)]
404pub struct GcStats {
405 /// Live registry entries scanned as trial-deletion roots.
406 pub candidates: usize,
407 /// Nodes visited (side-map size).
408 pub traced: usize,
409 /// White (garbage) nodes identified and severed/reclaimed.
410 pub collected: usize,
411 /// Registry entries removed: dead `Weak`s, plus duplicate entries for a
412 /// live allocation (one is kept).
413 pub pruned: usize,
414 /// True if the pass stopped before severing anything (a needed `RefCell`
415 /// was borrowed, or a collection was already running). Nothing was
416 /// mutated; a later collect can reclaim.
417 pub aborted: bool,
418}
419
420impl GcStats {
421 /// All-zero stats (`Default`, usable in `const` contexts).
422 pub const fn new() -> Self {
423 GcStats {
424 candidates: 0,
425 traced: 0,
426 collected: 0,
427 pruned: 0,
428 aborted: false,
429 }
430 }
431}
432
433// ── Thread-local collector state ──────────────────────────────────
434
435/// Collection-trigger floor: a pass runs no earlier than this many registry
436/// entries. Bounded by the churn leak oracle — the last un-collected batch
437/// (≈ floor × cycle size) is what a long eval retains at its high-water mark.
438const GC_FLOOR: usize = 1024;
439
440/// Survivor multiplier for the growth threshold (CPython's generation-0
441/// heuristic flattened to one generation): after a pass leaving S live
442/// entries, the next threshold-triggered pass waits for the registry to
443/// exceed `max(GC_FLOOR, GC_GROWTH × S)`. Live candidates are re-traced
444/// every pass (a registry, unlike a decrement buffer, cannot drop a
445/// proven-live entry), so the multiplier is what keeps live-closure-heavy
446/// workloads from paying O(live) tracing per O(live) births: at 4×, tracing
447/// S live entries is amortized over ≥ 3S births. Peak uncollected garbage is
448/// bounded by the same expression (M4 measured 4× as the knee where the
449/// closure-storm live-set tax drops under the gate with no oracle regression;
450/// 2× doubled the pass count for no memory benefit worth having).
451const GC_GROWTH: usize = 4;
452
453/// All collector state for one thread, behind a single `thread_local` so the
454/// hot path (`register_closure_birth`, one call per VM closure creation)
455/// pays one TLS access instead of one per sub-structure.
456struct GcState {
457 registry: RefCell<Vec<GcNode>>,
458 /// Seen-set for env home-adoption registration (plan §8: in the
459 /// registry, no core-type change). Keyed by wrapper-allocation identity;
460 /// the `Weak` value both proves liveness and pins the allocation's
461 /// address against reuse while the entry exists. Pruned alongside the
462 /// registry.
463 env_seen: RefCell<PtrMap<Weak<Env>>>,
464 payload_tracers: RefCell<HashMap<TypeId, PayloadTracer>>,
465 collecting: Cell<bool>,
466 last_survivors: Cell<usize>,
467 /// Registry length that triggers the next threshold collect —
468 /// `max(GC_FLOOR, GC_GROWTH × last survivors)`, precomputed at the end
469 /// of each pass so the birth path compares two integers.
470 threshold: Cell<usize>,
471 /// Stats of the last *completed* (non-aborted) pass, for `(gc/stats)`.
472 last_stats: Cell<GcStats>,
473 /// Pass observer ([`set_gc_observer`]); `None` = observation disabled.
474 observer: Cell<Option<fn(&GcPassEvent)>>,
475 /// Runtime interior hooks ([`set_runtime_interior_hooks`]); `None` when no
476 /// async runtime is wired (e.g. bare sema-core tests) — channel/promise
477 /// handles then trace as leaves.
478 interior: Cell<Option<RuntimeInteriorHooks>>,
479 /// Reusable pass buffers (owned by at most one pass at a time — the
480 /// `collecting` guard excludes reentry before the scratch is taken).
481 scratch: RefCell<Scratch>,
482}
483
484impl GcState {
485 fn new() -> Self {
486 GcState {
487 registry: RefCell::new(Vec::new()),
488 env_seen: RefCell::new(PtrMap::default()),
489 payload_tracers: RefCell::new(HashMap::new()),
490 collecting: Cell::new(false),
491 last_survivors: Cell::new(0),
492 threshold: Cell::new(GC_FLOOR),
493 last_stats: Cell::new(GcStats::new()),
494 observer: Cell::new(None),
495 interior: Cell::new(None),
496 scratch: RefCell::new(Scratch::default()),
497 }
498 }
499
500 /// Registry-growth trigger — two integer loads.
501 fn past_threshold(&self) -> bool {
502 !self.collecting.get() && self.registry.borrow().len() > self.threshold.get()
503 }
504
505 /// Register `home` as an env-wrapper candidate on first adoption.
506 fn adopt_home(&self, home: &Rc<Env>) -> bool {
507 match self.env_seen.borrow_mut().entry(NodePtr::of_rc(home)) {
508 hash_map::Entry::Occupied(entry) => {
509 // `home` is alive at this address, and the entry's `Weak`
510 // pins whatever allocation it was created from — same
511 // address ⇒ same (still live) allocation.
512 debug_assert!(entry.get().strong_count() > 0);
513 false
514 }
515 hash_map::Entry::Vacant(entry) => {
516 let weak = Rc::downgrade(home);
517 entry.insert(weak.clone());
518 self.registry.borrow_mut().push(GcNode::EnvWrapper(weak));
519 true
520 }
521 }
522 }
523}
524
525thread_local! {
526 static GC: GcState = GcState::new();
527}
528
529/// Register a cycle-birth candidate. One O(1) push — no dedup: each of
530/// these objects registers exactly once, at creation. Envs are the exception
531/// (they register at *home adoption*, which recurs per closure) and must go
532/// through [`register_env_candidate`] or [`register_closure_birth`] instead.
533/// `Weak`: never keeps garbage alive; dead entries self-prune during
534/// [`collect`], as does any duplicate entry for a live allocation.
535///
536/// Data births share the registry-growth trigger with closure births (plan
537/// §5.2): a push that crosses the threshold runs a [`threshold_collect`]
538/// right here, so a long eval that churns channels/thunks/promises/
539/// multimethods without ever creating a closure still prunes its dead
540/// entries — each one pins its allocation's `RcBox` through the `Weak` —
541/// and reclaims data-only cycles mid-eval, instead of retaining O(total
542/// births) until an outer safe point that a server-style `(loop …)` never
543/// reaches. The pass runs unpinned (sema-core has no view of the session
544/// env; pins are a pure optimization, exactly as at the agent-turn safe
545/// point): acyclic churn — the common case — takes the prune-only fast path
546/// and never traces, and a full trace amortizes over the ≥ 3×-survivors
547/// births the growth policy requires between passes. These constructors are
548/// cold (never in numeric hot loops), so the threshold compare is free in
549/// practice.
550pub fn register_candidate(node: GcNode) {
551 let past_threshold = GC.with(|gc| {
552 gc.registry.borrow_mut().push(node);
553 gc.past_threshold()
554 });
555 if past_threshold {
556 threshold_collect(&[], GcTrigger::Threshold);
557 }
558}
559
560/// Register an env wrapper as a cycle candidate, exactly once per wrapper
561/// allocation. Env candidates are discovered at *home adoption* (a closure
562/// taking the env as its `globals` home), which repeats for every closure
563/// the env homes — a workload like recursive-closure churn adopts one
564/// long-lived env hundreds of thousands of times, so this entry point
565/// deduplicates through a [`NodePtr`]-keyed seen-set. Returns whether this
566/// call registered the env (`false` = already registered).
567///
568/// Address reuse is sound: a seen entry's `Weak` keeps the wrapper
569/// allocation's memory pinned, so its address cannot be recycled by a fresh
570/// env while the entry exists, and entries are pruned alongside the registry
571/// during [`collect`].
572pub fn register_env_candidate(env: &Rc<Env>) -> bool {
573 GC.with(|gc| gc.adopt_home(env))
574}
575
576/// Cycle-birth registration for the VM's `make_closure` — the only hot
577/// candidate producer. One call (one TLS access) registers the closure's
578/// home env on first adoption and the closure wrapper itself, and reports
579/// whether the registry has grown past the collection threshold — the
580/// caller's cue to run a threshold safe-point [`collect`].
581///
582/// `home` is `None` when the caller already adopted this env (callers may
583/// cache the last adopted wrapper and skip the seen-set probe); `closure`
584/// is `None` when the caller proved the closure exempt from candidacy (it
585/// captured zero upvalues — see the exemption argument at the
586/// `make_closure` call site). Its home env is still adopted.
587pub fn register_closure_birth(home: Option<&Rc<Env>>, closure: Option<&Rc<NativeFn>>) -> bool {
588 GC.with(|gc| {
589 if let Some(home) = home {
590 gc.adopt_home(home);
591 }
592 if !gc.collecting.get() {
593 let mut reg = gc.registry.borrow_mut();
594 if let Some(nf) = closure {
595 reg.push(GcNode::ClosureFn(Rc::downgrade(nf)));
596 }
597 reg.len() > gc.threshold.get()
598 } else {
599 // Defensive: no candidate producer can run inside a pass (a pass
600 // runs no user code), but stay a strict no-trigger if one ever
601 // does.
602 if let Some(nf) = closure {
603 gc.registry
604 .borrow_mut()
605 .push(GcNode::ClosureFn(Rc::downgrade(nf)));
606 }
607 false
608 }
609 })
610}
611
612/// Register the tracer for a `NativeFn.payload` concrete type. A `NativeFn`
613/// whose payload has no registered tracer is treated as externally referenced
614/// (pinned — never collected, never descended into): conservative and safe.
615pub fn register_payload_tracer(type_id: TypeId, tracer: PayloadTracer) {
616 GC.with(|gc| {
617 gc.payload_tracers.borrow_mut().insert(type_id, tracer);
618 });
619}
620
621fn registered_payload_tracer(type_id: TypeId) -> Option<PayloadTracer> {
622 GC.with(|gc| gc.payload_tracers.borrow().get(&type_id).copied())
623}
624
625// ── Tracing ───────────────────────────────────────────────────────
626
627/// Enumerate the outgoing heap edges of `v`'s own allocation, with exact
628/// multiplicity (one `sink` call per strong `Rc` held), per the trace model
629/// in the plan §3. Interior containers report their elements; the six
630/// severable cells report their current contents; leaves report nothing.
631/// Returns `false` if a `RefCell` was unavailable (collection must abort).
632pub fn trace_value(v: &Value, sink: &mut dyn FnMut(GcEdge)) -> bool {
633 match v.view_ref() {
634 ValueViewRef::List(items) | ValueViewRef::Vector(items) => {
635 for item in items {
636 sink(GcEdge::Value(item));
637 }
638 true
639 }
640 ValueViewRef::Map(map) => {
641 for (k, val) in map {
642 sink(GcEdge::Value(k));
643 sink(GcEdge::Value(val));
644 }
645 true
646 }
647 ValueViewRef::HashMap(map) => {
648 for (k, val) in map {
649 sink(GcEdge::Value(k));
650 sink(GcEdge::Value(val));
651 }
652 true
653 }
654 ValueViewRef::Record(r) => {
655 for field in &r.fields {
656 sink(GcEdge::Value(field));
657 }
658 true
659 }
660 ValueViewRef::ToolDef(t) => {
661 sink(GcEdge::Value(&t.parameters));
662 sink(GcEdge::Value(&t.handler));
663 true
664 }
665 ValueViewRef::Agent(a) => {
666 for tool in &a.tools {
667 sink(GcEdge::Value(tool));
668 }
669 true
670 }
671 ValueViewRef::Thunk(t) => {
672 sink(GcEdge::Value(&t.body));
673 match t.forced.try_borrow() {
674 Ok(forced) => {
675 if let Some(fv) = forced.as_ref() {
676 sink(GcEdge::Value(fv));
677 }
678 true
679 }
680 Err(_) => false,
681 }
682 }
683 // A promise handle carries only a runtime `PromiseId`; its settled value
684 // lives in the runtime's `PromiseRegistry`. Reach it through the runtime
685 // interior hook so a cycle through a settled promise is discovered. No
686 // hook (no driving runtime) ⇒ trace as a leaf.
687 ValueViewRef::AsyncPromise(p) => match runtime_interior_hooks() {
688 Some(hooks) => (hooks.trace_promise)(p.id, sink),
689 None => true,
690 },
691 // A channel handle carries only a runtime `ChannelId`; its buffered
692 // values live in the runtime's `ChannelRegistry`. Reach them through the
693 // runtime interior hook so a cycle through the buffer is discovered. No
694 // hook (no driving runtime) ⇒ trace as a leaf.
695 ValueViewRef::Channel(c) => match runtime_interior_hooks() {
696 Some(hooks) => (hooks.trace_channel)(c.id, sink),
697 None => true,
698 },
699 ValueViewRef::MutableArray(a) => match a.items.try_borrow() {
700 Ok(items) => {
701 for item in items.iter() {
702 sink(GcEdge::Value(item));
703 }
704 true
705 }
706 Err(_) => false,
707 },
708 ValueViewRef::MutableCell(c) => match c.value.try_borrow() {
709 Ok(slot) => {
710 sink(GcEdge::Value(&slot));
711 true
712 }
713 Err(_) => false,
714 },
715 ValueViewRef::MultiMethod(m) => {
716 sink(GcEdge::Value(&m.dispatch_fn));
717 match m.methods.try_borrow() {
718 Ok(methods) => {
719 for (k, val) in methods.iter() {
720 sink(GcEdge::Value(k));
721 sink(GcEdge::Value(val));
722 }
723 }
724 Err(_) => return false,
725 }
726 match m.default.try_borrow() {
727 Ok(default) => {
728 if let Some(dv) = default.as_ref() {
729 sink(GcEdge::Value(dv));
730 }
731 true
732 }
733 Err(_) => false,
734 }
735 }
736 ValueViewRef::Macro(m) => {
737 // Macro bodies are literal templates; traced for safety.
738 for expr in &m.body {
739 sink(GcEdge::Value(expr));
740 }
741 // syntax-rules transformers hold quoted pattern/template data as
742 // Values — trace them so they are not untraced roots.
743 if let Some(sr) = &m.syntax_rules {
744 for (pat, tmpl) in &sr.rules {
745 sink(GcEdge::Value(pat));
746 sink(GcEdge::Value(tmpl));
747 }
748 }
749 true
750 }
751 ValueViewRef::Lambda(l) => {
752 for expr in &l.body {
753 sink(GcEdge::Value(expr));
754 }
755 // The lambda embeds its `Env` by value, so it holds one strong
756 // ref to the bindings allocation and one to the parent wrapper.
757 sink(GcEdge::EnvBindings(&l.env.bindings));
758 if let Some(parent) = &l.env.parent {
759 sink(GcEdge::Env(parent));
760 }
761 true
762 }
763 ValueViewRef::NativeFn(nf) => match &nf.payload {
764 // Invariant I2: a payload-less NativeFn's box captures nothing
765 // that can hold a Value or Env — it is a true leaf.
766 None => true,
767 Some(p) => match registered_payload_tracer(Any::type_id(&**p)) {
768 Some(tracer) => tracer(p, sink),
769 // Unknown payload type: report nothing here; the collector
770 // pins the node (treated as externally referenced).
771 None => true,
772 },
773 },
774 // Leaves (strings, bytevectors, numeric arrays, big ints, prompts,
775 // messages, conversations, streams) and immediates: no edges.
776 _ => true,
777 }
778}
779
780// ── Collection ────────────────────────────────────────────────────
781
782/// Run a full synchronous collection. `pins` = node pointers whose interiors
783/// are not descended into (session root envs — a pure optimization: pinned
784/// nodes are externally referenced by definition and marked black
785/// immediately). Caller guarantees the safe-point invariant (no outstanding
786/// env/cell borrows); if a borrow is found anyway, the pass aborts cleanly
787/// having mutated nothing. `trigger` is observational only (see [`GcTrigger`]).
788pub fn collect(pins: &[NodePtr], trigger: GcTrigger) -> GcStats {
789 collect_impl(pins, false, trigger)
790}
791
792/// Threshold safe-point collect ([`maybe_collect`] and the `make_closure`
793/// birth trigger): prunes dead registry entries first, and skips the trace
794/// when pruning alone brought the registry down to half the growth
795/// threshold — the signature of acyclic churn, where closures die by plain
796/// `Rc` drop and only their dead `Weak` entries accumulate. A skipped pass
797/// does NOT update survivors or the threshold (its candidates are unproven:
798/// they may include garbage cycles, and feeding them into the threshold
799/// would defer cycle detection geometrically), so cyclic garbage still
800/// forces a real trace as soon as it exceeds half the threshold — the same
801/// memory envelope the growth policy already allows. Explicit collects
802/// ([`collect`]: `(gc/collect)`, interpreter teardown) always trace.
803pub fn threshold_collect(pins: &[NodePtr], trigger: GcTrigger) -> GcStats {
804 collect_impl(pins, true, trigger)
805}
806
807/// Observation wrapper around [`run_pass`]: when an observer is registered,
808/// time the pass and report a [`GcPassEvent`] for it (completed, prune-only,
809/// or aborted alike). Unobserved passes skip straight through — one
810/// thread-local `Option` load of overhead.
811fn collect_impl(pins: &[NodePtr], threshold_pass: bool, trigger: GcTrigger) -> GcStats {
812 let Some(observer) = GC.with(|gc| gc.observer.get()) else {
813 return run_pass(pins, threshold_pass);
814 };
815 let registry_len_before = registry_len();
816 // `Instant::now` is unavailable on wasm32-unknown-unknown; an observer
817 // registered there (none is today — sema-otel is a no-op on wasm) sees
818 // duration 0 rather than a panic.
819 #[cfg(not(target_arch = "wasm32"))]
820 let start = Some(std::time::Instant::now());
821 #[cfg(target_arch = "wasm32")]
822 let start: Option<std::time::Instant> = None;
823 let stats = run_pass(pins, threshold_pass);
824 let duration_ns = start.map_or(0, |t| t.elapsed().as_nanos() as u64);
825 observer(&GcPassEvent {
826 trigger,
827 stats,
828 registry_len_before,
829 duration_ns,
830 });
831 stats
832}
833
834fn run_pass(pins: &[NodePtr], threshold_pass: bool) -> GcStats {
835 if GC.with(|gc| gc.collecting.get()) {
836 // Reentrancy guard: severing cascades `Value::drop`s, which must not
837 // re-enter the collector.
838 return GcStats {
839 aborted: true,
840 ..GcStats::default()
841 };
842 }
843 let _guard = CollectingGuard::engage();
844
845 // Take the reusable pass buffers (put back, reset, on every exit path).
846 // `collecting` excludes reentry, so the scratch is never taken twice.
847 let mut st = Collector {
848 s: GC.with(|gc| std::mem::take(&mut *gc.scratch.borrow_mut())),
849 aborted: false,
850 };
851 st.s.pins.extend(pins.iter().copied());
852
853 // 1. Snapshot + prune: upgrade live registry entries into strong handles
854 // and seed them straight into the side map (residual = strong − 1,
855 // the handle's own +1 pre-subtracted), drop dead ones. Duplicate
856 // registrations of one live allocation are pruned here — the extra
857 // handle is dropped before any other count is read, and one entry
858 // suffices to keep the object a candidate (so duplicates don't
859 // inflate the registry or the survivor-derived threshold).
860 let mut pruned = 0usize;
861 let interior = runtime_interior_hooks();
862 GC.with(|gc| {
863 gc.registry
864 .borrow_mut()
865 .retain(|node| match node.upgrade_handle() {
866 Some((ptr, handle)) => {
867 if st.s.nodes.contains_key(&ptr) {
868 pruned += 1;
869 false
870 } else {
871 st.seed_candidate(ptr, &handle);
872 st.s.snapshot.push((ptr, handle));
873 true
874 }
875 }
876 None => {
877 // A dead channel/promise handle leaves its registry record
878 // stranded; evict it so the runtime registry stays bounded.
879 if let Some(hooks) = &interior {
880 node.evict_dead_registry_record(hooks);
881 }
882 pruned += 1;
883 false
884 }
885 });
886 });
887 let candidates = st.s.snapshot.len();
888
889 // Prune-only fast pass: see [`threshold_collect`]. `candidates` counts
890 // live-at-snapshot entries only, so the comparison is against what the
891 // prune could not remove. Nothing has been traced yet (seeded nodes are
892 // queued, not descended), so bailing here costs only the seeding.
893 if threshold_pass && candidates <= GC.with(|gc| gc.threshold.get()) / 2 {
894 let stats = GcStats {
895 candidates,
896 traced: 0,
897 collected: 0,
898 pruned,
899 aborted: false,
900 };
901 let mut scratch = st.s;
902 scratch.reset();
903 GC.with(|gc| {
904 *gc.scratch.borrow_mut() = scratch;
905 gc.last_stats.set(stats);
906 });
907 return stats;
908 }
909
910 // 2. MarkGray: trial-delete from every seeded candidate over a shared
911 // side map, so overlapping subgraphs are traced once.
912 st.drain_pending();
913 if st.aborted {
914 // Nothing has been mutated: drop the side map and snapshot untouched.
915 let stats = GcStats {
916 candidates,
917 traced: st.s.nodes.len(),
918 collected: 0,
919 pruned,
920 aborted: true,
921 };
922 let mut scratch = st.s;
923 scratch.reset();
924 GC.with(|gc| *gc.scratch.borrow_mut() = scratch);
925 return stats;
926 }
927
928 // 3. Scan: residual count > 0 ⇒ externally referenced ⇒ scan_black
929 // (restore counts, blacken transitively); the rest tentatively white.
930 for i in 0..st.s.snapshot.len() {
931 let root = st.s.snapshot[i].0;
932 st.scan_node(root);
933 }
934
935 // 4. CollectWhite: identify the full white set first, then sever. All
936 // extracted cell contents are deferred into `Scratch::severed` so the
937 // Rc drop cascade runs on a fully-severed heap.
938 let collected = st.collect_white();
939 let traced = st.s.nodes.len();
940
941 // Release pass state (side-map handles, then severed values, then the
942 // snapshot — see `Scratch::reset`); the Rc cascade happens here, after
943 // all severing completed.
944 let mut scratch = st.s;
945 scratch.reset();
946 GC.with(|gc| *gc.scratch.borrow_mut() = scratch);
947
948 // Entries reclaimed by the cascade above are dead now; prune them so the
949 // survivor count (and the growth threshold derived from it) is exact.
950 // A pass that severed nothing ran no cascade — liveness is unchanged
951 // since the snapshot prune, so the sweep (a strong-count read per entry)
952 // is skipped and the snapshot's live count is the survivor count.
953 GC.with(|gc| {
954 let survivors = if collected == 0 {
955 gc.registry.borrow().len()
956 } else {
957 let mut reg = gc.registry.borrow_mut();
958 reg.retain(|node| {
959 let live = node.strong_count() > 0;
960 if !live {
961 pruned += 1;
962 }
963 live
964 });
965 let survivors = reg.len();
966 drop(reg);
967 // The env seen-set prunes in lockstep: dropping a dead entry's
968 // `Weak` unpins the allocation, so a later env reusing the
969 // address is correctly treated as unseen. (An aborted pass skips
970 // this; the next completed pass catches up — stale dead entries
971 // can never match a live env.)
972 gc.env_seen
973 .borrow_mut()
974 .retain(|_, weak| weak.strong_count() > 0);
975 survivors
976 };
977 gc.last_survivors.set(survivors);
978 gc.threshold
979 .set(std::cmp::max(GC_FLOOR, GC_GROWTH * survivors));
980 });
981
982 let stats = GcStats {
983 candidates,
984 traced,
985 collected,
986 pruned,
987 aborted: false,
988 };
989 GC.with(|gc| gc.last_stats.set(stats));
990 stats
991}
992
993/// Stats of the last completed collection pass (all-zero before the first
994/// one). Aborted passes mutate nothing and are not recorded.
995pub fn last_stats() -> GcStats {
996 GC.with(|gc| gc.last_stats.get())
997}
998
999/// Current registry length (live + not-yet-pruned dead entries) — the value
1000/// the growth threshold is checked against.
1001pub fn registry_len() -> usize {
1002 GC.with(|gc| gc.registry.borrow().len())
1003}
1004
1005/// True when the registry has grown past the collection threshold and no
1006/// collection is already running — the cheap pre-check that lets safe
1007/// points skip building a pin set when no pass would run. (`make_closure`
1008/// gets this for free from [`register_closure_birth`]'s return value.)
1009pub fn should_collect() -> bool {
1010 GC.with(|gc| gc.past_threshold())
1011}
1012
1013/// Pin set for a session root env: the wrapper allocation, its bindings, and
1014/// every ancestor wrapper/bindings up the parent chain. Passing this to
1015/// [`collect`]/[`maybe_collect`] keeps a pass from descending the live global
1016/// namespace (a pure optimization — pinned nodes are externally referenced by
1017/// definition).
1018pub fn env_chain_pins(env: &Rc<Env>) -> Vec<NodePtr> {
1019 let mut pins = vec![NodePtr::of_rc(env), NodePtr::of_env_bindings(env)];
1020 let mut parent = env.parent.clone();
1021 while let Some(p) = parent {
1022 pins.push(NodePtr::of_rc(&p));
1023 pins.push(NodePtr::of_env_bindings(&p));
1024 parent = p.parent.clone();
1025 }
1026 pins
1027}
1028
1029/// Threshold-gated [`threshold_collect`] for safe points: runs when the
1030/// registry has grown past `max(GC_FLOOR, GC_GROWTH × survivors of the last
1031/// collect)` (CPython's generation-0 heuristic flattened to one generation).
1032pub fn maybe_collect(pins: &[NodePtr], trigger: GcTrigger) -> Option<GcStats> {
1033 should_collect().then(|| threshold_collect(pins, trigger))
1034}
1035
1036/// RAII guard for the thread-local `collecting` flag: engaged for the whole
1037/// pass, released on every exit path (including abort).
1038struct CollectingGuard;
1039
1040impl CollectingGuard {
1041 fn engage() -> Self {
1042 GC.with(|gc| gc.collecting.set(true));
1043 CollectingGuard
1044 }
1045}
1046
1047impl Drop for CollectingGuard {
1048 fn drop(&mut self) {
1049 GC.with(|gc| gc.collecting.set(false));
1050 }
1051}
1052
1053// ── Side map ──────────────────────────────────────────────────────
1054
1055#[derive(Clone, Copy, PartialEq, Eq)]
1056enum Color {
1057 /// Visited by MarkGray; membership in a garbage cycle still open.
1058 Gray,
1059 /// Proven externally referenced (or pinned); kept.
1060 Black,
1061 /// Trial deletion zeroed every strong count: garbage, to be severed.
1062 White,
1063}
1064
1065/// Strong handle to a traced node, kept in the side map so every traced
1066/// allocation stays alive (and severable) for the duration of the pass.
1067/// Cloned/taken only *after* the node's strong count has been recorded, so
1068/// the handle itself is invisible to the trial-deletion arithmetic.
1069#[derive(Clone)]
1070enum NodeHandle {
1071 /// Any cycle-capable heap value (containers, thunk, channel, promise,
1072 /// multimethod, macro, lambda, NativeFn).
1073 Value(Value),
1074 /// An `Rc<Env>` wrapper allocation.
1075 EnvWrapper(Rc<Env>),
1076 /// An env's shared bindings allocation.
1077 Bindings(Rc<EnvBindings>),
1078 /// A foreign node: no owned handle (the graph keeps it alive), just its
1079 /// trace/sever behavior.
1080 Opaque {
1081 trace: OpaqueTraceFn,
1082 sever: OpaqueSeverFn,
1083 },
1084}
1085
1086struct NodeState {
1087 /// Residual strong count: seeded from `Rc::strong_count` (minus the
1088 /// snapshot's own handle), decremented once per traced incoming edge.
1089 count: isize,
1090 color: Color,
1091 /// Whether MarkGray enumerated this node's children (pinned and
1092 /// unknown-payload nodes are never descended).
1093 descended: bool,
1094 /// `(start, len)` range into the pass's shared edge arena
1095 /// ([`Scratch::edges`]): the node's outgoing edges recorded during
1096 /// MarkGray, with multiplicity — the later phases replay these instead
1097 /// of re-tracing, so no `RefCell` is touched again until severing, and
1098 /// no per-node `Vec` is allocated (a churn pass traces thousands of
1099 /// nodes; one arena beats thousands of tiny allocations).
1100 children: (u32, u32),
1101 handle: NodeHandle,
1102}
1103
1104/// Reusable collection buffers, kept in a thread-local and recycled across
1105/// passes (`clear()` keeps capacity): threshold-driven passes run every ~1k
1106/// closure births under churn, and rebuilding the side map from scratch —
1107/// growth rehashes included — dominated pass cost before reuse.
1108#[derive(Default)]
1109struct Scratch {
1110 nodes: PtrMap<NodeState>,
1111 /// Shared children arena; see [`NodeState::children`].
1112 edges: Vec<NodePtr>,
1113 /// MarkGray worklist: nodes inserted but not yet descended. Every phase
1114 /// walks the graph with explicit worklists rather than Rust recursion —
1115 /// graph depth is user-controlled (deeply nested lists, long env parent
1116 /// chains), and a per-level native stack frame overflows (uncatchable
1117 /// SIGABRT) at depths ordinary Sema data reaches.
1118 pending: Vec<NodePtr>,
1119 pins: PtrSet,
1120 /// Live registry entries upgraded into strong handles for the pass.
1121 snapshot: Vec<(NodePtr, NodeHandle)>,
1122 /// The complete white set, identified before any severing starts.
1123 whites: Vec<NodePtr>,
1124 /// Extracted cell contents, dropped only after all severing completed.
1125 severed: Vec<Value>,
1126 /// Scan worklist (disjoint from `black_work`: scan_black runs while a
1127 /// scan_node traversal is still in flight).
1128 scan_work: Vec<NodePtr>,
1129 black_work: Vec<NodePtr>,
1130}
1131
1132impl Scratch {
1133 /// Drop pass state and return the buffers to capacity-preserving empty.
1134 /// Order matters: side-map handles first, then the extracted severed
1135 /// contents, then the snapshot — the `Rc` drop cascade must run on a
1136 /// fully-severed heap, exactly like the local-variable drop order the
1137 /// collector used before buffer reuse.
1138 fn reset(&mut self) {
1139 self.nodes.clear();
1140 self.severed.clear();
1141 self.snapshot.clear();
1142 self.edges.clear();
1143 self.pending.clear();
1144 self.pins.clear();
1145 self.whites.clear();
1146 self.scan_work.clear();
1147 self.black_work.clear();
1148 }
1149}
1150
1151struct Collector {
1152 s: Scratch,
1153 aborted: bool,
1154}
1155
1156impl Collector {
1157 // -- MarkGray --
1158
1159 /// Seed a snapshot candidate into the side map: residual count starts at
1160 /// `strong − 1` (the snapshot handle's own +1 pre-subtracted), colored
1161 /// gray and queued for descent — trial deletion then treats it exactly
1162 /// like a discovered node. Candidates are starting points, not edges:
1163 /// no decrement beyond the handle adjustment.
1164 fn seed_candidate(&mut self, ptr: NodePtr, handle: &NodeHandle) {
1165 let strong = match handle {
1166 NodeHandle::Value(v) => v
1167 .heap_strong_count()
1168 .expect("registered candidates are heap allocations"),
1169 NodeHandle::EnvWrapper(rc) => Rc::strong_count(rc),
1170 NodeHandle::Bindings(rc) => Rc::strong_count(rc),
1171 NodeHandle::Opaque { .. } => {
1172 unreachable!("opaque nodes are discovered via edges, never registered")
1173 }
1174 };
1175 let pin = matches!(handle, NodeHandle::Value(v) if has_unknown_payload(v));
1176 self.insert_node(ptr, strong - 1, pin, handle.clone());
1177 }
1178
1179 /// MarkGray driver: descend queued nodes until the worklist is empty.
1180 /// Each node is queued exactly once (at insert), so this is one bounded
1181 /// pass over the subgraph with O(1) native stack per node.
1182 fn drain_pending(&mut self) {
1183 while let Some(ptr) = self.s.pending.pop() {
1184 if self.aborted {
1185 return;
1186 }
1187 self.descend(ptr);
1188 }
1189 }
1190
1191 /// Process one traced edge: ensure the target node exists (queueing it
1192 /// for descent) and decrement its residual count for this edge. Returns
1193 /// the target's pointer so the caller can record the edge for the later
1194 /// phases (`None` for immediates and leaves, which never become nodes).
1195 fn gray_edge(&mut self, edge: GcEdge<'_>) -> Option<NodePtr> {
1196 match edge {
1197 GcEdge::Value(v) => {
1198 let ptr = value_node_ptr(v)?;
1199 if !self.s.nodes.contains_key(&ptr) {
1200 let strong = v
1201 .heap_strong_count()
1202 .expect("node values are heap allocations");
1203 let pin = has_unknown_payload(v);
1204 self.insert_node(ptr, strong, pin, NodeHandle::Value(v.clone()));
1205 }
1206 self.dec(ptr);
1207 Some(ptr)
1208 }
1209 GcEdge::Env(rc) => {
1210 let ptr = NodePtr::of_rc(rc);
1211 if !self.s.nodes.contains_key(&ptr) {
1212 let strong = Rc::strong_count(rc);
1213 self.insert_node(ptr, strong, false, NodeHandle::EnvWrapper(rc.clone()));
1214 }
1215 self.dec(ptr);
1216 Some(ptr)
1217 }
1218 GcEdge::EnvBindings(rc) => {
1219 let ptr = NodePtr::of_rc(rc);
1220 if !self.s.nodes.contains_key(&ptr) {
1221 let strong = Rc::strong_count(rc);
1222 self.insert_node(ptr, strong, false, NodeHandle::Bindings(rc.clone()));
1223 }
1224 self.dec(ptr);
1225 Some(ptr)
1226 }
1227 GcEdge::Opaque {
1228 ptr,
1229 strong_count,
1230 trace,
1231 sever,
1232 } => {
1233 if !self.s.nodes.contains_key(&ptr) {
1234 self.insert_node(
1235 ptr,
1236 strong_count,
1237 false,
1238 NodeHandle::Opaque { trace, sever },
1239 );
1240 }
1241 self.dec(ptr);
1242 Some(ptr)
1243 }
1244 }
1245 }
1246
1247 /// First sighting of a node: record its residual count (the caller has
1248 /// already excluded any snapshot-handle contribution), color it, and
1249 /// queue it for descent. Pinned nodes are black from birth and never
1250 /// descended (never queued).
1251 fn insert_node(&mut self, ptr: NodePtr, count: usize, pinned_extra: bool, handle: NodeHandle) {
1252 let pinned = pinned_extra || self.s.pins.contains(&ptr);
1253 self.s.nodes.insert(
1254 ptr,
1255 NodeState {
1256 count: count as isize,
1257 color: if pinned { Color::Black } else { Color::Gray },
1258 descended: pinned,
1259 children: (0, 0),
1260 handle,
1261 },
1262 );
1263 if !pinned {
1264 self.s.pending.push(ptr);
1265 }
1266 }
1267
1268 fn dec(&mut self, ptr: NodePtr) {
1269 self.s
1270 .nodes
1271 .get_mut(&ptr)
1272 .expect("dec target was just ensured")
1273 .count -= 1;
1274 }
1275
1276 /// Enumerate a node's children once, decrementing each target and
1277 /// recording the edge range (with multiplicity) for the later phases.
1278 /// Newly discovered children are queued on the worklist, not descended
1279 /// inline — native stack use is O(1) regardless of graph depth, and each
1280 /// node's edges land in one contiguous arena slice (descents never nest).
1281 fn descend(&mut self, ptr: NodePtr) {
1282 let handle = match self.s.nodes.get_mut(&ptr) {
1283 Some(node) if !node.descended => {
1284 node.descended = true;
1285 node.handle.clone()
1286 }
1287 _ => return,
1288 };
1289 let start = self.s.edges.len();
1290 let ok = {
1291 let this = &mut *self;
1292 let mut sink = |edge: GcEdge<'_>| {
1293 if let Some(child) = this.gray_edge(edge) {
1294 this.s.edges.push(child);
1295 }
1296 };
1297 match &handle {
1298 NodeHandle::Value(v) => trace_value(v, &mut sink),
1299 NodeHandle::EnvWrapper(env) => {
1300 sink(GcEdge::EnvBindings(&env.bindings));
1301 if let Some(parent) = &env.parent {
1302 sink(GcEdge::Env(parent));
1303 }
1304 true
1305 }
1306 NodeHandle::Bindings(bindings) => match bindings.try_borrow() {
1307 Ok(map) => {
1308 for value in map.values() {
1309 sink(GcEdge::Value(value));
1310 }
1311 true
1312 }
1313 Err(_) => false,
1314 },
1315 NodeHandle::Opaque { trace, .. } => trace(ptr, &mut sink),
1316 }
1317 };
1318 if !ok {
1319 self.aborted = true;
1320 return;
1321 }
1322 let len = self.s.edges.len() - start;
1323 self.s
1324 .nodes
1325 .get_mut(&ptr)
1326 .expect("descended node exists")
1327 .children = (start as u32, len as u32);
1328 }
1329
1330 // -- Scan / ScanBlack (replayed on the recorded side graph; explicit
1331 // worklists, same depth rationale as `pending`) --
1332
1333 /// Partition the gray subgraph under `root`: residual count > 0 ⇒
1334 /// externally referenced ⇒ [`Self::scan_black`]; residual 0 ⇒ tentatively
1335 /// white, children scanned in turn. Order-independent: counts only grow
1336 /// in this phase, and every increment blackens its target, so a node
1337 /// still gray when popped carries exactly its MarkGray residual.
1338 fn scan_node(&mut self, root: NodePtr) {
1339 debug_assert!(self.s.scan_work.is_empty());
1340 self.s.scan_work.push(root);
1341 while let Some(ptr) = self.s.scan_work.pop() {
1342 let Some(node) = self.s.nodes.get_mut(&ptr) else {
1343 continue;
1344 };
1345 if node.color != Color::Gray {
1346 continue;
1347 }
1348 if node.count > 0 {
1349 self.scan_black(ptr);
1350 } else {
1351 node.color = Color::White;
1352 let (start, len) = node.children;
1353 let range = start as usize..(start + len) as usize;
1354 self.s.scan_work.extend_from_slice(&self.s.edges[range]);
1355 }
1356 }
1357 }
1358
1359 /// Externally referenced: blacken the subgraph and restore the counts
1360 /// trial deletion took (one re-increment per recorded edge). Each node is
1361 /// blackened at most once and its edges replayed exactly once, so the
1362 /// restore arithmetic is exact. (Separate worklist from `scan_node`'s: a
1363 /// scan traversal is still in flight when this runs.)
1364 fn scan_black(&mut self, root: NodePtr) {
1365 self.s
1366 .nodes
1367 .get_mut(&root)
1368 .expect("scan_black node exists")
1369 .color = Color::Black;
1370 debug_assert!(self.s.black_work.is_empty());
1371 self.s.black_work.push(root);
1372 while let Some(ptr) = self.s.black_work.pop() {
1373 let (start, len) = self.s.nodes[&ptr].children;
1374 for i in start as usize..(start + len) as usize {
1375 let child = self.s.edges[i];
1376 let child_node = self.s.nodes.get_mut(&child).expect("recorded child exists");
1377 child_node.count += 1;
1378 if child_node.color != Color::Black {
1379 child_node.color = Color::Black;
1380 self.s.black_work.push(child);
1381 }
1382 }
1383 }
1384 }
1385
1386 // -- CollectWhite --
1387
1388 /// Identify the complete white set, then sever: version-bump every white
1389 /// env wrapper first (inline-cache hygiene), then clear each white node's
1390 /// mutable cell, deferring all extracted contents into `Scratch::severed`.
1391 fn collect_white(&mut self) -> usize {
1392 debug_assert!(self.s.whites.is_empty());
1393 let whites = &mut self.s.whites;
1394 whites.extend(
1395 self.s
1396 .nodes
1397 .iter()
1398 .filter(|(_, node)| node.color == Color::White)
1399 .map(|(ptr, _)| *ptr),
1400 );
1401 for i in 0..self.s.whites.len() {
1402 let ptr = self.s.whites[i];
1403 if let NodeHandle::EnvWrapper(env) = &self.s.nodes[&ptr].handle {
1404 sever_white_env_wrapper(env);
1405 }
1406 }
1407 for i in 0..self.s.whites.len() {
1408 let ptr = self.s.whites[i];
1409 sever_node(ptr, &self.s.nodes[&ptr].handle, &mut self.s.severed);
1410 }
1411 self.s.whites.len()
1412 }
1413}
1414
1415/// True for a `NativeFn` carrying a payload whose type has no registered
1416/// tracer: its edges are invisible, so the node is pinned (kept) instead.
1417fn has_unknown_payload(v: &Value) -> bool {
1418 match v.view_ref() {
1419 ValueViewRef::NativeFn(nf) => match &nf.payload {
1420 Some(p) => registered_payload_tracer(Any::type_id(&**p)).is_none(),
1421 None => false,
1422 },
1423 _ => false,
1424 }
1425}
1426
1427/// Clear a white node's mutable cell per the plan §3 "severed how" column,
1428/// extracting the contents into `severed` (dropped by the caller after all
1429/// severing completes). White nodes are unreachable from any live
1430/// borrow-holder — a held borrow implies a live stack reference implies an
1431/// unaccounted strong count implies black — so the `try_borrow_mut`s here
1432/// cannot fail; that impossibility is debug-asserted, and in release a
1433/// failure degrades to keeping the node (leak-safe).
1434fn sever_node(ptr: NodePtr, handle: &NodeHandle, severed: &mut Vec<Value>) {
1435 match handle {
1436 NodeHandle::Bindings(bindings) => match bindings.try_borrow_mut() {
1437 Ok(mut map) => severed.extend(map.drain().map(|(_, value)| value)),
1438 Err(_) => debug_assert!(false, "white env bindings borrowed during severing"),
1439 },
1440 // Version bump already done in the first pass; the parent edge is
1441 // immutable and dies with the wrapper in the cascade.
1442 NodeHandle::EnvWrapper(_) => {}
1443 NodeHandle::Opaque { sever, .. } => severed.extend(sever(ptr)),
1444 NodeHandle::Value(v) => match v.view_ref() {
1445 ValueViewRef::Thunk(t) => match t.forced.try_borrow_mut() {
1446 Ok(mut forced) => severed.extend(forced.take()),
1447 Err(_) => debug_assert!(false, "white thunk borrowed during severing"),
1448 },
1449 // Channel/promise interior lives in the runtime registry, not inline
1450 // in the handle — sever it through the interior hook (drain the
1451 // buffer / clear the settled value). No hook ⇒ nothing to sever
1452 // (the handle was a leaf this pass).
1453 ValueViewRef::Channel(c) => {
1454 if let Some(hooks) = runtime_interior_hooks() {
1455 severed.extend((hooks.sever_channel)(c.id));
1456 }
1457 }
1458 ValueViewRef::AsyncPromise(p) => {
1459 if let Some(hooks) = runtime_interior_hooks() {
1460 severed.extend((hooks.sever_promise)(p.id));
1461 }
1462 }
1463 ValueViewRef::MutableArray(a) => match a.items.try_borrow_mut() {
1464 Ok(mut items) => severed.extend(items.drain(..)),
1465 Err(_) => debug_assert!(false, "white mutable array borrowed during severing"),
1466 },
1467 ValueViewRef::MutableCell(c) => match c.value.try_borrow_mut() {
1468 Ok(mut slot) => severed.push(std::mem::replace(&mut *slot, Value::NIL)),
1469 Err(_) => debug_assert!(false, "white mutable cell borrowed during severing"),
1470 },
1471 ValueViewRef::MultiMethod(m) => {
1472 match m.methods.try_borrow_mut() {
1473 Ok(mut methods) => {
1474 for (k, value) in std::mem::take(&mut *methods) {
1475 severed.push(k);
1476 severed.push(value);
1477 }
1478 }
1479 Err(_) => debug_assert!(false, "white multimethod borrowed during severing"),
1480 }
1481 match m.default.try_borrow_mut() {
1482 Ok(mut default) => severed.extend(default.take()),
1483 Err(_) => debug_assert!(false, "white multimethod borrowed during severing"),
1484 }
1485 }
1486 // Containers, NativeFn, Lambda, Macro: no severable cell of
1487 // their own — reclaimed by the cascade once the cells above are
1488 // cleared (invariant I1: every cycle passes through one).
1489 _ => {}
1490 },
1491 }
1492}
1493
1494// ── Severing helpers ──────────────────────────────────────────────
1495
1496/// Sever a white env *wrapper*: bump the version cell so any surviving VM
1497/// inline-cache entry keyed on (env, version) cannot serve a stale read once
1498/// the shared bindings map is cleared. The wrapper owns no severable cell of
1499/// its own (`parent` is immutable); version hygiene is its entire severing
1500/// step, and it runs before any white bindings map is drained.
1501fn sever_white_env_wrapper(env: &Env) {
1502 env.bump_version();
1503}
1504
1505// ── Internal node classification ──────────────────────────────────
1506
1507/// Node pointer for cycle-capable heap values; `None` for immediates and
1508/// leaf heap types (which can never sit on a cycle).
1509fn value_node_ptr(v: &Value) -> Option<NodePtr> {
1510 let ptr = v.heap_ptr()?;
1511 match v.view_ref() {
1512 ValueViewRef::List(_)
1513 | ValueViewRef::Vector(_)
1514 | ValueViewRef::Map(_)
1515 | ValueViewRef::HashMap(_)
1516 | ValueViewRef::Record(_)
1517 | ValueViewRef::ToolDef(_)
1518 | ValueViewRef::Agent(_)
1519 | ValueViewRef::Thunk(_)
1520 | ValueViewRef::Channel(_)
1521 | ValueViewRef::AsyncPromise(_)
1522 | ValueViewRef::MultiMethod(_)
1523 | ValueViewRef::MutableArray(_)
1524 | ValueViewRef::MutableCell(_)
1525 | ValueViewRef::Macro(_)
1526 | ValueViewRef::Lambda(_)
1527 | ValueViewRef::NativeFn(_) => Some(NodePtr(ptr)),
1528 _ => None,
1529 }
1530}
1531
1532// ── Tests ─────────────────────────────────────────────────────────
1533
1534#[cfg(test)]
1535mod tests {
1536 use super::*;
1537 use crate::value::intern;
1538 use std::collections::BTreeMap;
1539
1540 // -- Test payload types (stand-ins for sema-vm's VmClosurePayload /
1541 // UpvalueCell, exercising the payload-tracer and Opaque paths without
1542 // a sema-vm dependency) --
1543
1544 /// Payload holding a strong `Rc<Env>` wrapper (shape E's closure→env edge).
1545 struct EnvPayload {
1546 env: Rc<Env>,
1547 }
1548
1549 fn env_payload_tracer(p: &Rc<dyn Any>, sink: &mut dyn FnMut(GcEdge)) -> bool {
1550 // The whole NativeFn holds exactly one strong ref to the payload
1551 // allocation (the `payload` field; the test fn's box captures nothing).
1552 sink(GcEdge::Opaque {
1553 ptr: NodePtr::of_rc(p),
1554 strong_count: Rc::strong_count(p),
1555 trace: env_payload_trace,
1556 sever: no_sever,
1557 });
1558 true
1559 }
1560
1561 fn env_payload_trace(ptr: NodePtr, sink: &mut dyn FnMut(GcEdge)) -> bool {
1562 // SAFETY: `ptr` is the data pointer of a live `Rc<EnvPayload>` — the
1563 // collector keeps every traced allocation alive for the duration of
1564 // the collection (snapshot + side-map handles + deferred drops).
1565 let payload = unsafe { &*(ptr.raw() as *const EnvPayload) };
1566 sink(GcEdge::Env(&payload.env));
1567 true
1568 }
1569
1570 fn no_sever(_: NodePtr) -> Option<Value> {
1571 None
1572 }
1573
1574 #[test]
1575 fn registered_payload_emits_opaque_allocation_and_delegates_trace() {
1576 struct RegisteredTracePayload {
1577 env: Rc<Env>,
1578 }
1579
1580 fn registered_trace_payload_trace(ptr: NodePtr, sink: &mut dyn FnMut(GcEdge)) -> bool {
1581 // SAFETY: `ptr` identifies the live `RegisteredTracePayload`
1582 // allocation retained by the NativeFn during tracing.
1583 let payload = unsafe { &*(ptr.raw() as *const RegisteredTracePayload) };
1584 sink(GcEdge::Env(&payload.env));
1585 true
1586 }
1587
1588 fn registered_trace_payload_tracer(
1589 payload: &Rc<dyn Any>,
1590 sink: &mut dyn FnMut(GcEdge),
1591 ) -> bool {
1592 sink(GcEdge::Opaque {
1593 ptr: NodePtr::of_rc(payload),
1594 strong_count: Rc::strong_count(payload),
1595 trace: registered_trace_payload_trace,
1596 sever: no_sever,
1597 });
1598 true
1599 }
1600
1601 fn invoke_registered_payload(
1602 _: &RegisteredTracePayload,
1603 _: &mut crate::runtime::NativeCallContext<'_>,
1604 _: &[Value],
1605 ) -> crate::runtime::NativeResult {
1606 Ok(crate::runtime::NativeOutcome::Return(Value::NIL))
1607 }
1608
1609 register_payload_tracer(
1610 TypeId::of::<RegisteredTracePayload>(),
1611 registered_trace_payload_tracer,
1612 );
1613 let env = Env::new();
1614 let native = Value::native_fn(NativeFn::with_payload_result(
1615 "registered-trace",
1616 Rc::new(RegisteredTracePayload { env: Rc::new(env) }),
1617 invoke_registered_payload,
1618 ));
1619 let mut opaque_count = 0;
1620 let mut opaque = None;
1621 let mut payload_strong_count = None;
1622 assert!(trace_value(&native, &mut |edge| {
1623 opaque_count += 1;
1624 if let GcEdge::Opaque {
1625 ptr,
1626 strong_count,
1627 trace,
1628 ..
1629 } = edge
1630 {
1631 opaque = Some((ptr, trace));
1632 payload_strong_count = Some(strong_count);
1633 }
1634 }));
1635 assert_eq!(opaque_count, 1, "one registered payload allocation");
1636 assert_eq!(payload_strong_count, Some(1), "payload field is sole owner");
1637 let (ptr, trace) = opaque.expect("registered tracer emitted opaque edge");
1638 let mut delegated_count = 0;
1639 let mut delegated_env = false;
1640 assert!(trace(ptr, &mut |edge| {
1641 delegated_count += 1;
1642 delegated_env = matches!(edge, GcEdge::Env(_));
1643 }));
1644 assert_eq!(delegated_count, 1, "one delegated environment edge");
1645 assert!(delegated_env);
1646 }
1647
1648 #[test]
1649 fn unregistered_payload_is_conservatively_pinned() {
1650 struct UnknownPayload;
1651 let native = Value::native_fn(NativeFn::with_payload(
1652 "unknown",
1653 Rc::new(UnknownPayload),
1654 |_, _| Ok(Value::NIL),
1655 ));
1656 let mut edges = 0;
1657 assert!(trace_value(&native, &mut |_| edges += 1));
1658 assert_eq!(edges, 0);
1659 assert!(has_unknown_payload(&native));
1660 }
1661
1662 /// A mutable cell node (UpvalueCell stand-in), participating via Opaque.
1663 struct TestCell {
1664 slot: RefCell<Value>,
1665 }
1666
1667 /// Payload holding a strong `Rc<TestCell>` (shape U's closure→cell edge).
1668 struct CellPayload {
1669 cell: Rc<TestCell>,
1670 }
1671
1672 fn cell_payload_tracer(p: &Rc<dyn Any>, sink: &mut dyn FnMut(GcEdge)) -> bool {
1673 sink(GcEdge::Opaque {
1674 ptr: NodePtr::of_rc(p),
1675 strong_count: Rc::strong_count(p),
1676 trace: cell_payload_trace,
1677 sever: no_sever,
1678 });
1679 true
1680 }
1681
1682 fn cell_payload_trace(ptr: NodePtr, sink: &mut dyn FnMut(GcEdge)) -> bool {
1683 // SAFETY: as in env_payload_trace — live Rc<CellPayload> data pointer.
1684 let payload = unsafe { &*(ptr.raw() as *const CellPayload) };
1685 sink(GcEdge::Opaque {
1686 ptr: NodePtr::of_rc(&payload.cell),
1687 strong_count: Rc::strong_count(&payload.cell),
1688 trace: test_cell_trace,
1689 sever: test_cell_sever,
1690 });
1691 true
1692 }
1693
1694 fn test_cell_trace(ptr: NodePtr, sink: &mut dyn FnMut(GcEdge)) -> bool {
1695 // SAFETY: as above — live Rc<TestCell> data pointer.
1696 let cell = unsafe { &*(ptr.raw() as *const TestCell) };
1697 match cell.slot.try_borrow() {
1698 Ok(slot) => {
1699 sink(GcEdge::Value(&slot));
1700 true
1701 }
1702 Err(_) => false,
1703 }
1704 }
1705
1706 fn test_cell_sever(ptr: NodePtr) -> Option<Value> {
1707 // SAFETY: as above — live Rc<TestCell> data pointer.
1708 let cell = unsafe { &*(ptr.raw() as *const TestCell) };
1709 match cell.slot.try_borrow_mut() {
1710 Ok(mut slot) => Some(std::mem::replace(&mut *slot, Value::NIL)),
1711 Err(_) => {
1712 debug_assert!(false, "white cell borrowed during severing");
1713 None
1714 }
1715 }
1716 }
1717
1718 /// Builds the shape-E graph: env bindings → NativeFn → payload → env
1719 /// wrapper → same bindings. Returns (env, wrapper, payload rc, nf rc).
1720 #[allow(clippy::type_complexity)]
1721 fn build_env_nativefn_cycle() -> (Env, Rc<Env>, Rc<EnvPayload>, Rc<NativeFn>) {
1722 register_payload_tracer(TypeId::of::<EnvPayload>(), env_payload_tracer);
1723 let env = Env::new();
1724 let wrapper = Rc::new(env.clone());
1725 let payload = Rc::new(EnvPayload {
1726 env: wrapper.clone(),
1727 });
1728 let nf = Rc::new(NativeFn::with_payload(
1729 "cyclic",
1730 payload.clone() as Rc<dyn Any>,
1731 |_, _| Ok(Value::NIL),
1732 ));
1733 env.set(intern("self"), Value::native_fn_from_rc(nf.clone()));
1734 (env, wrapper, payload, nf)
1735 }
1736
1737 // 1a. env⇄nativefn garbage cycle is collected.
1738 #[test]
1739 fn env_nativefn_cycle_collected() {
1740 let (env, wrapper, payload, nf) = build_env_nativefn_cycle();
1741 let weak_nf = Rc::downgrade(&nf);
1742 let weak_bindings = Rc::downgrade(&env.bindings);
1743 register_candidate(GcNode::ClosureFn(Rc::downgrade(&nf)));
1744 register_candidate(GcNode::EnvBindings(Rc::downgrade(&env.bindings)));
1745 drop((env, wrapper, payload, nf));
1746 assert!(
1747 weak_nf.upgrade().is_some(),
1748 "cycle keeps the graph alive pre-collect"
1749 );
1750
1751 let stats = collect(&[], GcTrigger::Explicit);
1752
1753 assert!(!stats.aborted);
1754 assert_eq!(stats.candidates, 2, "closure + env bindings registered");
1755 assert_eq!(stats.traced, 4, "nf + payload + wrapper + bindings");
1756 assert_eq!(stats.collected, 4);
1757 assert!(weak_nf.upgrade().is_none(), "NativeFn reclaimed");
1758 assert!(weak_bindings.upgrade().is_none(), "env bindings reclaimed");
1759 }
1760
1761 // 1b. same shape with an external strong ref: kept and still usable.
1762 #[test]
1763 fn env_nativefn_cycle_with_external_ref_kept() {
1764 let (env, wrapper, payload, nf) = build_env_nativefn_cycle();
1765 let weak_nf = Rc::downgrade(&nf);
1766 register_candidate(GcNode::ClosureFn(Rc::downgrade(&nf)));
1767 let keeper = wrapper.clone();
1768 drop((env, wrapper, payload, nf));
1769
1770 let stats = collect(&[], GcTrigger::Explicit);
1771
1772 assert!(!stats.aborted);
1773 assert_eq!(stats.collected, 0, "externally referenced: nothing severed");
1774 assert!(weak_nf.upgrade().is_some());
1775 let looked_up = keeper.get(intern("self"));
1776 assert!(looked_up.is_some(), "binding survives and resolves");
1777 assert!(looked_up.unwrap().is_native_fn());
1778 }
1779
1780 // 2. cycle through an immutable list: cell → list → nativefn → cell.
1781 #[test]
1782 fn cycle_through_immutable_list_collected() {
1783 register_payload_tracer(TypeId::of::<CellPayload>(), cell_payload_tracer);
1784 let cell = Rc::new(TestCell {
1785 slot: RefCell::new(Value::NIL),
1786 });
1787 let payload = Rc::new(CellPayload { cell: cell.clone() });
1788 let nf = Rc::new(NativeFn::with_payload(
1789 "cell-closure",
1790 payload.clone() as Rc<dyn Any>,
1791 |_, _| Ok(Value::NIL),
1792 ));
1793 *cell.slot.borrow_mut() = Value::list(vec![Value::native_fn_from_rc(nf.clone())]);
1794 let weak_nf = Rc::downgrade(&nf);
1795 let weak_cell = Rc::downgrade(&cell);
1796 register_candidate(GcNode::ClosureFn(Rc::downgrade(&nf)));
1797 drop((cell, payload, nf));
1798
1799 let stats = collect(&[], GcTrigger::Explicit);
1800
1801 assert!(!stats.aborted);
1802 assert_eq!(stats.collected, 4, "nf + payload + cell + list");
1803 assert!(weak_nf.upgrade().is_none());
1804 assert_eq!(weak_cell.strong_count(), 0);
1805 }
1806
1807 // Mutable-array self-cycle (an array pushed into itself) collected via
1808 // the constructor's creation-time candidate registration.
1809 #[test]
1810 fn mutable_array_self_cycle_collected() {
1811 let arr = Value::mutable_array(Vec::new());
1812 let a = arr
1813 .as_mutable_array_rc()
1814 .expect("constructor yields a mutable array");
1815 a.items.borrow_mut().push(arr.clone());
1816 let weak = Rc::downgrade(&a);
1817 drop((arr, a));
1818 assert!(
1819 weak.upgrade().is_some(),
1820 "self-cycle keeps the array alive pre-collect"
1821 );
1822
1823 let stats = collect(&[], GcTrigger::Explicit);
1824
1825 assert!(!stats.aborted);
1826 assert!(stats.collected >= 1);
1827 assert_eq!(weak.strong_count(), 0, "array reclaimed");
1828 }
1829
1830 // Mutable-cell self-cycle (a cell set to itself) collected the same way.
1831 #[test]
1832 fn mutable_cell_self_cycle_collected() {
1833 let cell = Value::mutable_cell(Value::NIL);
1834 let c = cell
1835 .as_mutable_cell_rc()
1836 .expect("constructor yields a mutable cell");
1837 *c.value.borrow_mut() = cell.clone();
1838 let weak = Rc::downgrade(&c);
1839 drop((cell, c));
1840 assert!(
1841 weak.upgrade().is_some(),
1842 "self-cycle keeps the cell alive pre-collect"
1843 );
1844
1845 let stats = collect(&[], GcTrigger::Explicit);
1846
1847 assert!(!stats.aborted);
1848 assert!(stats.collected >= 1);
1849 assert_eq!(weak.strong_count(), 0, "cell reclaimed");
1850 }
1851
1852 // A live mutable array next to garbage cycles keeps its contents intact.
1853 #[test]
1854 fn live_mutable_array_kept_intact() {
1855 let arr = Value::mutable_array(vec![Value::int(1), Value::int(2)]);
1856 let stats = collect(&[], GcTrigger::Explicit);
1857 assert!(!stats.aborted);
1858 let a = arr.as_mutable_array().expect("still a mutable array");
1859 assert_eq!(&*a.items.borrow(), &[Value::int(1), Value::int(2)]);
1860 }
1861
1862 // 3. Thunk.forced self-cycle collected; live unforced thunk kept intact.
1863 #[test]
1864 fn forced_thunk_self_cycle_collected() {
1865 let t = Rc::new(Thunk {
1866 body: Value::NIL,
1867 forced: RefCell::new(None),
1868 });
1869 *t.forced.borrow_mut() = Some(Value::thunk_from_rc(t.clone()));
1870 let weak = Rc::downgrade(&t);
1871 register_candidate(GcNode::Thunk(Rc::downgrade(&t)));
1872 drop(t);
1873
1874 let stats = collect(&[], GcTrigger::Explicit);
1875
1876 assert!(!stats.aborted);
1877 assert_eq!(stats.collected, 1);
1878 assert_eq!(weak.strong_count(), 0, "thunk reclaimed");
1879 }
1880
1881 #[test]
1882 fn live_unforced_thunk_kept_intact() {
1883 let body = Value::list(vec![Value::int(1)]);
1884 let t = Rc::new(Thunk {
1885 body: body.clone(),
1886 forced: RefCell::new(None),
1887 });
1888 register_candidate(GcNode::Thunk(Rc::downgrade(&t)));
1889
1890 let stats = collect(&[], GcTrigger::Explicit);
1891
1892 assert!(!stats.aborted);
1893 assert_eq!(stats.collected, 0);
1894 assert!(t.forced.borrow().is_none(), "forced cell untouched");
1895 assert_eq!(t.body, body, "body untouched");
1896 }
1897
1898 // (Retired) A channel containing itself is no longer representable: a
1899 // `Channel` carries only a runtime `ChannelId`, never a `Value`, so it
1900 // cannot close a data cycle and is not a GC candidate. Its buffered values
1901 // live in the runtime's `ChannelRegistry`.
1902
1903 // 5. multimethod whose method value reaches back to it.
1904 #[test]
1905 fn multimethod_method_cycle_collected() {
1906 let mm = Rc::new(MultiMethod {
1907 name: intern("mm"),
1908 dispatch_fn: Value::NIL,
1909 methods: RefCell::new(BTreeMap::new()),
1910 default: RefCell::new(None),
1911 });
1912 mm.methods
1913 .borrow_mut()
1914 .insert(Value::keyword("k"), Value::multimethod_from_rc(mm.clone()));
1915 let weak = Rc::downgrade(&mm);
1916 register_candidate(GcNode::MultiMethod(Rc::downgrade(&mm)));
1917 drop(mm);
1918
1919 let stats = collect(&[], GcTrigger::Explicit);
1920
1921 assert!(!stats.aborted);
1922 assert_eq!(stats.collected, 1);
1923 assert_eq!(weak.strong_count(), 0, "multimethod reclaimed");
1924 }
1925
1926 // (Retired) A promise resolving to itself is no longer representable: an
1927 // `AsyncPromise` carries only a runtime `PromiseId`, never a `Value`, so it
1928 // cannot close a data cycle and is not a GC candidate.
1929
1930 // 6. shared subgraph reachable from TWO candidates: traced once, counts
1931 // exact, collected exactly once.
1932 #[test]
1933 fn shared_subgraph_traced_once_collected_once() {
1934 let t1 = Rc::new(Thunk {
1935 body: Value::NIL,
1936 forced: RefCell::new(None),
1937 });
1938 let t2 = Rc::new(Thunk {
1939 body: Value::NIL,
1940 forced: RefCell::new(None),
1941 });
1942 let shared = Value::list(vec![
1943 Value::thunk_from_rc(t1.clone()),
1944 Value::thunk_from_rc(t2.clone()),
1945 ]);
1946 *t1.forced.borrow_mut() = Some(shared.clone());
1947 *t2.forced.borrow_mut() = Some(shared);
1948 let (w1, w2) = (Rc::downgrade(&t1), Rc::downgrade(&t2));
1949 register_candidate(GcNode::Thunk(Rc::downgrade(&t1)));
1950 register_candidate(GcNode::Thunk(Rc::downgrade(&t2)));
1951 drop((t1, t2));
1952
1953 let stats = collect(&[], GcTrigger::Explicit);
1954
1955 assert!(!stats.aborted);
1956 assert_eq!(stats.candidates, 2);
1957 assert_eq!(stats.traced, 3, "t1 + t2 + shared list, list traced once");
1958 assert_eq!(stats.collected, 3);
1959 assert_eq!(w1.strong_count(), 0);
1960 assert_eq!(w2.strong_count(), 0);
1961 }
1962
1963 // 7. pinned env bindings keep a garbage-shaped cycle alive.
1964 #[test]
1965 fn pinned_env_bindings_keep_cycle() {
1966 let (env, wrapper, payload, nf) = build_env_nativefn_cycle();
1967 let weak_nf = Rc::downgrade(&nf);
1968 let pin = NodePtr::of_env_bindings(&env);
1969 register_candidate(GcNode::ClosureFn(Rc::downgrade(&nf)));
1970 drop((env, wrapper, payload, nf));
1971
1972 let stats = collect(&[pin], GcTrigger::Explicit);
1973 assert!(!stats.aborted);
1974 assert_eq!(stats.collected, 0, "pinned root: nothing severed");
1975 assert!(weak_nf.upgrade().is_some());
1976
1977 // Without the pin the same graph is garbage and is reclaimed.
1978 let stats2 = collect(&[], GcTrigger::Explicit);
1979 assert!(!stats2.aborted);
1980 assert!(stats2.collected >= 4);
1981 assert!(weak_nf.upgrade().is_none());
1982 }
1983
1984 // 8. a held borrow aborts the whole collection; nothing severed; a later
1985 // collect (borrow released) reclaims.
1986 #[test]
1987 fn outstanding_borrow_aborts_collection() {
1988 let (env, wrapper, payload, nf) = build_env_nativefn_cycle();
1989 let weak_nf = Rc::downgrade(&nf);
1990 let bindings = env.bindings.clone();
1991 register_candidate(GcNode::ClosureFn(Rc::downgrade(&nf)));
1992 drop((env, wrapper, payload, nf));
1993
1994 let guard = bindings.borrow_mut();
1995 let stats = collect(&[], GcTrigger::Explicit);
1996 assert!(stats.aborted, "borrowed bindings must abort the pass");
1997 assert_eq!(stats.collected, 0);
1998 assert!(weak_nf.upgrade().is_some(), "graph intact after abort");
1999 assert!(guard.contains_key(&intern("self")), "bindings untouched");
2000 drop(guard);
2001 drop(bindings);
2002
2003 let stats2 = collect(&[], GcTrigger::Explicit);
2004 assert!(!stats2.aborted);
2005 assert!(stats2.collected >= 4);
2006 assert!(weak_nf.upgrade().is_none());
2007 }
2008
2009 // 9. reentrancy guard: collect during a collection is a no-op.
2010 #[test]
2011 fn reentrant_collect_is_noop() {
2012 let t = Rc::new(Thunk {
2013 body: Value::NIL,
2014 forced: RefCell::new(None),
2015 });
2016 *t.forced.borrow_mut() = Some(Value::thunk_from_rc(t.clone()));
2017 let weak = Rc::downgrade(&t);
2018 register_candidate(GcNode::Thunk(Rc::downgrade(&t)));
2019 drop(t);
2020
2021 GC.with(|gc| gc.collecting.set(true));
2022 let stats = collect(&[], GcTrigger::Explicit);
2023 assert!(stats.aborted);
2024 assert_eq!(stats.collected, 0);
2025 assert!(weak.upgrade().is_some(), "no-op left the graph alone");
2026 assert!(maybe_collect(&[], GcTrigger::Threshold).is_none());
2027 GC.with(|gc| gc.collecting.set(false));
2028
2029 collect(&[], GcTrigger::Explicit);
2030 assert_eq!(weak.strong_count(), 0);
2031 }
2032
2033 // 10. the env-wrapper sever step is a version bump (inline-cache hygiene).
2034 // Unobservable post-collect on garbage by construction, so the helper
2035 // is asserted directly.
2036 #[test]
2037 fn severed_env_wrapper_bumps_version() {
2038 let env = Env::new();
2039 let v0 = env.version.get();
2040 sever_white_env_wrapper(&env);
2041 assert_eq!(env.version.get(), v0.wrapping_add(1));
2042 }
2043
2044 // 11. duplicate edges (same NativeFn twice in one list) decrement twice.
2045 #[test]
2046 fn duplicate_edges_have_exact_multiplicity() {
2047 register_payload_tracer(TypeId::of::<CellPayload>(), cell_payload_tracer);
2048 let cell = Rc::new(TestCell {
2049 slot: RefCell::new(Value::NIL),
2050 });
2051 let payload = Rc::new(CellPayload { cell: cell.clone() });
2052 let nf = Rc::new(NativeFn::with_payload(
2053 "twice",
2054 payload.clone() as Rc<dyn Any>,
2055 |_, _| Ok(Value::NIL),
2056 ));
2057 *cell.slot.borrow_mut() = Value::list(vec![
2058 Value::native_fn_from_rc(nf.clone()),
2059 Value::native_fn_from_rc(nf.clone()),
2060 ]);
2061 let weak_nf = Rc::downgrade(&nf);
2062 register_candidate(GcNode::ClosureFn(Rc::downgrade(&nf)));
2063 drop((cell, payload, nf));
2064
2065 let stats = collect(&[], GcTrigger::Explicit);
2066
2067 assert!(!stats.aborted);
2068 assert_eq!(stats.collected, 4, "nf + payload + cell + list");
2069 assert!(weak_nf.upgrade().is_none(), "both list slots accounted");
2070 }
2071
2072 // 12. dead Weak entries are pruned and reported.
2073 #[test]
2074 fn dead_registry_entries_are_pruned() {
2075 let t = Rc::new(Thunk {
2076 body: Value::NIL,
2077 forced: RefCell::new(None),
2078 });
2079 register_candidate(GcNode::Thunk(Rc::downgrade(&t)));
2080 drop(t); // acyclic: plain Rc drop reclaims it before any collect
2081
2082 let stats = collect(&[], GcTrigger::Explicit);
2083
2084 assert_eq!(stats.pruned, 1);
2085 assert_eq!(stats.candidates, 0);
2086 assert_eq!(stats.traced, 0);
2087 assert_eq!(stats.collected, 0);
2088 }
2089
2090 // 13. threshold behavior: maybe_collect stays quiet below the threshold,
2091 // and a data-birth registration that crosses it self-collects inside
2092 // register_candidate — dead-entry retention between outer safe points
2093 // is bounded by the threshold, not by total births.
2094 #[test]
2095 fn maybe_collect_respects_threshold() {
2096 assert!(
2097 maybe_collect(&[], GcTrigger::Threshold).is_none(),
2098 "empty registry: no collection"
2099 );
2100 for _ in 0..1025 {
2101 let t = Rc::new(Thunk {
2102 body: Value::NIL,
2103 forced: RefCell::new(None),
2104 });
2105 register_candidate(GcNode::Thunk(Rc::downgrade(&t)));
2106 }
2107 // The 1025th push crossed GC_FLOOR and ran a threshold pass inline:
2108 // 1024 dead entries pruned, the (then still live) current thunk kept.
2109 let stats = last_stats();
2110 assert_eq!(stats.pruned, 1024, "dead entries pruned at birth trigger");
2111 assert_eq!(stats.candidates, 1, "the in-scope thunk was live");
2112 assert!(registry_len() <= 1, "registry bounded by the trigger");
2113 assert!(
2114 maybe_collect(&[], GcTrigger::Threshold).is_none(),
2115 "already pruned below threshold"
2116 );
2117 }
2118
2119 // trace_value multiplicity spot-checks (the arithmetic's raw material).
2120 #[test]
2121 fn trace_value_reports_exact_container_edges() {
2122 let nf = Rc::new(NativeFn::simple("leaf", |_| Ok(Value::NIL)));
2123 let nf_val = Value::native_fn_from_rc(nf);
2124 let list = Value::list(vec![nf_val.clone(), nf_val.clone(), Value::int(1)]);
2125 let mut edges = 0usize;
2126 assert!(trace_value(&list, &mut |e| {
2127 if let GcEdge::Value(v) = e {
2128 if value_node_ptr(v).is_some() {
2129 edges += 1;
2130 }
2131 }
2132 }));
2133 assert_eq!(edges, 2, "same NativeFn twice = two edges; int = none");
2134 }
2135
2136 #[test]
2137 fn trace_value_reports_forced_thunk_contents() {
2138 let inner = Value::list(vec![Value::int(1)]);
2139 let t = Rc::new(Thunk {
2140 body: inner.clone(),
2141 forced: RefCell::new(Some(inner)),
2142 });
2143 let tv = Value::thunk_from_rc(t);
2144 let mut edges = 0usize;
2145 assert!(trace_value(&tv, &mut |e| {
2146 if matches!(e, GcEdge::Value(_)) {
2147 edges += 1;
2148 }
2149 }));
2150 assert_eq!(edges, 2, "body + forced contents");
2151 }
2152
2153 #[test]
2154 fn trace_value_aborts_on_borrowed_cell() {
2155 let t = Rc::new(Thunk {
2156 body: Value::NIL,
2157 forced: RefCell::new(None),
2158 });
2159 let tv = Value::thunk_from_rc(t.clone());
2160 let guard = t.forced.borrow_mut();
2161 assert!(!trace_value(&tv, &mut |_| {}), "borrowed forced cell");
2162 drop(guard);
2163 assert!(trace_value(&tv, &mut |_| {}));
2164 }
2165
2166 // -- Depth regressions: every collector phase runs on explicit worklists,
2167 // so traversal depth must never consume Rust stack. (The Rc drop
2168 // cascade of *severed* contents still recurses — pre-existing
2169 // Value::drop behavior, deliberately untouched — so the fully-garbage
2170 // deep test stays below drop-glue limits while the worklist tests go
2171 // far past any stack budget.) --
2172
2173 /// Nest `depth` single-element lists, returning a handle to every level
2174 /// (innermost first). Holding all levels lets a test unwind the chain
2175 /// outermost-first, one `Rc` release per pop, without the recursive drop
2176 /// cascade a plain drop of the outermost handle would trigger.
2177 fn deep_chain(depth: usize) -> Vec<Value> {
2178 let mut levels = Vec::with_capacity(depth);
2179 let mut v = Value::int(0);
2180 for _ in 0..depth {
2181 v = Value::list(vec![v]);
2182 levels.push(v.clone());
2183 }
2184 levels
2185 }
2186
2187 /// Drop the chain without deep recursion: outermost-first, each pop
2188 /// releases exactly one level (its child stays alive in the vec).
2189 fn unwind_chain(levels: &mut Vec<Value>) {
2190 while levels.pop().is_some() {}
2191 }
2192
2193 // 14a. deep LIVE structure: MarkGray + ScanBlack walk 100k levels
2194 // (external handles ⇒ root count > 0 ⇒ the whole chain is
2195 // re-blackened) with bounded native stack.
2196 #[test]
2197 fn deep_live_structure_traced_without_stack_overflow() {
2198 const DEPTH: usize = 100_000;
2199 let mut levels = deep_chain(DEPTH);
2200 let outermost = levels.last().expect("nonempty chain").clone();
2201 let t = Rc::new(Thunk {
2202 body: Value::NIL,
2203 forced: RefCell::new(Some(outermost)),
2204 });
2205 register_candidate(GcNode::Thunk(Rc::downgrade(&t)));
2206
2207 let stats = collect(&[], GcTrigger::Explicit);
2208
2209 assert!(!stats.aborted);
2210 assert_eq!(stats.traced, DEPTH + 1, "thunk + every level visited");
2211 assert_eq!(stats.collected, 0, "externally held: everything kept");
2212 assert!(t.forced.borrow().is_some(), "forced cell untouched");
2213 t.forced.borrow_mut().take();
2214 unwind_chain(&mut levels);
2215 }
2216
2217 // 14b. deep structure hanging off a GARBAGE cycle: MarkGray descends
2218 // 100k levels from the dead thunk; only the 2-node cycle is severed
2219 // (the chain stays alive through the external handles).
2220 #[test]
2221 fn deep_garbage_cycle_collected_without_stack_overflow() {
2222 const DEPTH: usize = 100_000;
2223 let mut levels = deep_chain(DEPTH);
2224 let outermost = levels.last().expect("nonempty chain").clone();
2225 let t = Rc::new(Thunk {
2226 body: Value::NIL,
2227 forced: RefCell::new(None),
2228 });
2229 *t.forced.borrow_mut() = Some(Value::list(vec![
2230 outermost,
2231 Value::thunk_from_rc(t.clone()),
2232 ]));
2233 let weak = Rc::downgrade(&t);
2234 register_candidate(GcNode::Thunk(Rc::downgrade(&t)));
2235 drop(t);
2236
2237 let stats = collect(&[], GcTrigger::Explicit);
2238
2239 assert!(!stats.aborted);
2240 assert_eq!(stats.collected, 2, "thunk + forced list; held chain kept");
2241 assert_eq!(weak.strong_count(), 0, "cycle reclaimed");
2242 assert!(
2243 levels.last().expect("chain kept").is_list(),
2244 "chain survives its garbage neighbor"
2245 );
2246 unwind_chain(&mut levels);
2247 }
2248
2249 // 14c. fully-garbage deep chain: the whole chain goes white and the
2250 // severed contents' drop cascades through the unsevered interior
2251 // lists inside collect. Depth sits above the stack-overflow point
2252 // of a per-level recursive MarkGray in debug builds, below the
2253 // (pre-existing) recursive drop cascade's limit.
2254 #[test]
2255 fn deep_garbage_chain_fully_collected() {
2256 const DEPTH: usize = 3_000;
2257 let mut v = Value::int(0);
2258 for _ in 0..DEPTH {
2259 v = Value::list(vec![v]);
2260 }
2261 let t = Rc::new(Thunk {
2262 body: Value::NIL,
2263 forced: RefCell::new(None),
2264 });
2265 *t.forced.borrow_mut() = Some(Value::list(vec![v, Value::thunk_from_rc(t.clone())]));
2266 let weak = Rc::downgrade(&t);
2267 register_candidate(GcNode::Thunk(Rc::downgrade(&t)));
2268 drop(t);
2269
2270 let stats = collect(&[], GcTrigger::Explicit);
2271
2272 assert!(!stats.aborted);
2273 assert_eq!(stats.collected, DEPTH + 2, "chain + wrapper list + thunk");
2274 assert_eq!(weak.strong_count(), 0);
2275 }
2276
2277 // 14d. garbage ring of 100k thunks linked forced→forced: MarkGray and
2278 // Scan's white marking each walk the full ring depth. Every node's
2279 // cell is severed, so the in-collect drops release one thunk at a
2280 // time — deep-white coverage with no deep drop cascade.
2281 #[test]
2282 fn deep_thunk_ring_goes_white_without_stack_overflow() {
2283 const DEPTH: usize = 100_000;
2284 let thunks: Vec<Rc<Thunk>> = (0..DEPTH)
2285 .map(|_| {
2286 Rc::new(Thunk {
2287 body: Value::NIL,
2288 forced: RefCell::new(None),
2289 })
2290 })
2291 .collect();
2292 for (i, t) in thunks.iter().enumerate() {
2293 let next = thunks[(i + 1) % DEPTH].clone();
2294 *t.forced.borrow_mut() = Some(Value::thunk_from_rc(next));
2295 }
2296 let weak = Rc::downgrade(&thunks[0]);
2297 register_candidate(GcNode::Thunk(Rc::downgrade(&thunks[0])));
2298 drop(thunks);
2299
2300 let stats = collect(&[], GcTrigger::Explicit);
2301
2302 assert!(!stats.aborted);
2303 assert_eq!(stats.traced, DEPTH, "every ring node visited");
2304 assert_eq!(stats.collected, DEPTH, "entire ring reclaimed");
2305 assert_eq!(weak.strong_count(), 0);
2306 }
2307
2308 // -- Env home-adoption registration (per-make_closure, so it must dedup;
2309 // plain register_candidate is once-at-creation) --
2310
2311 // 15a. register_env_candidate: first adoption registers, repeats dedup,
2312 // the seen-set survives collects while the env lives and prunes
2313 // when it dies. (Candidates are the home WRAPPER — the shape-E cycle
2314 // is reached through it.)
2315 #[test]
2316 fn register_env_candidate_dedups_and_collects_shape_e() {
2317 let (env, wrapper, payload, nf) = build_env_nativefn_cycle();
2318 assert!(register_env_candidate(&wrapper), "first adoption registers");
2319 assert!(!register_env_candidate(&wrapper), "later adoptions dedup");
2320
2321 let stats_live = collect(&[], GcTrigger::Explicit);
2322 assert!(!stats_live.aborted);
2323 assert_eq!(stats_live.candidates, 1, "deduped to one registration");
2324 assert_eq!(stats_live.collected, 0, "externally held: kept");
2325 assert!(
2326 !register_env_candidate(&wrapper),
2327 "seen entry survives a collect while the env lives"
2328 );
2329
2330 let weak_bindings = Rc::downgrade(&env.bindings);
2331 drop((env, wrapper, payload, nf));
2332 let stats = collect(&[], GcTrigger::Explicit);
2333 assert!(!stats.aborted);
2334 assert_eq!(stats.collected, 4, "nf + payload + wrapper + bindings");
2335 assert_eq!(weak_bindings.strong_count(), 0, "env cycle reclaimed");
2336 assert_eq!(
2337 GC.with(|gc| gc.env_seen.borrow().len()),
2338 0,
2339 "seen entry pruned with the registry"
2340 );
2341 let wrapper2 = Rc::new(Env::new());
2342 assert!(
2343 register_env_candidate(&wrapper2),
2344 "fresh env registers anew"
2345 );
2346 }
2347
2348 // 15c. register_closure_birth is the fused make_closure path: adopts the
2349 // home wrapper once, registers non-exempt closures, and reports the
2350 // growth threshold. A zero-upvalue-style exempt closure (None) still
2351 // adopts its home, and the resulting shape-E cycle is collected via
2352 // the env candidate alone.
2353 #[test]
2354 fn register_closure_birth_env_candidate_covers_exempt_closure() {
2355 let (env, wrapper, payload, nf) = build_env_nativefn_cycle();
2356 register_closure_birth(Some(&wrapper), None);
2357 register_closure_birth(Some(&wrapper), None);
2358 let weak_nf = Rc::downgrade(&nf);
2359 drop((env, wrapper, payload, nf));
2360
2361 let stats = collect(&[], GcTrigger::Explicit);
2362 assert!(!stats.aborted);
2363 assert_eq!(stats.candidates, 1, "home adopted once, closure exempt");
2364 assert_eq!(
2365 stats.collected, 4,
2366 "nf + payload + wrapper + bindings via the env candidate"
2367 );
2368 assert!(weak_nf.upgrade().is_none(), "exempt closure reclaimed");
2369 }
2370
2371 // 15b. duplicate raw registrations of one live allocation are pruned to
2372 // a single entry (which keeps collecting), instead of accumulating
2373 // and inflating the survivor-derived growth threshold.
2374 #[test]
2375 fn duplicate_live_registrations_pruned_to_one() {
2376 let t = Rc::new(Thunk {
2377 body: Value::NIL,
2378 forced: RefCell::new(None),
2379 });
2380 for _ in 0..5 {
2381 register_candidate(GcNode::Thunk(Rc::downgrade(&t)));
2382 }
2383
2384 let stats = collect(&[], GcTrigger::Explicit);
2385 assert!(!stats.aborted);
2386 assert_eq!(stats.candidates, 1, "one snapshot root per allocation");
2387 assert_eq!(stats.pruned, 4, "duplicates removed, one entry kept");
2388 assert_eq!(stats.collected, 0, "live thunk kept");
2389
2390 // The kept entry still collects the thunk once it becomes garbage.
2391 *t.forced.borrow_mut() = Some(Value::thunk_from_rc(t.clone()));
2392 let weak = Rc::downgrade(&t);
2393 drop(t);
2394 let stats2 = collect(&[], GcTrigger::Explicit);
2395 assert_eq!(stats2.candidates, 1);
2396 assert_eq!(stats2.collected, 1);
2397 assert_eq!(weak.strong_count(), 0);
2398 }
2399}