Skip to main content

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}