Skip to main content

lean_rs/runtime/
obj.rs

1//! Lifetime-bound owned and borrowed Lean object handles.
2//!
3//! [`Obj`] is the only safe owner of a Lean reference count inside
4//! `lean-rs`. [`Clone`] performs `lean_inc`, [`Drop`] performs
5//! `lean_dec`, and both delegate to the `pub unsafe fn` mirrors in
6//! [`lean_rs_sys::refcount`] (the inlined fast paths). [`ObjRef`] is the
7//! borrowed view: it costs nothing on construction or drop and cannot
8//! outlive either the runtime borrow or the owning [`Obj`].
9//!
10//! Both types are `pub(crate)`; the public handles (`LeanExpr<'lean>`,
11//! `LeanName<'lean>`, `LeanSession<'lean, '_>`, …) wrap them. Raw
12//! `lean_*` symbols enter this file from `lean-rs-sys` and never leave
13//! the `pub(crate)` boundary.
14
15// SAFETY DOC: every `unsafe { ... }` block in this file carries its own
16// `// SAFETY:` comment naming the invariant. The blanket allow keeps
17// the unsafe surface inside the smallest scope that compiles, per
18// `docs/architecture/01-safety-model.md`.
19#![allow(unsafe_code)]
20// `Obj` / `ObjRef` and their methods are infrastructure consumed by the
21// `pub(crate) abi` module and by the typed `LeanExported` /
22// `LeanSession` machinery in `module` and `host`. Many helpers are only
23// reached through generic dispatch and look dead to the lib-only
24// `cargo build`; only `cargo test` exercises every branch (transitively
25// through `abi::tests` and the integration suites).
26#![allow(
27    dead_code,
28    reason = "reached through generic dispatch; lib-only build cannot prove reachability"
29)]
30
31use core::fmt;
32use core::marker::PhantomData;
33use core::mem::ManuallyDrop;
34use core::ptr::NonNull;
35
36use lean_rs_sys::lean_object;
37use lean_rs_sys::refcount::{lean_dec, lean_inc};
38
39use crate::runtime::LeanRuntime;
40
41/// Owned handle to a Lean heap or scalar object.
42///
43/// `Obj<'lean>` holds exactly one Lean reference count for the duration
44/// of its scope. [`Clone`] bumps that count via `lean_inc`; [`Drop`]
45/// releases it via `lean_dec`. The `'lean` lifetime parameter is the
46/// type-system anchor for "init-before-use": the constructor takes a
47/// `&'lean LeanRuntime` borrow, so the handle cannot exist before the
48/// runtime is up and cannot outlive the borrow it was derived from.
49///
50/// Neither [`Send`] nor [`Sync`]: the contained `NonNull<lean_object>`
51/// and the `PhantomData<&'lean LeanRuntime>` (where `LeanRuntime: !Sync`)
52/// both propagate the per-thread restriction. A negative-bound static
53/// assertion in `tests` fails to compile if either auto-trait is ever
54/// implemented.
55pub struct Obj<'lean> {
56    ptr: NonNull<lean_object>,
57    _life: PhantomData<&'lean LeanRuntime>,
58}
59
60/// Borrowed view of an [`Obj`].
61///
62/// `ObjRef<'lean, 'a>` carries no refcount obligation: construction and
63/// destruction are no-ops. The double phantom records both the runtime
64/// borrow (`'lean`) and the borrow of the owning `Obj<'lean>` (`'a`);
65/// the latter prevents a view from outliving its source.
66pub struct ObjRef<'lean, 'a> {
67    ptr: NonNull<lean_object>,
68    _life: PhantomData<(&'lean LeanRuntime, &'a Obj<'lean>)>,
69}
70
71impl<'lean> Obj<'lean> {
72    /// Wrap a raw owned Lean pointer.
73    ///
74    /// # Safety
75    ///
76    /// The caller must guarantee all of the following:
77    ///
78    /// * `ptr` is non-null and points to a live Lean object (a
79    ///   scalar-tagged pointer is allowed; `lean_inc` / `lean_dec`
80    ///   short-circuit on those).
81    /// * The caller transfers exactly one Lean reference count to this
82    ///   handle. After this call the caller must not call `lean_dec`
83    ///   on `ptr` itself; releasing the count is the new `Obj`'s job
84    ///   (via [`Drop`] or [`Obj::into_raw`]).
85    /// * `ptr` was produced by the same Lean runtime instance witnessed
86    ///   by `_runtime`. The borrow is a type-level proof that Lean is
87    ///   initialised and pins the `'lean` lifetime of the returned
88    ///   handle; it carries no payload of its own.
89    pub unsafe fn from_owned_raw(_runtime: &'lean LeanRuntime, ptr: *mut lean_object) -> Self {
90        // SAFETY: caller guarantees `ptr` is non-null (documented above);
91        // `NonNull::new_unchecked` then carries the invariant in the
92        // type. Pointer provenance and refcount transfer are the
93        // caller's obligations.
94        let ptr = unsafe { NonNull::new_unchecked(ptr) };
95        Self {
96            ptr,
97            _life: PhantomData,
98        }
99    }
100
101    /// Consume the handle and return the underlying raw owned pointer
102    /// without running [`Drop`].
103    ///
104    /// The returned pointer carries the same one reference count that
105    /// this `Obj` held. Whoever receives it inherits the obligation to
106    /// release it (via `lean_dec`, or by reconstructing an `Obj` with
107    /// [`Obj::from_owned_raw`]).
108    pub fn into_raw(self) -> *mut lean_object {
109        // `ManuallyDrop` blocks the destructor so we do not decrement
110        // the count on the way out — the caller takes ownership.
111        ManuallyDrop::new(self).ptr.as_ptr()
112    }
113
114    /// Borrow the underlying raw pointer for a borrowed-argument FFI
115    /// call (`b_lean_obj_arg`).
116    ///
117    /// The returned pointer must not be passed where a Lean function
118    /// expects to consume one reference count; that is what
119    /// [`Obj::into_raw`] is for.
120    pub fn as_raw_borrowed(&self) -> *mut lean_object {
121        self.ptr.as_ptr()
122    }
123
124    /// Produce a borrowed view tied to this `Obj`.
125    pub fn borrow(&self) -> ObjRef<'lean, '_> {
126        ObjRef {
127            ptr: self.ptr,
128            _life: PhantomData,
129        }
130    }
131
132    /// Recover the runtime borrow that anchors this handle's `'lean`.
133    ///
134    /// `LeanRuntime` is a ZST whose only construction path goes through
135    /// [`LeanRuntime::init`]; the `'lean` lifetime carried by `self` is
136    /// already a witness that a runtime borrow is live in the caller's
137    /// scope. Container readers (e.g. `Vec<T>::try_from_lean`) use this to
138    /// wrap extracted fields as fresh `Obj<'lean>` values without forcing
139    /// the runtime through every signature in the trait surface.
140    ///
141    /// `&self` rather than an associated function because the borrow
142    /// pins the inferred `'lean` lifetime to this `Obj`'s lifetime —
143    /// callers do not need to spell the parameter out at the call site.
144    #[allow(clippy::unused_self, reason = "`&self` pins the inferred 'lean lifetime parameter")]
145    pub fn runtime(&self) -> &'lean LeanRuntime {
146        // SAFETY: `LeanRuntime` is zero-sized; `NonNull::dangling()`
147        // produces an aligned non-null pointer suitable for a ZST borrow.
148        // The Lean runtime is alive whenever `'lean` is alive, so the
149        // synthesised reference is indistinguishable from the original
150        // `&LeanRuntime` borrow that witnessed `self`'s construction.
151        unsafe { NonNull::<LeanRuntime>::dangling().as_ref() }
152    }
153}
154
155impl ObjRef<'_, '_> {
156    /// Borrow the underlying raw pointer for a borrowed-argument FFI
157    /// call (`b_lean_obj_arg`).
158    pub fn as_raw_borrowed(&self) -> *mut lean_object {
159        self.ptr.as_ptr()
160    }
161}
162
163impl Clone for Obj<'_> {
164    fn clone(&self) -> Self {
165        // SAFETY: `self.ptr` is a live Lean object whose ownership we
166        // hold (refcount is at least 1); `lean_inc` short-circuits on
167        // scalar-tagged pointers and otherwise bumps the heap refcount.
168        // The Lean runtime is initialised because we hold an `Obj`
169        // bound by `'lean` to a `&LeanRuntime` borrow.
170        unsafe { lean_inc(self.ptr.as_ptr()) }
171        Self {
172            ptr: self.ptr,
173            _life: PhantomData,
174        }
175    }
176}
177
178impl Drop for Obj<'_> {
179    fn drop(&mut self) {
180        // SAFETY: `self.ptr` is the live Lean object whose one owned
181        // refcount this handle is about to release; `lean_dec` handles
182        // the scalar short-circuit and the cold-free path. The runtime
183        // is still initialised because `'lean` is alive while `self`
184        // is alive.
185        unsafe { lean_dec(self.ptr.as_ptr()) }
186    }
187}
188
189impl fmt::Debug for Obj<'_> {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        // Pointer-only: never dereference. Header inspection belongs in
192        // the `lean-rs-sys` predicates (`lean_obj_tag`,
193        // `lean_is_exclusive`, …), not in `Debug`.
194        f.debug_struct("Obj").field("ptr", &self.ptr.as_ptr()).finish()
195    }
196}
197
198impl fmt::Debug for ObjRef<'_, '_> {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        f.debug_struct("ObjRef").field("ptr", &self.ptr.as_ptr()).finish()
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    //! `cargo test -p lean-rs --lib runtime::obj::tests`
207    //!
208    //! Tests share a process; `LeanRuntime::init()` is the same cell
209    //! used by `runtime::tests`. The refcount-observation tests rely on
210    //! the `pub unsafe fn` predicates in `lean-rs-sys::object`
211    //! (`lean_is_exclusive`, `lean_is_shared`) — neither dereferences
212    //! the object payload, only the header's refcount.
213
214    #![allow(clippy::expect_used, clippy::panic)]
215
216    use core::ffi::c_char;
217
218    use lean_rs_sys::object::{lean_box, lean_is_exclusive, lean_is_shared};
219    use lean_rs_sys::refcount::lean_dec;
220    use lean_rs_sys::string::lean_mk_string;
221
222    use super::{Obj, ObjRef};
223    use crate::runtime::LeanRuntime;
224
225    /// Build a scalar-tagged handle for tests that only need to
226    /// exercise pointer-handling paths. `lean_inc` / `lean_dec`
227    /// short-circuit on scalars, so refcount math does not run.
228    fn scalar_obj(runtime: &LeanRuntime) -> Obj<'_> {
229        // SAFETY: `lean_box` is pure pointer arithmetic; the resulting
230        // scalar-tagged pointer is non-null and the refcount helpers
231        // treat it as a no-op.
232        unsafe { Obj::from_owned_raw(runtime, lean_box(7)) }
233    }
234
235    /// Build a freshly-allocated heap string (`refcount == 1`).
236    fn heap_string(runtime: &LeanRuntime) -> Obj<'_> {
237        let cstr = c"abc".as_ptr().cast::<c_char>();
238        // SAFETY: `cstr` is a valid NUL-terminated UTF-8 byte string
239        // with `'static` lifetime; `lean_mk_string` returns a
240        // `lean_obj_res` (owned, refcount 1). The runtime borrow
241        // witnesses that Lean is initialised.
242        unsafe { Obj::from_owned_raw(runtime, lean_mk_string(cstr)) }
243    }
244
245    #[test]
246    fn scalar_construction_and_drop_is_a_noop() {
247        let runtime = LeanRuntime::init().expect("runtime init must succeed");
248        let obj = scalar_obj(runtime);
249        // Drop at scope exit must not panic. The wrapper is wired to
250        // `lean_dec`, which short-circuits on scalar-tagged pointers.
251        drop(obj);
252    }
253
254    #[test]
255    fn clone_increments_heap_refcount() {
256        let runtime = LeanRuntime::init().expect("runtime init must succeed");
257        let obj = heap_string(runtime);
258
259        // SAFETY: `obj` owns the only reference; predicate only inspects
260        // the header refcount.
261        assert!(unsafe { lean_is_exclusive(obj.as_raw_borrowed()) });
262
263        let copy = obj.clone();
264        // SAFETY: `obj` and `copy` are both live owners.
265        assert!(unsafe { lean_is_shared(obj.as_raw_borrowed()) });
266        assert!(unsafe { lean_is_shared(copy.as_raw_borrowed()) });
267
268        drop(copy);
269        // SAFETY: only `obj` remains; refcount must be back to 1.
270        assert!(unsafe { lean_is_exclusive(obj.as_raw_borrowed()) });
271    }
272
273    #[test]
274    fn into_raw_does_not_decrement() {
275        let runtime = LeanRuntime::init().expect("runtime init must succeed");
276        let obj = heap_string(runtime);
277        let witness = obj.clone();
278        // Both handles live, refcount == 2.
279        // SAFETY: header-only inspection.
280        assert!(unsafe { lean_is_shared(witness.as_raw_borrowed()) });
281
282        let raw = obj.into_raw();
283        // `into_raw` transferred ownership without decrementing; the
284        // witness must still see refcount == 2.
285        // SAFETY: header-only inspection.
286        assert!(unsafe { lean_is_shared(witness.as_raw_borrowed()) });
287
288        // SAFETY: `raw` is the one reference count produced by `obj`;
289        // releasing it here pairs the transfer and keeps `witness`
290        // sole owner.
291        unsafe { lean_dec(raw) };
292        // SAFETY: header-only inspection.
293        assert!(unsafe { lean_is_exclusive(witness.as_raw_borrowed()) });
294    }
295
296    #[test]
297    fn borrow_does_not_adjust_refcount() {
298        let runtime = LeanRuntime::init().expect("runtime init must succeed");
299        let obj = heap_string(runtime);
300        // SAFETY: header-only inspection.
301        assert!(unsafe { lean_is_exclusive(obj.as_raw_borrowed()) });
302
303        let view: ObjRef<'_, '_> = obj.borrow();
304        let raw = view.as_raw_borrowed();
305        assert_eq!(raw, obj.as_raw_borrowed());
306
307        // SAFETY: header-only inspection; the borrow did not touch RC.
308        assert!(unsafe { lean_is_exclusive(obj.as_raw_borrowed()) });
309        // The view is a `Copy`-shaped no-op view; falling out of scope
310        // is the natural release path. Re-check the predicate after the
311        // last use to confirm no implicit RC adjustment happened.
312        let _ = view;
313        // SAFETY: header-only inspection.
314        assert!(unsafe { lean_is_exclusive(obj.as_raw_borrowed()) });
315    }
316
317    #[test]
318    fn debug_format_renders_pointer_without_dereferencing() {
319        let runtime = LeanRuntime::init().expect("runtime init must succeed");
320        let obj = scalar_obj(runtime);
321        let rendered = format!("{obj:?}");
322        // The shape is `Obj { ptr: 0x… }`; the contract is that
323        // formatting never reads through the pointer.
324        assert!(rendered.starts_with("Obj"));
325        assert!(rendered.contains("ptr"));
326
327        let view = obj.borrow();
328        let rendered_ref = format!("{view:?}");
329        assert!(rendered_ref.starts_with("ObjRef"));
330    }
331
332    /// `Obj<'lean>` is `!Send` and `!Sync`. Both checks use the
333    /// canonical `AmbiguousIfSend` ZST trick (`static_assertions`-style)
334    /// so this test module fails to compile if either auto-trait is
335    /// ever implemented for `Obj`.
336    ///
337    /// The marker structs (`Invalid`, `InvalidSync`) and the helper
338    /// traits exist only as type-level arguments to the trait selector;
339    /// they are never constructed or called dynamically, so we silence
340    /// `dead_code` locally rather than relax it crate-wide.
341    #[allow(dead_code, reason = "items are consumed only via trait selection at compile time")]
342    mod not_send_not_sync {
343        use super::Obj;
344
345        trait AmbiguousIfSend<A> {
346            fn check() {}
347        }
348        impl<T: ?Sized> AmbiguousIfSend<()> for T {}
349        struct Invalid;
350        impl<T: ?Sized + Send> AmbiguousIfSend<Invalid> for T {}
351
352        trait AmbiguousIfSync<A> {
353            fn check() {}
354        }
355        impl<T: ?Sized> AmbiguousIfSync<()> for T {}
356        struct InvalidSync;
357        impl<T: ?Sized + Sync> AmbiguousIfSync<InvalidSync> for T {}
358
359        fn _obj_is_not_send() {
360            // Compile-time: trait selection is ambiguous iff
361            // `Obj<'static>: Send`, which yields E0283 — that is the
362            // compile-fail signal this assertion produces. Picking
363            // `'static` is fine: `Send`/`Sync` are lifetime-invariant.
364            <Obj<'static> as AmbiguousIfSend<_>>::check();
365        }
366
367        fn _obj_is_not_sync() {
368            <Obj<'static> as AmbiguousIfSync<_>>::check();
369        }
370    }
371
372    /// Positive lifetime check: the returned `Obj<'_>` is tied to the
373    /// input runtime borrow, not `'static`. If a future refactor
374    /// weakened the lifetime — e.g. by erasing the `&LeanRuntime`
375    /// argument or returning `Obj<'static>` — the borrow checker would
376    /// refuse this function and any caller that tried to outlive the
377    /// runtime borrow.
378    fn _lifetime_anchored_to_runtime_borrow(runtime: &LeanRuntime) -> Obj<'_> {
379        // SAFETY: `lean_box` produces a scalar-tagged pointer; the
380        // refcount helpers treat it as a no-op.
381        unsafe { Obj::from_owned_raw(runtime, lean_box(0)) }
382    }
383
384    /// Iteration count for the long-running refcount stress tests.
385    ///
386    /// `LEAN_RS_REFCOUNT_STRESS_ITERS` lets the sanitizer CI job crank
387    /// the loop without code changes; the default keeps the stable
388    /// `cargo test` run in single-digit milliseconds.
389    fn stress_iters() -> usize {
390        std::env::var("LEAN_RS_REFCOUNT_STRESS_ITERS")
391            .ok()
392            .and_then(|raw| raw.parse::<usize>().ok())
393            .unwrap_or(256)
394    }
395
396    #[test]
397    fn clone_drop_cycle_preserves_exclusive_after_release() {
398        // A long Clone/Drop sequence on the same heap object must leave
399        // the refcount exactly where it started: bumping `lean_inc` for
400        // every clone and pairing it with `lean_dec` on drop is the
401        // single invariant the `Obj` wrapper exists to enforce. Under
402        // AddressSanitizer this also surfaces use-after-free if a Drop
403        // ever ran out of order with the wrapper's `ManuallyDrop` /
404        // `into_raw` paths.
405        let runtime = LeanRuntime::init().expect("runtime init must succeed");
406        let obj = heap_string(runtime);
407        // SAFETY: header-only inspection of a live owned object.
408        assert!(unsafe { lean_is_exclusive(obj.as_raw_borrowed()) });
409
410        let iters = stress_iters();
411        for _ in 0..iters {
412            let copy = obj.clone();
413            // SAFETY: header-only inspection.
414            assert!(unsafe { lean_is_shared(copy.as_raw_borrowed()) });
415            drop(copy);
416        }
417
418        // SAFETY: every clone above was paired with a drop; we should be
419        // back to the original sole owner.
420        assert!(unsafe { lean_is_exclusive(obj.as_raw_borrowed()) });
421    }
422
423    #[test]
424    fn many_independent_objs_drop_in_arbitrary_order() {
425        // Allocate a vector of distinct heap strings, clone every
426        // alternate handle to push half of them into the shared regime,
427        // then drop the originals in reverse order while keeping the
428        // clones live. Each clone must still inspect as exclusive after
429        // the matching original is gone — i.e. `Drop` released exactly
430        // one refcount, no more, no fewer. AddressSanitizer turns any
431        // double-`lean_dec` here into a heap-after-free diagnostic.
432        let runtime = LeanRuntime::init().expect("runtime init must succeed");
433        let n = stress_iters().clamp(8, 64);
434
435        let mut originals: Vec<Obj<'_>> = (0..n).map(|_| heap_string(runtime)).collect();
436        let clones: Vec<Obj<'_>> = originals.iter().map(Obj::clone).collect();
437
438        // Every original is now shared with its sibling clone.
439        for clone in &clones {
440            // SAFETY: header-only inspection.
441            assert!(unsafe { lean_is_shared(clone.as_raw_borrowed()) });
442        }
443
444        // Drop the originals last-to-first; the clones must observe a
445        // refcount transition shared → exclusive as their partner drops.
446        while let Some(orig) = originals.pop() {
447            drop(orig);
448        }
449
450        for clone in &clones {
451            // SAFETY: header-only inspection; partner original is gone.
452            assert!(unsafe { lean_is_exclusive(clone.as_raw_borrowed()) });
453        }
454    }
455
456    #[test]
457    fn panic_inside_clone_scope_does_not_leak_refcount() {
458        // A panic that unwinds past a live `Obj` must run its `Drop` so
459        // the refcount is released. We hold the original alive across
460        // the `catch_unwind`, clone it inside the closure, then panic
461        // before the clone's natural scope exit. If the panic path
462        // skipped `lean_dec`, the original would remain `lean_is_shared`
463        // after the catch; if it double-`lean_dec`d, AddressSanitizer
464        // would flag a heap-after-free on the next access.
465        use std::panic::{self, AssertUnwindSafe};
466
467        let runtime = LeanRuntime::init().expect("runtime init must succeed");
468        let obj = heap_string(runtime);
469        // SAFETY: header-only inspection of a live owned object.
470        assert!(unsafe { lean_is_exclusive(obj.as_raw_borrowed()) });
471
472        let outcome = panic::catch_unwind(AssertUnwindSafe(|| {
473            let copy = obj.clone();
474            // SAFETY: header-only inspection.
475            assert!(unsafe { lean_is_shared(copy.as_raw_borrowed()) });
476            panic!("synthetic panic — `copy` must drop on the unwind path");
477        }));
478        assert!(outcome.is_err(), "closure was expected to panic");
479
480        // SAFETY: header-only inspection; the clone's `Drop` must have
481        // run on the unwind path, restoring sole ownership to `obj`.
482        assert!(unsafe { lean_is_exclusive(obj.as_raw_borrowed()) });
483    }
484}