Skip to main content

kevy_lua/
lib.rs

1//! kevy-lua — Redis EVAL / EVALSHA / SCRIPT surface backed by luna-core.
2//!
3//! kevy's v1.27 script-host layer. Thin "cement" crate (per the
4//! stone-cement-stone model) — it carries no algorithmic content, only
5//! the bridge between kevy-rt's command dispatch path, kevy-resp's
6//! wire codec, and luna-core's sandboxed `Vm`.
7//!
8//! Design lock-in (see `.claude/rfcs/2026-06-23-v1.27-luna-bridge.md`):
9//!
10//! - **Default Lua 5.1** — preserves the Redis Lua ecosystem (BullMQ,
11//!   Redlock, rate limiters, anything copied from Redis docs).
12//! - **Per-script dialect opt-in via `#!lua version=N`** — scripts
13//!   opt into 5.2 / 5.3 / 5.4 / 5.5 with a single shebang line.
14//!   SHA1 cache key is the raw script bytes, so EVALSHA is
15//!   version-aware for free.
16//! - **VM per-shard, per-dialect, lazily spawned** — first EVAL
17//!   hitting a dialect on a shard constructs the VM; reused
18//!   afterwards. Idle RSS scales with dialects actually used.
19//! - **Atomic execution** — entering EVAL pauses other dispatch on
20//!   that shard until the script returns. Matches Redis semantics.
21//!
22//! # Phase status — P1
23//!
24//! - `Bridge` holds a per-dialect Vm pool (lazy-spawned).
25//! - `eval()` runs the script under the default 5.1 sandbox and
26//!   marshals the first returned `Value` into a RESP reply.
27//! - Shebang parsing, SHA1 cache, EVALSHA, SCRIPT LOAD/EXISTS/FLUSH,
28//!   and `redis.call` host plumbing land in P2-P5 per the RFC.
29
30#![forbid(unsafe_code)]
31#![warn(missing_docs)]
32
33use luna_core::runtime::value::Value;
34use luna_core::vm::exec::Vm;
35use std::cell::Cell;
36use std::rc::Rc;
37
38mod dispatch;
39mod host;
40mod marshal;
41mod resp;
42mod shebang;
43
44/// SHA-1 digest helpers. Exposed because the operator-side wire
45/// layer (kevy-rt's SCRIPT LOAD / EVALSHA codec) needs to convert
46/// between the 20-byte digest used as a cache key and the 40-char
47/// ASCII hex Redis uses on the wire.
48pub mod sha1;
49mod cmsgpack;
50mod cjson;
51
52/// Re-export so callers can name the dialect without depending on
53/// luna-core directly.
54pub use luna_core::version::LuaVersion;
55
56pub(crate) use dispatch::{DispatchHandle, DispatchSlot, DISPATCH_KEY};
57
58/// Lua 5.1 / 5.2 / 5.3 / 5.4 / 5.5 — five fixed slots.
59const N_DIALECTS: usize = 5;
60
61/// 200 M ≈ 5 s on modern hardware; matches Redis's default
62/// `lua-time-limit`. Overridable via [`Bridge::set_instr_budget`].
63/// `0` = unlimited (no budget cap).
64const DEFAULT_INSTR_BUDGET: i64 = 200_000_000;
65
66fn dialect_slot(v: LuaVersion) -> usize {
67    // `LuaVersion` is a `#[repr(...)]` C-style enum with the variants
68    // in version order (Lua51 = 0, …, Lua55 = 4). Stable per luna's
69    // semver promise (the variant declaration order is the wire layout
70    // — luna's docs explicitly say "New variants must be appended").
71    v as usize
72}
73
74/// A wire-level reply: just the encoded RESP bytes.
75pub type Reply = Vec<u8>;
76
77/// SCRIPT FLUSH mode (Redis 6.2+ semantics).
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum FlushMode {
80    /// Synchronous — drop the cache before returning.
81    Sync,
82    /// Asynchronous — schedule the cache drop. v1.27 implements
83    /// both modes as Sync; we keep the tag for future
84    /// differentiation (and Redis-compat replies).
85    Async,
86}
87
88/// A SHA1 hash of a script's source bytes. Used as the EVALSHA cache
89/// key. Includes any `#!lua version=N` shebang in the input, so a
90/// 5.1 script and the same script with a 5.3 shebang have distinct
91/// SHA1s and never collide in the cache.
92pub type ScriptSha1 = [u8; 20];
93
94/// kevy-lua per-shard bridge. One `Bridge` lives in each shard's
95/// runtime; it owns the per-dialect VM pool, the SHA1 cache, and
96/// (P3+) the kevy-side dispatch callback that `redis.call` invokes.
97///
98/// The bridge is intentionally NOT `Send` / `Sync` — same constraint
99/// as luna's `Vm`, which is `!Send + !Sync` by design. kevy's
100/// thread-per-core model means every shard owns its bridge
101/// exclusively.
102pub struct Bridge {
103    /// Lazily-spawned VM per dialect. First EVAL hitting a dialect
104    /// creates the VM; reused for every subsequent script on that
105    /// dialect. Five fixed slots indexed by [`dialect_slot`] (luna
106    /// `LuaVersion` is a 0-4 discriminant in version order).
107    vms: [Option<Vm>; N_DIALECTS],
108    /// Host dispatch closure invoked by `redis.call` / `redis.pcall`.
109    /// `Rc` so cheaply cloned into per-Vm userdata at construction
110    /// time without consuming the original.
111    dispatch: DispatchHandle,
112    /// Read-only mode flag set by [`Bridge::eval_ro`] /
113    /// [`Bridge::evalsha_ro`] before running the script and cleared
114    /// right after. `Rc<Cell<...>>` so every per-dialect Vm's
115    /// dispatch userdata sees the same bit without us having to
116    /// walk the pool. Shared with each `DispatchSlot`.
117    read_only: Rc<Cell<bool>>,
118    /// Per-Vm instruction budget applied at construction time
119    /// (`Vm::sandbox(...).with_instr_budget(N)`). Default 200 M,
120    /// matches the v1.27 P1-P6 hard-coded behaviour. The kevy
121    /// operator wires `[lua] time_limit_ms` through here via
122    /// [`Bridge::set_instr_budget`].
123    ///
124    /// Changes only affect VMs spawned **after** the setter call;
125    /// the kevy-side wiring sets it before any EVAL so this is fine
126    /// in practice. If a config reload needs to take effect on
127    /// in-flight VMs, call `script_flush` afterwards.
128    instr_budget: i64,
129    /// Allow-mask, one bit per [`dialect_slot`]. `true` at slot `i`
130    /// means dialect `i` is permitted; an EVAL whose shebang asks
131    /// for a denied dialect gets a wire `-ERR` reply. All-true by
132    /// default.
133    allow: [bool; N_DIALECTS],
134    /// SHA1 → raw script bytes (including shebang). Populated by
135    /// `script_load` and by every successful `eval`. EVALSHA reads
136    /// from here; SCRIPT FLUSH empties it; SCRIPT EXISTS probes it.
137    ///
138    /// Per-shard cache: kevy runs thread-per-core and each shard
139    /// owns its own Bridge, so we don't share a global cache. The
140    /// trade-off (cache miss on first hit per shard) is dwarfed by
141    /// the locking we'd otherwise need.
142    script_cache: std::collections::HashMap<ScriptSha1, Vec<u8>>,
143}
144
145impl Bridge {
146    /// Create a fresh bridge with `dispatch` as the host callback
147    /// behind `redis.call`. No Vms are spawned until the first
148    /// EVAL.
149    ///
150    /// The dispatch closure receives the script's argv (`&[&[u8]]`,
151    /// command name at index 0) plus a `read_only` flag and must
152    /// return RESP reply bytes. When `read_only` is true the
153    /// dispatcher MUST reject write commands with
154    /// `-READONLY can't write against a read-only script\r\n` so
155    /// `EVAL_RO` / `EVALSHA_RO` deliver Redis semantics. kevy-rt
156    /// owns the canonical command-flag table and does this check
157    /// natively in production; tests provide a stub dispatcher
158    /// hard-coding a few write commands (see `tests/integration.rs`).
159    ///
160    /// For embedders that don't need real keyspace access (e.g.
161    /// pure-computation EVAL), [`Bridge::with_no_dispatch`] installs
162    /// a default that returns `-ERR redis.call: no dispatch wired`
163    /// for every call.
164    pub fn new<F>(dispatch: F) -> Self
165    where
166        F: Fn(&[&[u8]], bool) -> Vec<u8> + 'static,
167    {
168        Self {
169            vms: [const { None }; N_DIALECTS],
170            dispatch: Rc::new(dispatch),
171            read_only: Rc::new(Cell::new(false)),
172            allow: [true; N_DIALECTS],
173            script_cache: std::collections::HashMap::new(),
174            instr_budget: DEFAULT_INSTR_BUDGET,
175        }
176    }
177
178    /// Override the per-Vm instruction budget (~5 s ≈ 200 M instr by
179    /// default). `0` disables the cap (unlimited execution).
180    ///
181    /// Setting it does NOT affect already-spawned VMs in the pool —
182    /// you can pair the call with [`Bridge::script_flush`] to force
183    /// a respawn under the new budget, or leave existing VMs as-is
184    /// and only catch new dialects.
185    pub fn set_instr_budget(&mut self, n: i64) {
186        self.instr_budget = n;
187    }
188
189    /// Bridge with a no-op dispatcher: every `redis.call` returns a
190    /// RESP error. Convenience for embedders that want EVAL but
191    /// don't have the host dispatch wired yet (e.g. pure-computation
192    /// scripts during early development).
193    #[must_use]
194    pub fn with_no_dispatch() -> Self {
195        Self::new(|_argv: &[&[u8]], _ro: bool| {
196            b"-ERR redis.call: no host dispatch wired\r\n".to_vec()
197        })
198    }
199
200    /// Restrict which Lua dialects this bridge will spawn VMs for.
201    /// An EVAL with `#!lua version=N` for a non-allowed dialect is
202    /// rejected with a `-ERR` reply. The 5.1 default is always
203    /// accessible via scripts with no shebang regardless of this
204    /// setting (you can't disable the ecosystem-default dialect
205    /// without taking a different `with_allowed_dialects` API).
206    ///
207    /// Passing an empty slice = no restriction = all five dialects
208    /// permitted (the constructor default).
209    pub fn set_allowed_dialects(&mut self, versions: &[LuaVersion]) {
210        if versions.is_empty() {
211            self.allow = [true; N_DIALECTS];
212            return;
213        }
214        self.allow = [false; N_DIALECTS];
215        for v in versions {
216            self.allow[dialect_slot(*v)] = true;
217        }
218    }
219
220    /// Compile-or-execute a script and marshal its first return value
221    /// into a RESP reply.
222    ///
223    /// P1 scope: default to Lua 5.1, ignore KEYS/ARGV (P3 binds them
224    /// to globals), no `redis.call` (P3), no shebang parsing (P4),
225    /// no SHA1 cache (P5). The point is to confirm
226    /// `EVAL "return 1" 0` produces `:1\r\n`.
227    pub fn eval(&mut self, script: &[u8], keys: &[&[u8]], args: &[&[u8]]) -> Reply {
228        // P4: peel off the `#!lua version=N` shebang first so we know
229        // which dialect Vm to route to before parsing the body.
230        let (sh, body) = match shebang::parse(script) {
231            Ok(t) => t,
232            Err(e) => return resp::err(format!("{e}").as_bytes()),
233        };
234        if !self.allow[dialect_slot(sh.version)] {
235            return resp::err(
236                format!(
237                    "dialect {} disabled by [lua] allow_dialects",
238                    version_tag(sh.version)
239                )
240                .as_bytes(),
241            );
242        }
243        let src = match std::str::from_utf8(body) {
244            Ok(s) => s,
245            Err(_) => return resp::err(b"script body is not valid UTF-8"),
246        };
247        // Redis EVAL semantics: every script that successfully runs
248        // (or even compiles) is added to the SCRIPT cache so a later
249        // EVALSHA can find it. We insert before running so a script
250        // that runs forever still gets a SCRIPT EXISTS hit (matches
251        // Redis behaviour).
252        let digest = sha1::sha1(script);
253        self.script_cache.entry(digest).or_insert_with(|| script.to_vec());
254        let vm = self.vm_for(sh.version);
255        // Bind KEYS / ARGV freshly per invocation. The `redis` host
256        // table was installed once when the Vm was constructed.
257        host::bind_keys_argv(vm, keys, args);
258        match vm.eval(src) {
259            Ok(results) => {
260                let first = results.first().copied().unwrap_or(Value::Nil);
261                marshal::value(vm, first)
262            }
263            Err(e) => resp::err(format_lua_error(&e).as_bytes()),
264        }
265    }
266
267    /// Read-only variant of [`Bridge::eval`]. The dispatcher receives
268    /// `read_only = true` for every `redis.call` from this script;
269    /// kevy-rt rejects write commands with
270    /// `-READONLY can't write against a read-only script\r\n`.
271    /// Redis 7.0+ `EVAL_RO`.
272    ///
273    /// All other semantics (KEYS / ARGV / SHA1 cache fill /
274    /// dialect routing) are identical to `eval`.
275    pub fn eval_ro(&mut self, script: &[u8], keys: &[&[u8]], args: &[&[u8]]) -> Reply {
276        self.read_only.set(true);
277        let r = self.eval(script, keys, args);
278        self.read_only.set(false);
279        r
280    }
281
282    /// Read-only variant of [`Bridge::evalsha`]. Redis 7.0+ `EVALSHA_RO`.
283    pub fn evalsha_ro(&mut self, sha1: ScriptSha1, keys: &[&[u8]], args: &[&[u8]]) -> Reply {
284        self.read_only.set(true);
285        let r = self.evalsha(sha1, keys, args);
286        self.read_only.set(false);
287        r
288    }
289
290    /// Run a previously-cached script by SHA1 hex.
291    ///
292    /// Returns `-NOSCRIPT ...` if the script isn't in the cache.
293    /// Identical to running `eval` with the cached bytes — the same
294    /// shebang routing, KEYS/ARGV binding, and redis.* host plumbing
295    /// apply.
296    pub fn evalsha(&mut self, sha1: ScriptSha1, keys: &[&[u8]], args: &[&[u8]]) -> Reply {
297        let Some(script) = self.script_cache.get(&sha1).cloned() else {
298            return resp::err(b"NOSCRIPT No matching script. Please use EVAL.");
299        };
300        self.eval(&script, keys, args)
301    }
302
303    /// Cache a script without running it. Returns the SHA1 digest;
304    /// the operator-side wire layer hex-encodes it for the Redis
305    /// SCRIPT LOAD reply.
306    pub fn script_load(&mut self, script: &[u8]) -> ScriptSha1 {
307        let digest = sha1::sha1(script);
308        self.script_cache.insert(digest, script.to_vec());
309        digest
310    }
311
312    /// Test which of the given SHA1s are in the cache. Returns a
313    /// vector with `true`/`false` for each input SHA1 in order.
314    #[must_use]
315    pub fn script_exists(&self, sha1s: &[ScriptSha1]) -> Vec<bool> {
316        sha1s.iter().map(|s| self.script_cache.contains_key(s)).collect()
317    }
318
319    /// Drop the SHA1 cache + all per-dialect VMs. `ASYNC` and `SYNC`
320    /// are currently both implemented as synchronous; the tag is
321    /// preserved for future differentiation.
322    pub fn script_flush(&mut self, _mode: FlushMode) {
323        for slot in &mut self.vms {
324            *slot = None;
325        }
326        self.script_cache.clear();
327    }
328
329
330    /// Number of dialect VMs currently spawned. Test-only helper —
331    /// production code doesn't need to inspect the pool.
332    #[cfg(test)]
333    fn vm_count(&self) -> usize {
334        self.vms.iter().filter(|s| s.is_some()).count()
335    }
336
337    /// Lazily build the sandbox Vm for `version`. Conservative
338    /// default: base + math + string + table libraries, no JIT,
339    /// no bytecode loading, 200M instruction budget (~5 s on modern
340    /// hardware — Redis's default `lua-time-limit`). The `redis`
341    /// host table is installed once at Vm-construction time; KEYS /
342    /// ARGV are re-bound per `eval` call (see [`Bridge::eval`]).
343    fn vm_for(&mut self, version: LuaVersion) -> &mut Vm {
344        let slot = &mut self.vms[dialect_slot(version)];
345        if slot.is_none() {
346            let mut builder = Vm::sandbox(version)
347                .open_base()
348                .open_math()
349                .open_string()
350                .open_table();
351            if self.instr_budget > 0 {
352                builder = builder.with_instr_budget(self.instr_budget);
353            }
354            let mut vm = builder.build();
355            host::install_redis_table(&mut vm);
356            // v1.27.3: BullMQ + Sidekiq Pro require `cmsgpack` global.
357            cmsgpack::install_cmsgpack(&mut vm);
358            cjson::install_cjson(&mut vm);
359            // Install the dispatch handle as a luna userdata global
360            // (luna v1.1 B8). `redis.call` retrieves it via
361            // `vm.userdata_borrow::<DispatchSlot>(DISPATCH_KEY)`. We
362            // clone the Rc so each Vm holds an independent handle
363            // pointing at the shared closure.
364            let _ = vm.set_userdata(
365                DISPATCH_KEY,
366                DispatchSlot {
367                    f: Rc::clone(&self.dispatch),
368                    read_only: Rc::clone(&self.read_only),
369                },
370            );
371            *slot = Some(vm);
372        }
373        slot.as_mut().expect("just-inserted Vm")
374    }
375}
376
377impl Default for Bridge {
378    /// Equivalent to [`Bridge::with_no_dispatch`] — the safe default
379    /// for embedders that don't have a host dispatch wired yet.
380    fn default() -> Self {
381        Self::with_no_dispatch()
382    }
383}
384
385fn format_lua_error(e: &luna_core::vm::error::LuaError) -> String {
386    // luna v1.1 B6: `impl Display for LuaError` — embedders no longer
387    // need to case-split on the inner Value type.
388    format!("{e}")
389}
390
391fn version_tag(v: LuaVersion) -> &'static str {
392    match v {
393        LuaVersion::Lua51 => "5.1",
394        LuaVersion::Lua52 => "5.2",
395        LuaVersion::Lua53 => "5.3",
396        LuaVersion::Lua54 => "5.4",
397        LuaVersion::Lua55 => "5.5",
398    }
399}
400
401
402// Most public-surface tests live in `tests/integration.rs` (the
403// house-rule 500 LOC limit on src/*.rs is preserved that way). The
404// few unit tests below need `Bridge::vm_count`, which is
405// `#[cfg(test)]`-gated and therefore not visible from
406// integration tests.
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    #[test]
412    fn eval_reuses_vm_across_calls() {
413        let mut b = Bridge::with_no_dispatch();
414        assert_eq!(b.eval(b"return 1", &[], &[]), b":1\r\n");
415        assert_eq!(b.eval(b"return 2", &[], &[]), b":2\r\n");
416        // One VM should be cached for the 5.1 default dialect.
417        assert_eq!(b.vm_count(), 1);
418    }
419
420    #[test]
421    fn script_flush_drops_vm_pool() {
422        let mut b = Bridge::with_no_dispatch();
423        let _ = b.eval(b"return 1", &[], &[]);
424        assert_eq!(b.vm_count(), 1);
425        b.script_flush(FlushMode::Sync);
426        assert_eq!(b.vm_count(), 0);
427    }
428}