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