This document covers miniextendr’s RAII-based GC protection facilities.

πŸ”—Overview

R uses a protection stack to prevent garbage collection of objects that are still in use. miniextendr provides ergonomic Rust wrappers that automatically balance PROTECT/UNPROTECT calls.

πŸ”—Protection Strategies

StrategyScopeMax SizeRelease OrderUse Case
PROTECT stackWithin .Call~50k (ppsize)LIFOTemporary allocations
ProtectPoolCross-.CallUnlimitedAny orderCross-call, many objects (10.1 ns/op)
Preserve listCross-.CallUnlimitedAny orderFew long-lived R objects
Refcount arenasFlexibleUnlimitedAny orderLegacy - see ProtectPool

πŸ”—PROTECT Stack Types

TypePurpose
ProtectScopeBatch protection with automatic UNPROTECT(n) on drop
Root<'scope>Lightweight handle tied to a scope’s lifetime
OwnedProtectSingle-value RAII guard for simple cases
ReprotectSlot<'scope>Protected slot supporting replace-in-place

πŸ”—ProtectScope

The primary tool for managing GC protection. Tracks how many values you protect and calls UNPROTECT(n) when dropped.

unsafe fn my_call(x: SEXP, y: SEXP) -> SEXP {
    let scope = ProtectScope::new();
    let x = scope.protect(x);
    let y = scope.protect(y);

    let result = scope.protect(some_operation(x.get(), y.get()));
    result.get()
} // UNPROTECT(3) called automatically

πŸ”—Allocation Helpers

Combine allocation and protection in one step:

// Allocate and protect in one call
let vec = scope.alloc_vector(SEXPTYPE::INTSXP, 100);
let mat = scope.alloc_matrix(SEXPTYPE::REALSXP, 10, 20);
let list = scope.alloc_vecsxp(5);
let strvec = scope.alloc_strsxp(10);

πŸ”—Full Wrapper Reference

MethodWrapsDescription
alloc_vector(ty, n)Rf_allocVectorGeneric vector of type ty and length n
alloc_matrix(ty, nrow, ncol)Rf_allocMatrix2-D matrix
alloc_list(n)Rf_allocListPairlist (LISTSXP) of length n
alloc_vecsxp(n)Rf_allocVector(VECSXP)Generic list (VECSXP)
alloc_strsxp(n)Rf_allocVector(STRSXP)Character vector
alloc_integer(n)Rf_allocVector(INTSXP)Integer vector
alloc_real(n)Rf_allocVector(REALSXP)Real vector
alloc_logical(n)Rf_allocVector(LGLSXP)Logical vector
alloc_raw(n)Rf_allocVector(RAWSXP)Raw vector
alloc_complex(n)Rf_allocVector(CPLXSXP)Complex vector
alloc_character(n)Rf_allocVector(STRSXP)Character vector (alias)
alloc_array(ty, dims)Rf_allocArrayN-D array; dims INTSXP protected inside scope
alloc_3d_array(ty, nrow, ncol, nface)Rf_alloc3DArray3-D array
alloc_lang(n)Rf_allocLangLanguage object (LANGSXP) of length n
alloc_s4_object()Rf_allocS4ObjectS4 object (S4SXP)
alloc_sexp(ty)Rf_allocSExpBare cons-cell node of the given type
mkchar(s)Rf_mkCharLenCE(..., CE_UTF8)CHARSXP from &str (UTF-8)
mkchar_ce(s, enc)Rf_mkCharLenCECHARSXP from &str with specified encoding
mkchar_len_ce(bytes, enc)Rf_mkCharLenCECHARSXP from &[u8] with specified encoding
cons(car, cdr)Rf_consPairlist cons cell
lcons(car, cdr)Rf_lconsLanguage cons cell
lengthgets(x, n)Rf_lengthgetsResize vector, returns new protected copy
xlengthgets(x, n)Rf_xlengthgetsResize vector (long-vector form)
duplicate(x)Rf_duplicateDeep copy
shallow_duplicate(x)Rf_shallow_duplicateShallow copy
coerce(x, ty)Rf_coerceType coercion
new_env(parent, hash, size)R_NewEnvNew environment
make_external_ptr(p, tag, prot)R_MakeExternalPtrRaw external pointer (escape hatch; prefer ExternalPtr<T>)
scalar_integer(x)Rf_ScalarIntegerScalar integer
scalar_real(x)Rf_ScalarRealScalar real
scalar_logical(x)Rf_ScalarLogicalScalar logical
scalar_complex(x)Rf_ScalarComplexScalar complex
scalar_raw(x)Rf_ScalarRawScalar raw
scalar_string(s)Rf_ScalarString(Rf_mkCharLenCE(...))Scalar character
collect(iter)(typed fill)Allocate + fill from exact-size iterator

πŸ”—Collecting Iterators (scope.collect)

Convert Rust iterators directly to typed R vectors:

// Type is inferred from the iterator's element type
let ints = scope.collect((0..100).map(|i| i as i32));     // β†’ INTSXP
let reals = scope.collect((0..100).map(|i| i as f64));    // β†’ REALSXP
let raw = scope.collect(vec![1u8, 2, 3, 4]);              // β†’ RAWSXP

Type mapping (via RNativeType trait):

Rust TypeR Vector Type
i32INTSXP
f64REALSXP
u8RAWSXP
RLogicalLGLSXP
RcomplexCPLXSXP

For unknown-length iterators (e.g., filter), collect to Vec first:

// filter() doesn't implement ExactSizeIterator
let evens: Vec<i32> = data.iter()
    .filter(|x| *x % 2 == 0)
    .copied()
    .collect();

// Vec implements ExactSizeIterator
let vec = scope.collect(evens);

Why this is efficient: Typed vectors (INTSXP, REALSXP, etc.) don’t need per-element protection. You allocate once, protect once, then fill by writing directly to the data pointer. No GC can occur during fills because you’re just doing pointer writes. No R allocations occur.

πŸ”—Root

A lightweight handle returned by scope.protect(). Has no Drop implementation . The scope owns the unprotection responsibility.

let root: Root<'_> = scope.protect(sexp);
root.get()      // Access the SEXP
root.into_raw() // Consume and return SEXP (still protected until scope drops)

πŸ”—OwnedProtect

Single-object RAII guard. Calls UNPROTECT(1) on drop.

unsafe fn simple_case() -> SEXP {
    let guard = OwnedProtect::new(Rf_allocVector(REALSXP, 10));
    fill_vector(guard.get());
    guard.get() // Safe: unprotect happens after this expression
}

Warning: Uses UNPROTECT(1) which removes the top of the stack. Nested protections from other sources can cause issues. Prefer ProtectScope for complex scenarios.

πŸ”—ReprotectSlot

A slot created with R_ProtectWithIndex that can be updated in-place via R_Reprotect. Essential for accumulator patterns where you repeatedly replace a protected value.

unsafe fn accumulate(n: usize) -> SEXP {
    let scope = ProtectScope::new();
    let slot = scope.protect_with_index(Rf_allocVector(INTSXP, 1));

    for i in 0..n {
        // Replace without growing protect stack
        slot.set(Rf_allocVector(INTSXP, i as isize));
    }

    slot.get()
} // Stack usage: always 1, regardless of n

πŸ”—Methods

MethodDescription
get()Get the currently protected SEXP
set(x)Replace with new value (calls R_Reprotect)
set_with(f)Safely replace: calls f(), temp-protects result, reprotects
take()Return current value and clear slot to R_NilValue
replace(x)Return current value and set slot to x
clear()Set slot to R_NilValue

πŸ”—Safe Replacement with set_with

The set_with method handles the GC gap that exists between allocating a new value and reprotecting it:

// Without set_with (manual pattern):
let new_val = Rf_allocVector(INTSXP, n);  // Unprotected!
Rf_protect(new_val);                       // Temp protect
slot.set(new_val);                         // Reprotect
Rf_unprotect(1);                           // Drop temp

// With set_with (handles it for you):
slot.set_with(|| Rf_allocVector(INTSXP, n));

πŸ”—Option-like Semantics

take(), replace(), and clear() provide Option-like ergonomics:

// Take: get value and clear slot
let old = slot.take();  // slot now holds R_NilValue

// Replace: get old value and set new
let old = slot.replace(new_value);

// Clear: just set to R_NilValue
slot.clear();

Important: Values returned by take() and replace() are unprotected. If they need to survive further allocations, protect them explicitly.

πŸ”—ProtectPool

A VECSXP-backed pool that stores protected SEXPs in a single R list, with slot management and generational key tracking on the Rust side. Designed for cross-.Call protection of many objects with any-order release.

πŸ”—Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  R side: VECSXP (GC-traced slots)   β”‚  ← one R_PreserveObject, ever
β”‚  [SEXP][SEXP][NIL][SEXP][NIL][SEXP] β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚ slot indices
β”Œβ”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Rust side: Vec<u32> generations    β”‚  ← one free list, one generation array
β”‚  + Vec<usize> free_slots            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

A single R_PreserveObject anchors the backing VECSXP. Each insert writes into a free slot; each release clears the slot and increments its generation counter. Keys carry both a slot index and a generation, so stale-key operations are no-ops rather than crashes.

πŸ”—API

MethodDescription
ProtectPool::new(capacity)Create a pool with an initial VECSXP capacity (grows automatically)
pool.insert(sexp)Protect a SEXP, returning a ProtectKey
pool.get(key)Retrieve the SEXP for a key, or None if the key is stale
pool.replace(key, sexp)Overwrite an existing slot in-place (pool equivalent of R_Reprotect)
pool.release(key)Release protection; stale keys are silently ignored
pool.len()Number of currently protected objects
pool.contains_key(key)Check whether a key is currently valid

ProtectKey is 8 bytes (4-byte slot index + 4-byte generation) and is Copy. Dropping a key without calling release leaks protection but does not crash.

ProtectPool is !Send + !Sync β€” all operations must occur on the R main thread.

πŸ”—Performance

10.1 ns/op for a single insert+release pair. Zero R allocation per insert β€” the backing VECSXP is allocated once at pool creation; inserts reuse existing slots. Contrast with preserve (Preserve List), which allocates one CONSXP per insert (~28.9 ns/op). See analysis/gc-protection-benchmarks-results.md for full benchmark data.

Automatic growth doubles the backing VECSXP when slots are exhausted. Growth copies existing slot contents, releases the old VECSXP via R_ReleaseObject, and preserves the new one.

πŸ”—Example

use miniextendr_api::protect_pool::{ProtectPool, ProtectKey};

unsafe fn build_cross_call_state() -> (ProtectPool, ProtectKey, ProtectKey) {
    let mut pool = ProtectPool::new(16);

    let s1 = SEXP::scalar_integer(42);
    let s2 = SEXP::scalar_real(3.14);
    let k1 = pool.insert(s1);
    let k2 = pool.insert(s2);

    // Both SEXPs survive GC across .Call boundaries
    (pool, k1, k2)
}

unsafe fn use_cross_call_state(pool: &mut ProtectPool, k1: ProtectKey, k2: ProtectKey) {
    // Retrieve β€” returns None if the key is stale
    let s1 = pool.get(k1).expect("k1 should still be valid");

    // Release when done β€” any order, not LIFO
    pool.release(k2);
    pool.release(k1);
}

πŸ”—Comparison to ProtectScope and Preserve List

ProtectScopeProtectPoolPreserve List
ScopeWithin one .CallCross-.CallCross-.Call
Release orderLIFO (drop)Any orderAny order
Per-op cost7.4 ns10.1 ns28.9 ns (CONSXP per insert)
R allocation per insertNoneNoneOne CONSXP
Max objects~50k (ppsize)Unlimited (grows)Unlimited
Key safetyLifetime-bound (Root<'scope>)Generational (stale = no-op)Manual

Use ProtectScope for temporaries that live only within a single .Call invocation. Use ProtectPool when protected objects must outlive a .Call boundary, you have many objects or high insert/release churn, and you need any-order release. Use the Preserve List when you have a small number of long-lived objects that are rarely released (e.g., cached lookup tables).

πŸ”—When to Use What

ScenarioUse
Multiple values, known at function startProtectScope
Single value, simple caseOwnedProtect
Accumulator loop, repeated replacementReprotectSlot
Many SEXPs that must outlive a .Call, any-order releaseProtectPool
Building typed vectors from iteratorsscope.collect()
Building lists with unknown lengthListAccumulator
Building string vectorsStrVecBuilder

πŸ”—Protection Patterns by R Type

πŸ”—Typed Vectors (INTSXP, REALSXP, RAWSXP, LGLSXP, CPLXSXP)

Simple: Allocate once, protect once, fill directly.

let vec = scope.alloc_vector(SEXPTYPE::INTSXP, n);
let ptr = INTEGER(vec.get());
for i in 0..n {
    *ptr.add(i) = i as i32;  // No GC possible here
}

Or use scope.collect() for even simpler code.

πŸ”—Lists (VECSXP)

Complex: Each element might allocate. Use ListBuilder or ListAccumulator.

// Known size
let builder = ListBuilder::new(&scope, n);
for i in 0..n {
    builder.set_protected(i, Rf_ScalarInteger(i));
}

// Unknown size (bounded stack usage)
let mut acc = ListAccumulator::new(&scope, 4);
for item in items {
    acc.push(item);
}

πŸ”—String Vectors (STRSXP)

Complex: Each mkChar allocates. Use StrVecBuilder.

let builder = StrVecBuilder::new(&scope, n);
for i in 0..n {
    builder.set_str(i, "hello");  // Handles CHARSXP protection
}

πŸ”—Bounded vs Unbounded Stack Usage

Unbounded (grows with input size):

for i in 0..n {
    scope.protect(allocate_something());  // Stack grows to n
}

Bounded (constant regardless of input):

let slot = scope.protect_with_index(R_NilValue);
for i in 0..n {
    slot.set(allocate_something());  // Stack stays at 1
}

R’s default --max-ppsize is 50000. Unbounded patterns can overflow this limit with large inputs. Bounded patterns handle any size.

πŸ”—ProtectPool

For objects that must survive across multiple .Call invocations:

use miniextendr_api::protect_pool::ProtectPool;

// Pool backed by a VECSXP with generational keys (O(1) insert/release).
let mut pool = unsafe { ProtectPool::new(16) };
let key = unsafe { pool.insert(my_sexp) };

// Later, release it (no LIFO constraint)
unsafe { pool.release(key); }

ProtectPool keeps protected SEXPs in a VECSXP backing slot, indexed by generational keys (slotmap-style). Single VECSXP protect via R_PreserveObject on construction; per-insert is a slot write with no R allocation.

Use ProtectPool (or R_PreserveObject directly) when you need to keep R objects alive across function calls (e.g., cached lookup tables).

πŸ”—Refcount Arenas

For scenarios involving many SEXPs (hundreds to millions), the PROTECT stack is too limited (~50k) and R_PreserveObject/R_ReleaseObject has O(n) release cost. Refcount arenas provide O(1) protect/unprotect with reference counting backed by a VECSXP:

use miniextendr_api::refcount_protect::ThreadLocalArena;

unsafe {
    ThreadLocalArena::init();

    // Protect (O(1) amortized)
    let sexp = ThreadLocalArena::protect(my_sexp);

    // RAII guard alternative
    // let guard = arena.guard(my_sexp);

    // Unprotect (O(1))
    ThreadLocalArena::unprotect(sexp);
}

πŸ”—Arena Variants

TypeBackingThread-Local
RefCountedArenaBTreeMap + RefCellNo
ThreadLocalArenaBTreeMap + thread_localYes

Thread-local variants avoid RefCell borrow overhead and are fastest for hot loops. These are the only two flavors instantiated anywhere in the tree; a HashMap- and ahash-backed variant family existed previously but was removed for having zero production or test consumers (see refcount_protect.rs’s module docs for the tracking issue if you need to re-add one).

πŸ”—Choosing a Strategy

Need GC protection?
β”œβ”€ Within a single .Call?
β”‚  β”œβ”€ 1 value β†’ OwnedProtect
β”‚  β”œβ”€ Few values (< 100) β†’ ProtectScope
β”‚  └─ Accumulator loop β†’ ReprotectSlot
β”œβ”€ Across .Call invocations?
β”‚  └─ Small number (< 10) β†’ ProtectPool or R_PreserveObject
└─ Many values (100+) / hot loop with many SEXPs?
   └─ ThreadLocalArena (or RefCountedArena for a non-thread-local instance)