luna_core/vm/host_roots.rs
1//! v1.3 Phase SR — host root pool types and slot-recycling API.
2//!
3//! This module owns the type definitions (`HostRootSlot` (private),
4//! [`HostRootTicket`], [`HostRootStale`]) plus the `pin_host` /
5//! `read_host` / `write_host` / `unpin` / `unpin_all` /
6//! `host_root_count` impls. The `Vm` struct itself (in
7//! `crate::vm::exec`) carries two fields:
8//!
9//! ```ignore
10//! pub(crate) host_roots: Vec<HostRootSlot>,
11//! pub(crate) host_roots_free: Vec<u32>,
12//! ```
13//!
14//! The pool replaces the v1.1 append-only `Vec<Value>`. Long-running
15//! embedders (request-per-script loops, edge workers) now release
16//! single pins via [`Vm::unpin`] without forcing `unpin_all` between
17//! requests; slots are recycled via a free list, and `HostRootTicket`
18//! carries an ABA-safe generation counter so a stale ticket (held
19//! across an unpin/re-pin cycle on the same slot) reads as `None` /
20//! `Err(HostRootStale)`.
21//!
22//! The GC tracer (in `crate::vm::exec`) walks each slot's `value`;
23//! free-list slots carry `Value::Nil` which is a GC no-op, so we don't
24//! bother branching on free vs live in the tracer hot path.
25//!
26//! See `.dev/rfcs/v1.3-audit-slot-recycling.md` for the design rationale.
27
28use crate::runtime::value::Value;
29use crate::vm::exec::Vm;
30
31/// v1.3 Phase SR — one slot in the host root pool.
32///
33/// `value == Value::Nil` when the slot is on the free list; the GC
34/// tracer treats `Nil` as a no-op so free slots cost nothing to
35/// trace. `generation` is bumped on every fresh `pin_host`
36/// allocation into this slot AND on every `unpin` / `unpin_all`.
37#[derive(Copy, Clone, Debug)]
38pub(crate) struct HostRootSlot {
39 pub(crate) value: Value,
40 pub(crate) generation: u32,
41}
42
43/// v1.3 Phase SR — opaque handle to a pinned host root.
44///
45/// `Copy` so embedder handle types (`LuaFunction` / `LuaTable` /
46/// `LuaRoot`) stay `Copy`. Two `u32` fields → 8 bytes total, fits in
47/// a register; matches `usize` payload size on 64-bit and is smaller
48/// on 32-bit.
49///
50/// Embedders store / copy / compare tickets but cannot mint a fake
51/// one — fields are crate-private, the only constructor is
52/// [`Vm::pin_host`].
53///
54/// Generation overflow at `u32::MAX` retires the slot permanently
55/// (the index is NOT pushed to the free list; future `pin_host`
56/// allocations bypass it). At 10⁹ unpins/day per slot that's ~4 days;
57/// lifetime leak is bounded.
58#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
59pub struct HostRootTicket {
60 pub(crate) idx: u32,
61 pub(crate) generation: u32,
62}
63
64impl HostRootTicket {
65 /// Slot index this ticket targets. Diagnostic / facade-author use
66 /// only — embedders should not rely on numerical identity.
67 pub fn idx(self) -> u32 {
68 self.idx
69 }
70
71 /// Generation this ticket was issued at. Diagnostic only —
72 /// equality against the live slot's current generation determines
73 /// validity.
74 pub fn generation(self) -> u32 {
75 self.generation
76 }
77}
78
79/// v1.3 Phase SR — error returned by [`Vm::write_host`] /
80/// [`Vm::unpin`] when the supplied ticket's `generation` no longer
81/// matches the live slot. Indicates the slot has been unpinned (and
82/// possibly re-pinned to an unrelated value) since the ticket was
83/// issued.
84#[derive(Copy, Clone, Debug, Eq, PartialEq)]
85pub struct HostRootStale;
86
87impl std::fmt::Display for HostRootStale {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.write_str("host root ticket is stale (slot was unpinned and possibly re-pinned)")
90 }
91}
92
93impl std::error::Error for HostRootStale {}
94
95impl Vm {
96 /// Pin `v` as a host root. Reuses a recycled slot if the free
97 /// list is non-empty, else extends the pool. Bumps the slot's
98 /// generation; previously-issued tickets for that slot become
99 /// stale (`read_host` returns `None`, `write_host` / `unpin`
100 /// return `Err(HostRootStale)`).
101 ///
102 /// Returns a [`HostRootTicket`] (`Copy`, 8 bytes). The value
103 /// becomes an extra GC root until the ticket is released via
104 /// [`Self::unpin`] or the whole pool via [`Self::unpin_all`].
105 pub fn pin_host(&mut self, v: Value) -> HostRootTicket {
106 if let Some(idx) = self.host_roots_free.pop() {
107 let slot = &mut self.host_roots[idx as usize];
108 // Bump generation on every fresh allocation into this
109 // slot so any stale ticket for this index reads as None.
110 // Saturating: a retired slot (generation == u32::MAX)
111 // would stay retired — its index never appears on the
112 // free list, so this branch is normally unreachable for
113 // retired slots; the saturating add is defensive.
114 slot.generation = slot.generation.saturating_add(1);
115 slot.value = v;
116 HostRootTicket {
117 idx,
118 generation: slot.generation,
119 }
120 } else {
121 let idx = self.host_roots.len() as u32;
122 // Generation starts at 0 for a freshly allocated slot;
123 // `unpin` bumps to 1 before pushing to the free list,
124 // and the next `pin_host` into that slot bumps to 2.
125 self.host_roots.push(HostRootSlot {
126 value: v,
127 generation: 0,
128 });
129 HostRootTicket { idx, generation: 0 }
130 }
131 }
132
133 /// Read a previously pinned host root. Returns `None` if the
134 /// ticket is stale (slot was unpinned and possibly re-pinned to a
135 /// different value) or if the ticket index is out of bounds.
136 pub fn read_host(&self, t: HostRootTicket) -> Option<Value> {
137 let slot = self.host_roots.get(t.idx as usize)?;
138 if slot.generation == t.generation {
139 Some(slot.value)
140 } else {
141 None
142 }
143 }
144
145 /// Mutate a previously pinned host root in place. Returns
146 /// `Err(HostRootStale)` on stale ticket; otherwise updates the
147 /// slot's value WITHOUT bumping generation (mutation does not
148 /// invalidate other live aliases of the same ticket).
149 pub fn write_host(&mut self, t: HostRootTicket, v: Value) -> Result<(), HostRootStale> {
150 let slot = self
151 .host_roots
152 .get_mut(t.idx as usize)
153 .ok_or(HostRootStale)?;
154 if slot.generation == t.generation {
155 slot.value = v;
156 Ok(())
157 } else {
158 Err(HostRootStale)
159 }
160 }
161
162 /// Drop a single pinned root. Clears the slot's value to `Nil`,
163 /// bumps the slot's generation, and pushes the index onto the
164 /// free list for reuse. Returns `Err(HostRootStale)` if the
165 /// ticket is stale (already-unpinned / re-pinned slot); the
166 /// pool is unchanged in that case.
167 ///
168 /// Generation overflow at `u32::MAX` retires the slot
169 /// permanently — the index is NOT pushed to the free list, and
170 /// future `pin_host` calls will allocate a fresh slot rather
171 /// than reuse this one.
172 pub fn unpin(&mut self, t: HostRootTicket) -> Result<(), HostRootStale> {
173 let slot = self
174 .host_roots
175 .get_mut(t.idx as usize)
176 .ok_or(HostRootStale)?;
177 if slot.generation != t.generation {
178 return Err(HostRootStale);
179 }
180 slot.value = Value::Nil;
181 if slot.generation == u32::MAX {
182 // Retire: do not push to free list. The slot stays at
183 // generation == u32::MAX with value Nil; the GC tracer
184 // will continue to walk it as a no-op, but no further
185 // `pin_host` will reuse it.
186 return Ok(());
187 }
188 slot.generation += 1;
189 self.host_roots_free.push(t.idx);
190 Ok(())
191 }
192
193 /// Number of currently-pinned (live) host roots. Diagnostic only.
194 ///
195 /// Computed as `host_roots.len() - host_roots_free.len()` — this
196 /// over-counts retired-by-overflow slots as still-allocated. For
197 /// a short-lived process the difference is sub-MB; long-running
198 /// servers should treat this as an upper bound on live pins.
199 pub fn host_root_count(&self) -> usize {
200 self.host_roots.len() - self.host_roots_free.len()
201 }
202
203 /// Drop every pinned host root. Embedders driving the `Lua`
204 /// facade in a request-per-script loop call this to release a
205 /// batch of pins in one shot. Bumps every slot's generation;
206 /// every previously-issued ticket becomes stale uniformly.
207 ///
208 /// Keeps the underlying `Vec` capacity to amortize future
209 /// `pin_host` allocations. Slots that already reached
210 /// `generation == u32::MAX` stay retired (not added back to the
211 /// free list).
212 pub fn unpin_all(&mut self) {
213 self.host_roots_free.clear();
214 for (i, slot) in self.host_roots.iter_mut().enumerate() {
215 slot.value = Value::Nil;
216 if slot.generation == u32::MAX {
217 // Already retired — skip.
218 continue;
219 }
220 slot.generation += 1;
221 self.host_roots_free.push(i as u32);
222 }
223 }
224}