Skip to main content

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}