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