Skip to main content

lua_types/
gc.rs

1//! `GcRef<T>` — the GC-managed reference handle.
2//!
3//! Newtype around `lua_gc::Gc<T>` — Copy under the hood, tracks allocation
4//! in the active `Heap` (via `lua_gc::with_current_heap(...)`). An earlier
5//! revision was a thin `Rc<T>` newtype instead; the surface stayed stable
6//! across the swap (`new`, `ptr_eq`, `identity`, `strong_count`,
7//! `weak_count`, `downgrade`), and existing code touching `gc.0` continues
8//! to work — `.0` is now `Gc<T>` instead of `Rc<T>`.
9//!
10//! # Weak refs
11//!
12//! Heap-tracked `GcWeak<T>` handles remember the heap active when they were
13//! created plus the target's heap allocation token. They upgrade only while
14//! that identity/token pair remains live. Handles to legacy uncollected boxes
15//! still upgrade forever, matching their process-lifetime allocation model.
16//!
17//! # Internal GC capability, not the embedding surface (issue #267)
18//!
19//! `GcRef` is a raw, `Copy`, guard-scoped GC capability: it carries no owner
20//! identity of its own, and its operations assume an active `HeapGuard` for the
21//! heap that owns the box. Holding a `GcRef` (or an issued `&T` from its
22//! `Deref`) across the owning heap's `drop_all`/`close`, or operating it under a
23//! *different* heap, is a use-after-free that safe code must not do. The
24//! guard-scoped operations here fail loudly on the one shape that is closable
25//! without reading the box — `downgrade`/`account_buffer` panic **deref-free**
26//! with no active guard (issue #267 F1/F3). The stale-*after-sweep* and
27//! foreign-heap shapes are **not** closed: validating them would mean reading a
28//! possibly-freed box, which *is* the use-after-free, so the raw type cannot
29//! make arbitrary handle lifetimes safe in release without slot-indexed handles
30//! / an API-shape change (spec option B).
31//!
32//! **`GcRef` is therefore an implementation-crate (`lua-types`/`lua-gc`)
33//! capability, not omniLua's public embedding surface.** Embedders never name
34//! it: the `omnilua` facade exposes only rooted handles (its `Value` /
35//! `RootedValue`, which own a `Lua`, root through a slab key, and return a
36//! stale-handle error on cross-state or post-teardown use) and deliberately does
37//! not re-export raw `Gc`/`GcRef`/`Heap`. Keep it that way — routing embedders
38//! through the rooted handle is what makes the "a handle must not outlive its
39//! heap" invariant real.
40
41use lua_gc::{Gc, HeapRef, Trace};
42
43/// A GC-managed pointer to a Lua collectable object. Newtype over
44/// `lua_gc::Gc<T>` so callers preserve `gc.0`-shape access while the
45/// backend swaps under them.
46#[derive(Debug)]
47pub struct GcRef<T: Trace + 'static>(pub Gc<T>);
48
49impl<T: Trace + 'static> GcRef<T> {
50    /// Allocate a new GC-tracked value on the active heap: joins the allgc
51    /// chain under a `HeapGuard` (set by `state.run()` / `pcall_k` /
52    /// `with_state`), or the heap's bootstrap list inside a bootstrap
53    /// window.
54    ///
55    /// Panics if no heap is active. There is no fallback: the old detached
56    /// arm allocated a box no heap ever freed (issue #249's leak class), and
57    /// since #253 moved host-side `LuaError` messages to owned bytes, no
58    /// legitimate guard-less allocation path remains — a panic here is
59    /// always a missing guard on the entry path, and the message says so.
60    pub fn new(value: T) -> Self {
61        let gc = lua_gc::with_current_heap(|heap| match heap {
62            Some(heap) => heap.allocate(value),
63            None => panic!(
64                "GcRef::new::<{}> with no active HeapGuard — a detached allocation \
65                 would never be freed (issue #249 class); push a HeapGuard or \
66                 bootstrap window on the entry path",
67                std::any::type_name::<T>()
68            ),
69        });
70        GcRef(gc)
71    }
72}
73
74impl<T: Trace + 'static> GcRef<T> {
75    /// Two `GcRef`s are identity-equal iff they point at the same box.
76    pub fn ptr_eq(a: &Self, b: &Self) -> bool {
77        Gc::ptr_eq(a.0, b.0)
78    }
79
80    /// Identity as a usize — used as a HashMap key for "same object" tests.
81    pub fn identity(&self) -> usize {
82        self.0.identity()
83    }
84
85    /// Number of strong references. `GcRef` is not reference-counted, so a live
86    /// handle reports one owning GC reachability handle.
87    pub fn strong_count(&self) -> usize {
88        1
89    }
90
91    /// Number of weak references. Weak handles are not counted.
92    pub fn weak_count(&self) -> usize {
93        0
94    }
95
96    /// Get a weak handle. If this allocation belongs to the currently-active
97    /// heap, the weak handle will stop upgrading once sweep removes that exact
98    /// heap allocation.
99    ///
100    /// # No-guard path is deref-free (issue #267 F1)
101    ///
102    /// With no active `HeapGuard` there is no heap to validate against, and the
103    /// handle **must not** read the box to decide — if its owning heap has been
104    /// dropped, the box is freed and that read is itself the use-after-free (the
105    /// old `is_heap_owned()` check was exactly this UAF-in-the-safety-check). So
106    /// this panics unconditionally, reading nothing, consistent with
107    /// [`GcRef::new`]'s guard-less panic and the always-on guard policy. The
108    /// legacy detached-box "always upgrades on a guard-less downgrade" path is
109    /// dropped with it.
110    ///
111    /// # Guarded path and residuals (issue #267)
112    ///
113    /// The guarded path mints a token against the active heap *without*
114    /// dereferencing the box — so a same-heap downgrade is validated by the
115    /// allocation token, and when the active guard is a *closed* heap the
116    /// closed-heap token refusal makes the resulting weak handle permanently
117    /// dead. The stale corners this cannot fix in release — a *foreign*-heap
118    /// downgrade (the box's owner is a different heap), a same-heap re-downgrade
119    /// of a box already swept while unrooted, and an already-issued `&T`
120    /// outliving a later `drop_all(&self)` — are **not** closed here: validating
121    /// them would require reading the (possibly freed) box, which *is* the
122    /// use-after-free. They are documented residuals that only slot-indexed
123    /// handles / an API-shape change (spec option B) close; see the spec and the
124    /// `stale_handle_kit`.
125    pub fn downgrade(&self) -> GcWeak<T> {
126        let identity = self.identity();
127        match lua_gc::with_current_heap(|heap| {
128            heap.map(|heap| {
129                let token = heap.register_allocation_token(identity);
130                (HeapRef::from_heap(heap), token)
131            })
132        }) {
133            Some((heap, allocation_token)) => GcWeak {
134                target: self.0,
135                identity,
136                allocation_token,
137                heap: Some(heap),
138            },
139            None => panic!(
140                "GcRef::downgrade::<{}> with no active HeapGuard — a GcRef \
141                 operated outside its owning heap's guard cannot be validated \
142                 against any heap, and reading the box to decide would be a \
143                 use-after-free if the heap has been dropped; push a HeapGuard \
144                 on the entry path",
145                std::any::type_name::<T>()
146            ),
147        }
148    }
149
150    /// Charge (`delta > 0`) or refund (`delta < 0`) bytes of this object's
151    /// owned heap buffers against the active heap's pacer, so collections
152    /// fire at honest memory pressure. No-op on `delta == 0` or when the
153    /// underlying box is uncollected (see [`lua_gc::Gc::account_buffer`]).
154    ///
155    /// # No-guard path is deref-free (issue #267 F3)
156    ///
157    /// With no active `HeapGuard` this panics unconditionally, reading nothing:
158    /// the charge would otherwise be silently dropped (pacer drift), and — as
159    /// with [`downgrade`](Self::downgrade) — reading the box to decide would be
160    /// a use-after-free if the heap has been dropped (the old `is_heap_owned()`
161    /// check was that UAF). The legacy detached no-op path is dropped with it.
162    ///
163    /// The guarded charge intrinsically reads the box (it mutates
164    /// `header.size`), so — unlike the no-guard path — it cannot be made
165    /// deref-free: a charge against a stale box under an active guard reads that
166    /// box, which is the same option-B residual as `downgrade`. Under quarantine
167    /// a same-heap swept target is caught by the `HDR_FREED` `debug_assert` in
168    /// `as_box`; in release it is a documented residual (see the spec).
169    pub fn account_buffer(&self, delta: isize) {
170        if delta == 0 {
171            return;
172        }
173        match lua_gc::with_current_heap(|h| {
174            h.map(|h| {
175                self.0.account_buffer(h, delta);
176            })
177        }) {
178            Some(()) => {}
179            None => panic!(
180                "GcRef::account_buffer::<{}>({delta}) with no active HeapGuard \
181                 — the charge would be silently dropped and the pacer would \
182                 drift from real memory, and reading the box to decide would be \
183                 a use-after-free if the heap has been dropped; push a HeapGuard \
184                 on the entry path",
185                std::any::type_name::<T>()
186            ),
187        }
188    }
189}
190
191/// A weak handle to a `GcRef<T>`.
192#[derive(Debug)]
193pub struct GcWeak<T: Trace + 'static> {
194    target: Gc<T>,
195    identity: usize,
196    allocation_token: usize,
197    heap: Option<HeapRef>,
198}
199
200impl<T: Trace + 'static> GcWeak<T> {
201    /// Try to promote to a strong reference.
202    pub fn upgrade(&self) -> Option<GcRef<T>> {
203        if let Some(heap) = &self.heap {
204            if !heap.contains_allocation(self.identity, self.allocation_token) {
205                return None;
206            }
207        }
208        Some(GcRef(self.target))
209    }
210
211    /// Strong reference count of the target from this weak handle's point of
212    /// view: one while it can still upgrade, zero after sweep.
213    pub fn strong_count(&self) -> usize {
214        usize::from(self.upgrade().is_some())
215    }
216
217    pub fn identity(&self) -> usize {
218        self.identity
219    }
220}
221
222impl<T: Trace + 'static> Clone for GcWeak<T> {
223    fn clone(&self) -> Self {
224        GcWeak {
225            target: self.target,
226            identity: self.identity,
227            allocation_token: self.allocation_token,
228            heap: self.heap.clone(),
229        }
230    }
231}
232
233impl<T: Trace + 'static> Clone for GcRef<T> {
234    fn clone(&self) -> Self {
235        GcRef(self.0)
236    }
237}
238
239impl<T: Trace + 'static> Copy for GcRef<T> {}
240
241impl<T: Trace + 'static> std::ops::Deref for GcRef<T> {
242    type Target = T;
243    fn deref(&self) -> &T {
244        &*self.0
245    }
246}
247
248impl<T: Trace + 'static> AsRef<T> for GcRef<T> {
249    fn as_ref(&self) -> &T {
250        &*self.0
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use lua_gc::Marker;
258
259    struct NoRoots;
260
261    impl Trace for NoRoots {
262        fn trace(&self, _m: &mut Marker) {}
263    }
264
265    #[derive(Debug)]
266    struct Cell0;
267
268    impl Trace for Cell0 {
269        fn trace(&self, _m: &mut Marker) {}
270    }
271
272    #[test]
273    fn heap_tracked_weak_refs_stop_upgrading_after_sweep() {
274        let heap = lua_gc::Heap::new();
275        heap.unpause();
276        let _guard = lua_gc::HeapGuard::push(&heap);
277
278        let strong = GcRef::new(Cell0);
279        let weak = strong.downgrade();
280        assert!(weak.upgrade().is_some());
281        assert_eq!(weak.strong_count(), 1);
282
283        heap.full_collect(&NoRoots);
284        assert!(weak.upgrade().is_none());
285        assert_eq!(weak.strong_count(), 0);
286    }
287
288    /// The detached fallback is gone (#253): a guard-less allocation is a
289    /// bug on the entry path, and it must fail loudly in every build, not
290    /// leak quietly for the life of the process.
291    #[test]
292    #[should_panic(expected = "no active HeapGuard")]
293    fn guardless_allocation_panics() {
294        let _ = GcRef::new(Cell0);
295    }
296
297    /// A guard-less `downgrade` of a heap-owned box mints a `GcWeak` with no
298    /// heap identity, so it upgrades forever — including after sweep frees the
299    /// target (use-after-free). That now panics unconditionally, not just
300    /// under a retired env flag.
301    #[test]
302    #[should_panic(expected = "no active HeapGuard")]
303    fn guardless_downgrade_of_heap_owned_box_panics() {
304        let heap = lua_gc::Heap::new();
305        let strong = {
306            let _guard = lua_gc::HeapGuard::push(&heap);
307            GcRef::new(Cell0)
308        };
309        let _ = strong.downgrade();
310    }
311
312    /// A guard-less `account_buffer` on a heap-owned box would silently drop
313    /// the pacer charge, drifting the collector from real memory. That now
314    /// panics unconditionally, not just under a retired env flag.
315    #[test]
316    #[should_panic(expected = "no active HeapGuard")]
317    fn guardless_account_buffer_on_heap_owned_box_panics() {
318        let heap = lua_gc::Heap::new();
319        let strong = {
320            let _guard = lua_gc::HeapGuard::push(&heap);
321            GcRef::new(Cell0)
322        };
323        strong.account_buffer(64);
324    }
325
326    /// Codex finding 2 on issue #260: downgrading a stale `GcRef` AFTER the
327    /// heap closed (its box already freed by `drop_all`) must yield a weak
328    /// handle that never upgrades. Before the closed-heap token refusal,
329    /// `downgrade` re-registered the freed box's address in the token map and
330    /// the weak handle upgraded into freed memory.
331    #[test]
332    fn downgrade_after_close_cannot_resurrect_freed_box() {
333        let heap = lua_gc::Heap::new();
334        heap.unpause();
335        let _guard = lua_gc::HeapGuard::push(&heap);
336
337        let stale = GcRef::new(Cell0);
338        heap.drop_all();
339
340        let weak = stale.downgrade();
341        assert!(
342            weak.upgrade().is_none(),
343            "a weak handle minted after close must never upgrade — the box \
344             is freed and the heap is closed"
345        );
346    }
347
348    /// Issue #267 regression: the same close-then-downgrade sequence must stay
349    /// sound under quarantine. A withdrawn revision of #267 added a "tripwire"
350    /// that read the target box header at `downgrade` time whenever the *active*
351    /// heap was quarantined — but the active heap's quarantine flag says nothing
352    /// about whether the target's owner already freed the box, so this deref'd
353    /// freed memory *inside* the safety check (codex found it here). `downgrade`
354    /// must never read the box: on a closed heap the token refusal alone makes
355    /// the weak permanently dead, with no header read.
356    #[test]
357    fn downgrade_after_close_under_quarantine_reads_no_box() {
358        let heap = lua_gc::Heap::new_quarantined();
359        heap.unpause();
360        let _guard = lua_gc::HeapGuard::push(&heap);
361
362        let stale = GcRef::new(Cell0);
363        heap.drop_all();
364
365        let weak = stale.downgrade();
366        assert!(weak.upgrade().is_none());
367    }
368}
369
370impl<T: PartialEq + Trace + 'static> PartialEq for GcRef<T> {
371    fn eq(&self, other: &Self) -> bool {
372        Gc::ptr_eq(self.0, other.0) || **self == **other
373    }
374}