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
17use lua_gc::{Gc, HeapRef, Marker, Trace};
18
19/// A GC-managed pointer to a Lua collectable object. Newtype over
20/// `lua_gc::Gc<T>` so callers preserve `gc.0`-shape access while the
21/// backend swaps under them.
22#[derive(Debug)]
23pub struct GcRef<T: Trace + 'static>(pub Gc<T>);
24
25impl<T: Trace + 'static> GcRef<T> {
26 /// Allocate a new GC-tracked value on the active heap: joins the allgc
27 /// chain under a `HeapGuard` (set by `state.run()` / `pcall_k` /
28 /// `with_state`), or the heap's bootstrap list inside a bootstrap
29 /// window.
30 ///
31 /// Panics if no heap is active. There is no fallback: the old detached
32 /// arm allocated a box no heap ever freed (issue #249's leak class), and
33 /// since #253 moved host-side `LuaError` messages to owned bytes, no
34 /// legitimate guard-less allocation path remains — a panic here is
35 /// always a missing guard on the entry path, and the message says so.
36 pub fn new(value: T) -> Self {
37 let gc = lua_gc::with_current_heap(|heap| match heap {
38 Some(heap) => heap.allocate(value),
39 None => panic!(
40 "GcRef::new::<{}> with no active HeapGuard — a detached allocation \
41 would never be freed (issue #249 class); push a HeapGuard or \
42 bootstrap window on the entry path",
43 std::any::type_name::<T>()
44 ),
45 });
46 GcRef(gc)
47 }
48
49 /// Cycle-aware trace dispatch via `Marker::try_visit`'s identity
50 /// short-circuit, rather than the in-header color flag that
51 /// `Trace for GcRef<T>` (in `trace_impls.rs`) uses directly via
52 /// `m.mark(self.0)`. Nothing in this workspace currently calls
53 /// `trace_obj`.
54 pub fn trace_obj(&self, m: &mut Marker) {
55 if m.try_visit(self.identity()) {
56 (**self).trace(m);
57 }
58 }
59}
60
61impl<T: Trace + 'static> GcRef<T> {
62 /// Two `GcRef`s are identity-equal iff they point at the same box.
63 pub fn ptr_eq(a: &Self, b: &Self) -> bool {
64 Gc::ptr_eq(a.0, b.0)
65 }
66
67 /// Identity as a usize — used as a HashMap key for "same object" tests.
68 pub fn identity(&self) -> usize {
69 self.0.identity()
70 }
71
72 /// Number of strong references. `GcRef` is not reference-counted, so a live
73 /// handle reports one owning GC reachability handle.
74 pub fn strong_count(&self) -> usize {
75 1
76 }
77
78 /// Number of weak references. Weak handles are not counted.
79 pub fn weak_count(&self) -> usize {
80 0
81 }
82
83 /// Get a weak handle. If this allocation belongs to the currently-active
84 /// heap, the weak handle will stop upgrading once sweep removes that exact
85 /// heap allocation.
86 ///
87 /// Downgrading a *heap-owned* box with no active `HeapGuard` is a
88 /// latent use-after-free: the resulting handle has no heap identity to
89 /// check, so it keeps upgrading after sweep frees the target. That case
90 /// panics unconditionally in every build; detached (process-lifetime)
91 /// targets keep the legacy always-upgrades behavior.
92 ///
93 /// A `GcRef` obtained before its heap closed (`Heap::drop_all` /
94 /// `close`) is dangling afterwards, and calling this on it is
95 /// use-after-free — the same contract as after `Heap::drop`. (Quarantine
96 /// mode detects use-after-*sweep* while the heap is alive; teardown
97 /// frees even quarantined boxes for real, so post-close dereferences are
98 /// plain UB with no tripwire.) When the *closed* heap is the active
99 /// guard, the closed-heap token refusal makes the resulting weak handle
100 /// permanently dead; with no guard or a different heap active, the
101 /// guard-mismatch cases above apply unchanged. A `Gc` box carries no
102 /// owner identity of its own to validate against — fixing that is the
103 /// heap-ownership redesign tracked as the #252 follow-up, not a #260
104 /// teardown property.
105 pub fn downgrade(&self) -> GcWeak<T> {
106 let identity = self.identity();
107 let tracked = lua_gc::with_current_heap(|heap| {
108 heap.map(|heap| {
109 let token = heap.register_allocation_token(identity);
110 (HeapRef::from_heap(heap), token)
111 })
112 });
113 if tracked.is_none() && self.0.is_heap_owned() {
114 panic!(
115 "GcRef::downgrade::<{}> on a heap-owned box with no \
116 active HeapGuard — the weak handle would upgrade forever, including after \
117 sweep frees the target (use-after-free); push a HeapGuard on the entry path",
118 std::any::type_name::<T>()
119 );
120 }
121 let (heap, allocation_token) = match tracked {
122 Some((heap, token)) => (Some(heap), token),
123 None => (None, 0),
124 };
125 GcWeak {
126 target: self.0,
127 identity,
128 allocation_token,
129 heap,
130 }
131 }
132
133 /// Charge (`delta > 0`) or refund (`delta < 0`) bytes of this object's
134 /// owned heap buffers against the active heap's pacer, so collections
135 /// fire at honest memory pressure. No-op on `delta == 0` or when the
136 /// underlying box is uncollected (see [`lua_gc::Gc::account_buffer`]).
137 ///
138 /// A guard-less charge on a *heap-owned* box panics unconditionally in
139 /// every build: the charge would be silently dropped and the pacer would
140 /// drift from real memory. Detached (uncollected, process-lifetime) boxes
141 /// keep the legacy no-op behavior.
142 pub fn account_buffer(&self, delta: isize) {
143 if delta == 0 {
144 return;
145 }
146 lua_gc::with_current_heap(|h| match h {
147 Some(h) => self.0.account_buffer(h, delta),
148 None => {
149 if self.0.is_heap_owned() {
150 panic!(
151 "account_buffer({delta}) on a heap-owned \
152 {} with no active HeapGuard — the charge would be silently dropped \
153 and the pacer would drift from real memory; push a HeapGuard on \
154 the entry path",
155 std::any::type_name::<T>()
156 );
157 }
158 }
159 })
160 }
161}
162
163/// A weak handle to a `GcRef<T>`.
164#[derive(Debug)]
165pub struct GcWeak<T: Trace + 'static> {
166 target: Gc<T>,
167 identity: usize,
168 allocation_token: usize,
169 heap: Option<HeapRef>,
170}
171
172impl<T: Trace + 'static> GcWeak<T> {
173 /// Try to promote to a strong reference.
174 pub fn upgrade(&self) -> Option<GcRef<T>> {
175 if let Some(heap) = &self.heap {
176 if !heap.contains_allocation(self.identity, self.allocation_token) {
177 return None;
178 }
179 }
180 Some(GcRef(self.target))
181 }
182
183 /// Strong reference count of the target from this weak handle's point of
184 /// view: one while it can still upgrade, zero after sweep.
185 pub fn strong_count(&self) -> usize {
186 usize::from(self.upgrade().is_some())
187 }
188
189 pub fn identity(&self) -> usize {
190 self.identity
191 }
192}
193
194impl<T: Trace + 'static> Clone for GcWeak<T> {
195 fn clone(&self) -> Self {
196 GcWeak {
197 target: self.target,
198 identity: self.identity,
199 allocation_token: self.allocation_token,
200 heap: self.heap.clone(),
201 }
202 }
203}
204
205impl<T: Trace + 'static> Clone for GcRef<T> {
206 fn clone(&self) -> Self {
207 GcRef(self.0)
208 }
209}
210
211impl<T: Trace + 'static> Copy for GcRef<T> {}
212
213impl<T: Trace + 'static> std::ops::Deref for GcRef<T> {
214 type Target = T;
215 fn deref(&self) -> &T {
216 &*self.0
217 }
218}
219
220impl<T: Trace + 'static> AsRef<T> for GcRef<T> {
221 fn as_ref(&self) -> &T {
222 &*self.0
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 struct NoRoots;
231
232 impl Trace for NoRoots {
233 fn trace(&self, _m: &mut Marker) {}
234 }
235
236 #[derive(Debug)]
237 struct Cell0;
238
239 impl Trace for Cell0 {
240 fn trace(&self, _m: &mut Marker) {}
241 }
242
243 #[test]
244 fn heap_tracked_weak_refs_stop_upgrading_after_sweep() {
245 let heap = lua_gc::Heap::new();
246 heap.unpause();
247 let _guard = lua_gc::HeapGuard::push(&heap);
248
249 let strong = GcRef::new(Cell0);
250 let weak = strong.downgrade();
251 assert!(weak.upgrade().is_some());
252 assert_eq!(weak.strong_count(), 1);
253
254 heap.full_collect(&NoRoots);
255 assert!(weak.upgrade().is_none());
256 assert_eq!(weak.strong_count(), 0);
257 }
258
259 /// The detached fallback is gone (#253): a guard-less allocation is a
260 /// bug on the entry path, and it must fail loudly in every build, not
261 /// leak quietly for the life of the process.
262 #[test]
263 #[should_panic(expected = "no active HeapGuard")]
264 fn guardless_allocation_panics() {
265 let _ = GcRef::new(Cell0);
266 }
267
268 /// A guard-less `downgrade` of a heap-owned box mints a `GcWeak` with no
269 /// heap identity, so it upgrades forever — including after sweep frees the
270 /// target (use-after-free). That now panics unconditionally, not just
271 /// under a retired env flag.
272 #[test]
273 #[should_panic(expected = "no active HeapGuard")]
274 fn guardless_downgrade_of_heap_owned_box_panics() {
275 let heap = lua_gc::Heap::new();
276 let strong = {
277 let _guard = lua_gc::HeapGuard::push(&heap);
278 GcRef::new(Cell0)
279 };
280 let _ = strong.downgrade();
281 }
282
283 /// A guard-less `account_buffer` on a heap-owned box would silently drop
284 /// the pacer charge, drifting the collector from real memory. That now
285 /// panics unconditionally, not just under a retired env flag.
286 #[test]
287 #[should_panic(expected = "no active HeapGuard")]
288 fn guardless_account_buffer_on_heap_owned_box_panics() {
289 let heap = lua_gc::Heap::new();
290 let strong = {
291 let _guard = lua_gc::HeapGuard::push(&heap);
292 GcRef::new(Cell0)
293 };
294 strong.account_buffer(64);
295 }
296
297 /// Codex finding 2 on issue #260: downgrading a stale `GcRef` AFTER the
298 /// heap closed (its box already freed by `drop_all`) must yield a weak
299 /// handle that never upgrades. Before the closed-heap token refusal,
300 /// `downgrade` re-registered the freed box's address in the token map and
301 /// the weak handle upgraded into freed memory.
302 #[test]
303 fn downgrade_after_close_cannot_resurrect_freed_box() {
304 let heap = lua_gc::Heap::new();
305 heap.unpause();
306 let _guard = lua_gc::HeapGuard::push(&heap);
307
308 let stale = GcRef::new(Cell0);
309 heap.drop_all();
310
311 let weak = stale.downgrade();
312 assert!(
313 weak.upgrade().is_none(),
314 "a weak handle minted after close must never upgrade — the box \
315 is freed and the heap is closed"
316 );
317 }
318}
319
320impl<T: PartialEq + Trace + 'static> PartialEq for GcRef<T> {
321 fn eq(&self, other: &Self) -> bool {
322 Gc::ptr_eq(self.0, other.0) || **self == **other
323 }
324}