Skip to main content

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}