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}