Skip to main content

miniextendr_api/
refcount_protect.rs

1//! Reference-counted GC protection using a `BTreeMap` + VECSXP backing.
2//!
3//! This module provides an alternative to [`gc_protect`](crate::gc_protect) that uses
4//! reference counting instead of R's LIFO protect stack. This allows releasing
5//! protections in any order and avoids the `--max-ppsize` limit.
6//!
7//! # Architecture
8//!
9//! ```text
10//! ┌─────────────────────────────────────────────────────────────────────┐
11//! │  RefCountedArena / ThreadLocalArena                                 │
12//! │  ┌─────────────────────────┐   ┌───────────────────────────────────┐│
13//! │  │  BTreeMap<usize, Entry> │   │  VECSXP (R_PreserveObject'd)      ││
14//! │  │  ────────────────────── │   │  ─────────────────────────────    ││
15//! │  │  sexp_a → {count:2, i:0}│◄──┤  [0]: sexp_a                      ││
16//! │  │  sexp_b → {count:1, i:1}│◄──┤  [1]: sexp_b                      ││
17//! │  │  sexp_c → {count:1, i:2}│◄──┤  [2]: sexp_c                      ││
18//! │  └─────────────────────────┘   │  [3]: <free>                      ││
19//! │                                └───────────────────────────────────┘│
20//! └─────────────────────────────────────────────────────────────────────┘
21//! ```
22//!
23//! # Available Types
24//!
25//! | Type | Storage | Use Case |
26//! |------|---------|----------|
27//! | [`RefCountedArena`] | `RefCell` | General purpose, ordered, ppsize-scale workloads |
28//! | [`ThreadLocalArena`] | `thread_local` | Lowest overhead — no `RefCell` borrow checking |
29//!
30//! Only these two flavors are instantiated anywhere in the tree today. This
31//! module previously also shipped HashMap- and ahash-backed variants
32//! (`HashMapArena`, `ThreadLocalHashArena`, `FastHashMapArena`,
33//! `ThreadLocalFastHashArena`) behind a generic `MapStorage` abstraction; they
34//! were removed for having zero production or test consumers (see
35//! <https://github.com/a2-ai/miniextendr/issues/ISSUE_NUMBER_PLACEHOLDER>).
36//! Re-add a flavor (and the generic `MapStorage` plumbing needed to support
37//! more than one map type again) when a real consumer appears.
38
39use crate::sys::{R_PreserveObject, R_ReleaseObject, Rf_allocVector, Rf_protect, Rf_unprotect};
40use crate::{R_xlen_t, SEXP, SEXPTYPE, SexpExt};
41use std::cell::RefCell;
42use std::collections::BTreeMap;
43use std::marker::PhantomData;
44use std::mem::MaybeUninit;
45use std::rc::Rc;
46
47// region: Entry type
48
49/// Entry in the reference count map.
50#[derive(Debug, Clone, Copy)]
51struct Entry {
52    /// Reference count (how many times this SEXP has been protected)
53    count: usize,
54    /// Index in the backing VECSXP
55    index: usize,
56}
57// endregion
58
59// region: Core arena state (shared between RefCell and thread_local variants)
60
61/// Core arena state without interior mutability.
62///
63/// This is used internally by both [`RefCountedArena`] (with `RefCell`) and
64/// [`ThreadLocalArena`] (with `UnsafeCell`).
65struct ArenaState {
66    /// Map from SEXP pointer to entry
67    map: MaybeUninit<BTreeMap<usize, Entry>>,
68    /// Backing VECSXP (preserved via R_PreserveObject)
69    backing: SEXP,
70    /// Current capacity
71    capacity: usize,
72    /// Number of active entries
73    len: usize,
74    /// Free list for slot reuse
75    free_list: Vec<usize>,
76    /// Monotonic write cursor: next index to hand out on the fresh-slot path.
77    /// Distinct from `len` (live count) — after release cycles `len` can be
78    /// lower than the highest index ever handed out, so using `len` as the
79    /// cursor can return an index that is already on the free-list.
80    next_slot: usize,
81}
82
83impl ArenaState {
84    /// Initial capacity for the backing VECSXP.
85    ///
86    /// This is suitable for light usage (a handful of protected values).
87    /// For ppsize-scale workloads (hundreds or thousands of protected values),
88    /// use [`RefCountedArena::with_capacity`] or
89    /// [`ThreadLocalArena::init_with_capacity`] to avoid repeated backing
90    /// VECSXP growth and map rehashing.
91    const INITIAL_CAPACITY: usize = 16;
92
93    /// Maximum capacity: the backing VECSXP is indexed by `R_xlen_t` (isize),
94    /// so the capacity must fit in a non-negative `R_xlen_t`.
95    const MAX_CAPACITY: usize = R_xlen_t::MAX as usize;
96
97    /// Convert a `usize` capacity to `R_xlen_t`, panicking on overflow.
98    #[inline]
99    fn capacity_as_r_xlen(cap: usize) -> R_xlen_t {
100        R_xlen_t::try_from(cap).unwrap_or_else(|_| {
101            panic!(
102                "arena capacity {} exceeds R_xlen_t::MAX ({})",
103                cap,
104                R_xlen_t::MAX
105            )
106        })
107    }
108
109    /// Create uninitialized state (for thread_local).
110    const fn uninit() -> Self {
111        Self {
112            map: MaybeUninit::uninit(),
113            backing: SEXP(std::ptr::null_mut()),
114            capacity: 0,
115            len: 0,
116            free_list: Vec::new(),
117            next_slot: 0,
118        }
119    }
120
121    /// Initialize the state.
122    ///
123    /// # Safety
124    ///
125    /// Must be called exactly once before using the state.
126    unsafe fn init(&mut self, capacity: usize) {
127        let capacity = capacity.max(1);
128        assert!(
129            capacity <= Self::MAX_CAPACITY,
130            "arena capacity {} exceeds R_xlen_t::MAX ({})",
131            capacity,
132            R_xlen_t::MAX
133        );
134        unsafe {
135            let r_cap = Self::capacity_as_r_xlen(capacity);
136            let backing = Rf_protect(Rf_allocVector(SEXPTYPE::VECSXP, r_cap));
137            R_PreserveObject(backing);
138            Rf_unprotect(1);
139
140            self.map.write(BTreeMap::new());
141            self.backing = backing;
142            self.capacity = capacity;
143            self.len = 0;
144            self.next_slot = 0;
145            self.free_list = Vec::with_capacity(capacity);
146        }
147    }
148
149    /// Create initialized state.
150    unsafe fn new(capacity: usize) -> Self {
151        let capacity = capacity.max(1);
152        let mut state = Self {
153            map: MaybeUninit::new(BTreeMap::new()),
154            backing: SEXP(std::ptr::null_mut()),
155            capacity: 0,
156            len: 0,
157            next_slot: 0,
158            free_list: Vec::with_capacity(capacity),
159        };
160        unsafe { state.init_backing(capacity) };
161        state
162    }
163
164    /// Initialize just the backing (map already initialized).
165    unsafe fn init_backing(&mut self, capacity: usize) {
166        let capacity = capacity.max(1);
167        assert!(
168            capacity <= Self::MAX_CAPACITY,
169            "arena capacity {} exceeds R_xlen_t::MAX ({})",
170            capacity,
171            R_xlen_t::MAX
172        );
173        unsafe {
174            let r_cap = Self::capacity_as_r_xlen(capacity);
175            let backing = Rf_protect(Rf_allocVector(SEXPTYPE::VECSXP, r_cap));
176            R_PreserveObject(backing);
177            Rf_unprotect(1);
178
179            self.backing = backing;
180            self.capacity = capacity;
181        }
182    }
183
184    /// Get a reference to the map.
185    #[inline]
186    fn map(&self) -> &BTreeMap<usize, Entry> {
187        // SAFETY: Map is initialized before any access
188        unsafe { self.map.assume_init_ref() }
189    }
190
191    /// Get a mutable reference to the map.
192    #[inline]
193    fn map_mut(&mut self) -> &mut BTreeMap<usize, Entry> {
194        // SAFETY: Map is initialized before any access
195        unsafe { self.map.assume_init_mut() }
196    }
197
198    /// Decrement the count for a key and remove if zero.
199    ///
200    /// Returns `Some((true, index))` if the entry was found and removed,
201    /// `Some((false, index))` if the entry was found but count > 0 after
202    /// decrement, `None` if the entry was not found.
203    #[inline]
204    fn decrement_and_maybe_remove(&mut self, key: &usize) -> Option<(bool, usize)> {
205        let map = self.map_mut();
206        if let Some(entry) = map.get_mut(key) {
207            entry.count -= 1;
208            if entry.count == 0 {
209                let index = entry.index;
210                map.remove(key);
211                Some((true, index))
212            } else {
213                Some((false, entry.index))
214            }
215        } else {
216            None
217        }
218    }
219
220    /// Protect a SEXP from garbage collection.
221    ///
222    /// # Safety
223    ///
224    /// Must be called from the R main thread. The SEXP must be valid.
225    #[inline]
226    unsafe fn protect(&mut self, x: SEXP) -> SEXP {
227        if x.is_nil() {
228            return x;
229        }
230
231        let key = x.0 as usize;
232
233        if let Some(entry) = self.map_mut().get_mut(&key) {
234            entry.count += 1;
235        } else {
236            let index = self.allocate_slot();
237            self.backing.set_vector_elt(index as R_xlen_t, x);
238            self.map_mut().insert(key, Entry { count: 1, index });
239            self.len += 1;
240        }
241
242        x
243    }
244
245    /// Unprotect a SEXP, allowing garbage collection when refcount reaches zero.
246    ///
247    /// # Safety
248    ///
249    /// Must be called from the R main thread. The SEXP must have been
250    /// previously protected by this arena.
251    #[inline]
252    unsafe fn unprotect(&mut self, x: SEXP) {
253        if x.is_nil() {
254            return;
255        }
256
257        let key = x.0 as usize;
258
259        match self.decrement_and_maybe_remove(&key) {
260            Some((true, index)) => {
261                // Entry was removed (count reached 0)
262                self.backing.set_vector_elt(index as R_xlen_t, SEXP::nil());
263                self.free_list.push(index);
264                self.len -= 1;
265            }
266            Some((false, _)) => {
267                // Entry still exists (count > 0)
268            }
269            None => {
270                panic!("unprotect called on SEXP not protected by this arena");
271            }
272        }
273    }
274
275    /// Try to unprotect a SEXP. Returns false if not protected by this arena.
276    ///
277    /// # Safety
278    ///
279    /// Must be called from the R main thread.
280    #[inline]
281    unsafe fn try_unprotect(&mut self, x: SEXP) -> bool {
282        if x.is_nil() {
283            return false;
284        }
285
286        let key = x.0 as usize;
287
288        match self.decrement_and_maybe_remove(&key) {
289            Some((true, index)) => {
290                // Entry was removed (count reached 0)
291                self.backing.set_vector_elt(index as R_xlen_t, SEXP::nil());
292                self.free_list.push(index);
293                self.len -= 1;
294                true
295            }
296            Some((false, _)) => {
297                // Entry still exists (count > 0)
298                true
299            }
300            None => false,
301        }
302    }
303
304    #[inline]
305    /// Returns true if this arena currently protects `x`.
306    fn is_protected(&self, x: SEXP) -> bool {
307        if x.is_nil() {
308            return false;
309        }
310        let key = x.0 as usize;
311        self.map().contains_key(&key)
312    }
313
314    #[inline]
315    /// Returns the current reference count for `x` in this arena.
316    ///
317    /// Returns 0 if `x` is not protected or is `SEXP::nil()`.
318    fn ref_count(&self, x: SEXP) -> usize {
319        if x.is_nil() {
320            return 0;
321        }
322        let key = x.0 as usize;
323        self.map().get(&key).map(|e| e.count).unwrap_or(0)
324    }
325
326    fn allocate_slot(&mut self) -> usize {
327        if let Some(index) = self.free_list.pop() {
328            return index;
329        }
330
331        if self.next_slot >= self.capacity {
332            unsafe { self.grow() };
333        }
334
335        let idx = self.next_slot;
336        self.next_slot += 1;
337        idx
338    }
339
340    unsafe fn grow(&mut self) {
341        let old_capacity = self.capacity;
342        let new_capacity = old_capacity
343            .checked_mul(2)
344            .expect("arena capacity overflow during growth");
345        assert!(
346            new_capacity <= Self::MAX_CAPACITY,
347            "arena capacity {} would exceed R_xlen_t::MAX ({}) after growth",
348            new_capacity,
349            R_xlen_t::MAX
350        );
351        let old_backing = self.backing;
352
353        unsafe {
354            let r_new_cap = Self::capacity_as_r_xlen(new_capacity);
355            let new_backing = Rf_protect(Rf_allocVector(SEXPTYPE::VECSXP, r_new_cap));
356            R_PreserveObject(new_backing);
357
358            for i in 0..old_capacity {
359                let r_i = Self::capacity_as_r_xlen(i);
360                let elt = old_backing.vector_elt(r_i);
361                new_backing.set_vector_elt(r_i, elt);
362            }
363
364            R_ReleaseObject(old_backing);
365            Rf_unprotect(1);
366
367            self.backing = new_backing;
368            self.capacity = new_capacity;
369        }
370    }
371
372    /// Clear all protected values from the arena.
373    ///
374    /// # Safety
375    ///
376    /// Must be called from the R main thread.
377    unsafe fn clear(&mut self) {
378        for entry in self.map().values() {
379            self.backing
380                .set_vector_elt(entry.index as R_xlen_t, SEXP::nil());
381        }
382        self.map_mut().clear();
383        self.free_list.clear();
384        self.len = 0;
385        self.next_slot = 0;
386    }
387
388    unsafe fn release_backing(&mut self) {
389        if !self.backing.0.is_null() {
390            unsafe { R_ReleaseObject(self.backing) };
391            self.backing = SEXP(std::ptr::null_mut());
392        }
393    }
394}
395// endregion
396
397// region: RefCountedArena - RefCell-based arena
398
399/// Enforces `!Send + !Sync` (R API is not thread-safe).
400type NoSendSync = PhantomData<Rc<()>>;
401
402/// A reference-counted arena for GC protection, backed by a `BTreeMap`.
403///
404/// This provides an alternative to R's PROTECT stack that:
405/// - Uses reference counting for each SEXP
406/// - Allows releasing protections in any order
407/// - Has no stack size limit (uses heap allocation)
408pub struct RefCountedArena {
409    state: RefCell<ArenaState>,
410    _nosend: NoSendSync,
411}
412
413impl RefCountedArena {
414    /// Create a new arena with default capacity (16 slots).
415    ///
416    /// For workloads protecting many distinct SEXPs (e.g., ppsize-scale loops),
417    /// prefer [`with_capacity`](Self::with_capacity) to avoid backing VECSXP
418    /// growth and map rehashing during operation.
419    ///
420    /// # Safety
421    ///
422    /// Must be called from the R main thread. There is deliberately no
423    /// `Default` impl: constructing an arena allocates an R VECSXP (via
424    /// `Rf_allocVector` / `R_PreserveObject`) and is an unsafe, main-thread-only
425    /// act, not a safe default (#1096).
426    pub unsafe fn new() -> Self {
427        unsafe { Self::with_capacity(ArenaState::INITIAL_CAPACITY) }
428    }
429
430    /// Create a new arena with specific initial capacity.
431    ///
432    /// Pre-sizing the arena avoids growth of the backing VECSXP and rehashing
433    /// of the internal map. Use this when the expected number of distinct
434    /// protected values is known or can be estimated.
435    ///
436    /// # Safety
437    ///
438    /// Must be called from the R main thread.
439    pub unsafe fn with_capacity(capacity: usize) -> Self {
440        Self {
441            state: RefCell::new(unsafe { ArenaState::new(capacity) }),
442            _nosend: PhantomData,
443        }
444    }
445
446    /// Protect a SEXP, incrementing its reference count.
447    ///
448    /// # Safety
449    ///
450    /// Must be called from the R main thread.
451    #[inline]
452    pub unsafe fn protect(&self, x: SEXP) -> SEXP {
453        unsafe { self.state.borrow_mut().protect(x) }
454    }
455
456    /// Unprotect a SEXP, decrementing its reference count.
457    ///
458    /// # Safety
459    ///
460    /// Must be called from the R main thread.
461    ///
462    /// # Panics
463    ///
464    /// Panics if `x` was not protected by this arena.
465    #[inline]
466    pub unsafe fn unprotect(&self, x: SEXP) {
467        unsafe { self.state.borrow_mut().unprotect(x) };
468    }
469
470    /// Try to unprotect a SEXP, returning `true` if it was protected.
471    ///
472    /// # Safety
473    ///
474    /// Must be called from the R main thread.
475    #[inline]
476    pub unsafe fn try_unprotect(&self, x: SEXP) -> bool {
477        unsafe { self.state.borrow_mut().try_unprotect(x) }
478    }
479
480    /// Check if a SEXP is currently protected by this arena.
481    #[inline]
482    pub fn is_protected(&self, x: SEXP) -> bool {
483        self.state.borrow().is_protected(x)
484    }
485
486    /// Get the reference count for a SEXP (0 if not protected).
487    #[inline]
488    pub fn ref_count(&self, x: SEXP) -> usize {
489        self.state.borrow().ref_count(x)
490    }
491
492    /// Get the number of distinct SEXPs currently protected.
493    #[inline]
494    pub fn len(&self) -> usize {
495        self.state.borrow().len
496    }
497
498    /// Check if the arena is empty.
499    #[inline]
500    pub fn is_empty(&self) -> bool {
501        self.state.borrow().len == 0
502    }
503
504    /// Get the current capacity.
505    #[inline]
506    pub fn capacity(&self) -> usize {
507        self.state.borrow().capacity
508    }
509
510    /// Clear all protections.
511    ///
512    /// # Safety
513    ///
514    /// Must be called from the R main thread.
515    pub unsafe fn clear(&self) {
516        unsafe { self.state.borrow_mut().clear() };
517    }
518
519    /// Protect a SEXP and return an RAII guard.
520    ///
521    /// # Safety
522    ///
523    /// Must be called from the R main thread.
524    #[inline]
525    pub unsafe fn guard(&self, x: SEXP) -> ArenaGuard<'_> {
526        unsafe { ArenaGuard::new(self, x) }
527    }
528}
529
530impl Drop for RefCountedArena {
531    fn drop(&mut self) {
532        let state = self.state.get_mut();
533        // SAFETY: RefCountedArena always constructs via ArenaState::new(),
534        // which initializes the map.
535        unsafe { state.map.assume_init_drop() };
536        unsafe { state.release_backing() };
537    }
538}
539
540// endregion
541
542// region: RAII Guard
543
544/// An RAII guard that unprotects a SEXP when dropped.
545pub struct ArenaGuard<'a> {
546    arena: &'a RefCountedArena,
547    sexp: SEXP,
548}
549
550impl<'a> ArenaGuard<'a> {
551    /// Create a new guard that protects the SEXP and unprotects on drop.
552    ///
553    /// # Safety
554    ///
555    /// Must be called from the R main thread. The SEXP must be valid.
556    #[inline]
557    pub unsafe fn new(arena: &'a RefCountedArena, sexp: SEXP) -> Self {
558        unsafe { arena.protect(sexp) };
559        Self { arena, sexp }
560    }
561
562    #[inline]
563    /// Returns the protected SEXP.
564    pub fn get(&self) -> SEXP {
565        self.sexp
566    }
567}
568
569impl Drop for ArenaGuard<'_> {
570    fn drop(&mut self) {
571        unsafe { self.arena.unprotect(self.sexp) };
572    }
573}
574
575impl std::ops::Deref for ArenaGuard<'_> {
576    type Target = SEXP;
577
578    #[inline]
579    fn deref(&self) -> &Self::Target {
580        &self.sexp
581    }
582}
583// endregion
584
585// region: ThreadLocalArena
586
587/// State wrapper for the thread-local arena.
588struct ThreadLocalState {
589    inner: ArenaState,
590    initialized: bool,
591}
592
593impl ThreadLocalState {
594    /// Create an uninitialized thread-local arena state.
595    ///
596    /// Call `init` or `init_with_capacity` before use.
597    const fn uninit() -> Self {
598        Self {
599            inner: ArenaState::uninit(),
600            initialized: false,
601        }
602    }
603
604    /// Initialize with default capacity (16 slots).
605    ///
606    /// For ppsize-scale workloads, prefer [`init_with_capacity`](Self::init_with_capacity)
607    /// to avoid backing VECSXP growth and map rehashing during operation.
608    ///
609    /// # Safety
610    ///
611    /// Must be called from the R main thread. Must only be called once.
612    unsafe fn init(&mut self) {
613        unsafe { self.inner.init(ArenaState::INITIAL_CAPACITY) };
614        self.initialized = true;
615    }
616
617    /// Initialize with specific capacity.
618    ///
619    /// Pre-sizing avoids growth of the backing VECSXP and rehashing of the
620    /// internal map. Use this when the expected number of distinct protected
621    /// values is known or can be estimated (e.g., the length of an input vector).
622    ///
623    /// # Safety
624    ///
625    /// Must be called from the R main thread. Must only be called once.
626    unsafe fn init_with_capacity(&mut self, capacity: usize) {
627        unsafe { self.inner.init(capacity) };
628        self.initialized = true;
629    }
630}
631
632impl Drop for ThreadLocalState {
633    fn drop(&mut self) {
634        if self.initialized {
635            // SAFETY: The map was initialized in init() or init_with_capacity().
636            // We must manually drop it because MaybeUninit does not run Drop.
637            unsafe { self.inner.map.assume_init_drop() };
638        }
639        // R backing is released separately via release_backing() if needed.
640        // Thread-local destructors may run after R has shut down, so we do NOT
641        // call R_ReleaseObject here — the R runtime owns the backing VECSXP
642        // lifetime via R_PreserveObject.
643    }
644}
645
646thread_local! {
647    static THREAD_LOCAL_STATE: std::cell::UnsafeCell<ThreadLocalState> =
648        const { std::cell::UnsafeCell::new(ThreadLocalState::uninit()) };
649}
650
651/// Thread-local, `BTreeMap`-backed reference-counted GC protection arena.
652///
653/// This provides the lowest overhead for protection operations by
654/// eliminating `RefCell` borrow checking — each thread gets its own
655/// `ThreadLocalState` (private) accessed through an `UnsafeCell`.
656///
657/// ```ignore
658/// use miniextendr_api::refcount_protect::ThreadLocalArena;
659/// unsafe { ThreadLocalArena::protect(x) };
660/// ```
661pub struct ThreadLocalArena;
662
663impl ThreadLocalArena {
664    /// Access the thread-local state.
665    #[inline]
666    fn with_state<R, F: FnOnce(&mut ThreadLocalState) -> R>(f: F) -> R {
667        THREAD_LOCAL_STATE.with(|cell| f(unsafe { &mut *cell.get() }))
668    }
669
670    /// Initialize the arena with default capacity (called automatically on first use).
671    ///
672    /// # Safety
673    ///
674    /// Must be called from the R main thread.
675    pub unsafe fn init() {
676        Self::with_state(|s| {
677            if !s.initialized {
678                unsafe { s.init() };
679            }
680        });
681    }
682
683    /// Initialize the arena with specific capacity.
684    ///
685    /// Use this when you know the expected number of distinct protected values
686    /// to avoid backing VECSXP growth and map rehashing during operation.
687    ///
688    /// If already initialized, this is a no-op.
689    ///
690    /// # Safety
691    ///
692    /// Must be called from the R main thread.
693    pub unsafe fn init_with_capacity(capacity: usize) {
694        Self::with_state(|s| {
695            if !s.initialized {
696                unsafe { s.init_with_capacity(capacity) };
697            }
698        });
699    }
700
701    /// Protect a SEXP, incrementing its reference count.
702    ///
703    /// # Safety
704    ///
705    /// Must be called from the R main thread.
706    #[inline]
707    pub unsafe fn protect(x: SEXP) -> SEXP {
708        Self::with_state(|s| {
709            if !s.initialized {
710                unsafe { s.init() };
711            }
712            unsafe { s.inner.protect(x) }
713        })
714    }
715
716    /// Unprotect a SEXP.
717    ///
718    /// # Safety
719    ///
720    /// Must be called from the R main thread.
721    #[inline]
722    pub unsafe fn unprotect(x: SEXP) {
723        Self::with_state(|s| {
724            // If the arena was never initialized, no SEXP could have been
725            // protected by it, so there is nothing to unprotect.
726            if !s.initialized {
727                return;
728            }
729            unsafe { s.inner.unprotect(x) };
730        });
731    }
732
733    /// Try to unprotect a SEXP.
734    ///
735    /// # Safety
736    ///
737    /// Must be called from the R main thread.
738    #[inline]
739    pub unsafe fn try_unprotect(x: SEXP) -> bool {
740        Self::with_state(|s| {
741            // If the arena was never initialized, no SEXP could have been
742            // protected by it, so return false.
743            if !s.initialized {
744                return false;
745            }
746            unsafe { s.inner.try_unprotect(x) }
747        })
748    }
749
750    /// Protect without checking initialization.
751    ///
752    /// For hot loops where `init()` or `init_with_capacity()` has already been called.
753    ///
754    /// # Safety
755    ///
756    /// - Must be called from the R main thread.
757    /// - The arena must have been initialized via `init()` or `init_with_capacity()`.
758    #[inline]
759    pub unsafe fn protect_fast(x: SEXP) -> SEXP {
760        Self::with_state(|s| {
761            debug_assert!(s.initialized, "protect_fast called before init");
762            unsafe { s.inner.protect(x) }
763        })
764    }
765
766    /// Unprotect without checking initialization.
767    ///
768    /// For hot loops where `init()` or `init_with_capacity()` has already been called.
769    ///
770    /// # Safety
771    ///
772    /// - Must be called from the R main thread.
773    /// - The arena must have been initialized via `init()` or `init_with_capacity()`.
774    #[inline]
775    pub unsafe fn unprotect_fast(x: SEXP) {
776        Self::with_state(|s| {
777            debug_assert!(s.initialized, "unprotect_fast called before init");
778            unsafe { s.inner.unprotect(x) };
779        });
780    }
781
782    /// Try to unprotect without checking initialization.
783    ///
784    /// For hot loops where `init()` or `init_with_capacity()` has already been called.
785    ///
786    /// # Safety
787    ///
788    /// - Must be called from the R main thread.
789    /// - The arena must have been initialized via `init()` or `init_with_capacity()`.
790    #[inline]
791    pub unsafe fn try_unprotect_fast(x: SEXP) -> bool {
792        Self::with_state(|s| {
793            debug_assert!(s.initialized, "try_unprotect_fast called before init");
794            unsafe { s.inner.try_unprotect(x) }
795        })
796    }
797
798    /// Check if a SEXP is protected.
799    #[inline]
800    pub fn is_protected(x: SEXP) -> bool {
801        Self::with_state(|s| {
802            if !s.initialized {
803                return false;
804            }
805            s.inner.is_protected(x)
806        })
807    }
808
809    /// Get reference count.
810    #[inline]
811    pub fn ref_count(x: SEXP) -> usize {
812        Self::with_state(|s| {
813            if !s.initialized {
814                return 0;
815            }
816            s.inner.ref_count(x)
817        })
818    }
819
820    /// Number of protected SEXPs.
821    #[inline]
822    pub fn len() -> usize {
823        Self::with_state(|s| s.inner.len)
824    }
825
826    /// Check if empty.
827    #[inline]
828    pub fn is_empty() -> bool {
829        Self::len() == 0
830    }
831
832    /// Get capacity.
833    #[inline]
834    pub fn capacity() -> usize {
835        Self::with_state(|s| s.inner.capacity)
836    }
837
838    /// Clear all protections.
839    ///
840    /// # Safety
841    ///
842    /// Must be called from the R main thread.
843    pub unsafe fn clear() {
844        Self::with_state(|s| {
845            if s.initialized {
846                unsafe { s.inner.clear() };
847            }
848        });
849    }
850}
851
852// Tests are in tests/refcount_protect.rs (requires R runtime via miniextendr-engine)
853// endregion