// lua_isolation.rs --- T M7.8 cancellation hook. //! VM-level cancellation for misbehaving Lua packages. //! //! Per spec §sec:packages-future / T M7.8: a *count hook* installed via //! [`mlua::Lua::set_hook`] runs every N instructions, polls an //! [`AtomicBool`], and aborts the running chunk if the flag is set. The //! flag is flippable from any thread via [`CancelHandle`], so a thread //! other than the VM's main thread (the spec calls out an //! "input-watching" thread) can interrupt a hot-looping main-thread //! package without signal-handler VM access. //! //! ## Design rationale //! //! Two alternatives were rejected during M7 planning (recorded here so //! the choice doesn't drift on the next refactor): //! //! * *Coroutine scheduling.* Requires every package to yield //! cooperatively, which is exactly the property a misbehaving //! package lacks. //! * *On-demand signal-installed hooks.* SIGINT can break out of //! native syscalls but cannot safely manipulate Lua VM state from //! a signal handler — signal-handler reentrancy into the VM //! allocator is unsafe. //! //! The chosen mechanism — install a count hook once at VM init, //! poll an atomic flag — runs in-VM at instruction granularity, needs //! no cooperation from package authors, and never touches the VM from //! a signal handler. //! //! ## `LuaJIT` JIT-trace caveat //! //! `LuaJIT` honors `lua_sethook` from the interpreter, but JIT-compiled //! traces do not call hooks. A tight loop the JIT has compiled //! bypasses polling until the trace recorder eventually exits the //! trace, which can take 100ms-1s under pathological JIT behavior. //! Lua 5.4 has no JIT and the hook fires at instruction granularity //! reliably. The acceptance test budget (1s) reflects the `LuaJIT` //! caveat, not aspirational responsiveness. use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use mlua::{HookTriggers, Lua, VmState}; use thiserror::Error; /// Default instruction budget for the count hook. /// /// Picked empirically (T M7.8 acceptance bullet 5); the recorded /// numbers live in `TRANSITION-M7.md`. Sits in the middle of the /// spec-mandated 10K-100K range. /// /// **Why N doesn't move overhead much.** Both lua54 and luajit (in /// interpreter mode) decrement the hook counter on *every* /// instruction; the hook callback only fires when the counter hits /// zero. So the synthetic-loop overhead measured below is dominated /// by the per-instruction decrement-and-compare, not by callback /// frequency. Empirically, N=50K and N=100K give the same hook-on /// elapsed time within measurement noise. We pick the lower end of /// the working range so cancel latency is as tight as it can be. /// /// Measured on `tests/m7_8_acceptance.rs::benchmark_records_overhead_and_latency`, /// debug build, `local s = 0 for i = 1, 10M do s = s + 1 end`: /// /// | flavor | hook-on | hook-off | cancel latency | /// |-----------------|-----------|-----------|----------------| /// | lua54 | ~57ms | ~29ms | ~60µs | /// | luajit (no JIT) | ~32ms | ~25ms | ~100µs | /// /// The synthetic-loop overhead (30-100%) is the worst-case envelope /// for an all-Lua tight loop. Real package code interleaves Rust /// callbacks, table lookups, and string allocations whose own per-op /// cost dwarfs the hook poll. Cancel latency, the user-facing /// number, comfortably beats the spec's 1-second budget on both /// flavors. pub const DEFAULT_INSTRUCTION_BUDGET: u32 = 50_000; /// Errors raised by the isolation layer. /// /// Appears as the `source()` of an [`mlua::Error::ExternalError`] /// returned by any function whose execution was running while the /// cancel flag was set. #[derive(Debug, Error)] pub enum IsolationError { /// The cancel flag was observed set by the count hook. #[error("Lua execution cancelled (C-g)")] Cancelled, } /// Owner-side cancellation control for a Lua VM. /// /// Wraps an [`Arc`] so the same flag is visible to the /// count-hook closure inside the VM and to any [`CancelHandle`] held /// by another thread. Reset semantics belong to the host: it observes /// a cancellation in the eval result, logs it, then calls /// [`CancelToken::reset`] before the next eval — so a cancel signal /// is consumed exactly once. #[derive(Debug, Clone, Default)] pub struct CancelToken { flag: Arc, } impl CancelToken { /// Construct a fresh, un-cancelled token. #[must_use] pub fn new() -> Self { Self { flag: Arc::new(AtomicBool::new(false)), } } /// Flip the flag. Idempotent: a second `cancel` before a `reset` /// is a no-op (the flag was already set). pub fn cancel(&self) { self.flag.store(true, Ordering::Relaxed); } /// Whether a cancellation has been requested but not yet reset. #[must_use] pub fn is_cancelled(&self) -> bool { self.flag.load(Ordering::Relaxed) } /// Clear the flag. Called by the host after observing a /// cancellation in an eval result so the next eval starts fresh. pub fn reset(&self) { self.flag.store(false, Ordering::Relaxed); } /// Cross-thread write-only handle. The returned [`CancelHandle`] /// is `Send + Sync` and may be moved or cloned to other threads. #[must_use] pub fn handle(&self) -> CancelHandle { CancelHandle { flag: Arc::clone(&self.flag), } } } /// `Send + Sync` handle to a [`CancelToken`]'s flag. /// /// Held by a thread other than the one running the Lua VM (e.g. the /// editor's input-watching thread). Calling [`CancelHandle::cancel`] /// sets the flag the in-VM count hook polls; the next time the hook /// runs (i.e. within N instructions on lua54, possibly later on /// LuaJIT-compiled traces — see module docs) the running chunk /// aborts. #[derive(Debug, Clone)] pub struct CancelHandle { flag: Arc, } impl CancelHandle { /// Flip the cancel flag. See [`CancelToken::cancel`] for /// idempotence semantics. pub fn cancel(&self) { self.flag.store(true, Ordering::Relaxed); } /// Whether a cancellation is currently pending. Useful for tests /// that need to verify the flag landed without racing the hook. #[must_use] pub fn is_cancelled(&self) -> bool { self.flag.load(Ordering::Relaxed) } } /// Install the count hook on `lua`. Should be called exactly once at /// VM init. /// /// The hook captures `token`'s flag (via [`Arc`] clone) and runs every /// `n` instructions. On observing the flag set, it returns /// [`mlua::Error::external`] wrapping [`IsolationError::Cancelled`], /// which mlua propagates as the chunk's failure result. /// /// The hook does *not* reset the flag; the host's eval-result handler /// is responsible for calling [`CancelToken::reset`] after observing /// a cancellation, so the signal is consumed exactly once. /// /// `n` should be in the 10K-100K range per spec; see /// [`DEFAULT_INSTRUCTION_BUDGET`] for the value pmacs ships. pub fn install_cancel_hook(lua: &Lua, token: &CancelToken, n: u32) { let flag = Arc::clone(&token.flag); let triggers = HookTriggers::new().every_nth_instruction(n); lua.set_hook(triggers, move |_lua, _debug| { if flag.load(Ordering::Relaxed) { Err(mlua::Error::external(IsolationError::Cancelled)) } else { Ok(VmState::Continue) } }); } /// Whether `err` (or any error in its [`mlua::Error::chain`]) is an /// [`IsolationError::Cancelled`]. /// /// Used by the host's eval-result path to distinguish a cancellation /// from an arbitrary package error: cancellations get logged as /// `(cancelled)` and the flag is reset, whereas a syntax / runtime /// error from package code gets logged with its message and leaves /// the flag alone. /// /// Walks via [`mlua::Error::chain`] (which knows how to peel /// `CallbackError`'s `cause` and `WithContext`'s `cause`) plus, at /// each step, [`mlua::Error::downcast_ref`] for the /// [`mlua::Error::ExternalError`] payload. mlua's /// `std::error::Error::source` impl returns `None` on `CallbackError` /// (line 337 of mlua 0.10's error.rs), so a plain `source`-walk /// would miss the cancellation when the hook fires inside a Lua /// chunk loaded by `Lua::load(...).exec()`. #[must_use] pub fn is_cancellation(err: &mlua::Error) -> bool { if err.downcast_ref::().is_some() { return true; } for cause in err.chain() { if cause.downcast_ref::().is_some() { return true; } } false } #[cfg(test)] mod tests { use super::*; use std::thread; use std::time::Duration; /// Disable `LuaJIT` JIT compilation for unit tests that exercise the /// count hook on tight loops. Without this, `LuaJIT` compiles /// `while true do end` into a trace that bypasses the hook /// entirely (per the module docs' caveat). On lua54 this is a /// no-op — the `jit` table doesn't exist there. fn disable_jit_if_present(lua: &Lua) { // `jit.off()` (no args) disables the JIT compiler globally, // ensuring the count hook fires on every chunk this VM runs // afterwards. Without this, LuaJIT would compile the test // bodies' tight loops into traces that bypass the hook // (per the module docs' caveat). The acceptance test // `tests/m7_8_acceptance.rs` exercises the JIT-on path with // the spec-mandated 1s budget. // // `jit.flush()` purges any traces compiled before the // off-toggle (e.g. from earlier inits in the same VM). let _ = lua.load("if jit then jit.off(); jit.flush() end").exec(); } #[test] fn token_starts_uncancelled() { let t = CancelToken::new(); assert!(!t.is_cancelled()); } #[test] fn cancel_then_reset_round_trips() { let t = CancelToken::new(); t.cancel(); assert!(t.is_cancelled()); t.reset(); assert!(!t.is_cancelled()); } #[test] fn handle_writes_visible_to_token() { let t = CancelToken::new(); let h = t.handle(); h.cancel(); assert!(t.is_cancelled()); } #[test] fn cross_thread_cancel_via_handle() { let t = CancelToken::new(); let h = t.handle(); let join = thread::spawn(move || { thread::sleep(Duration::from_millis(10)); h.cancel(); }); // Spin until the other thread fires (bounded by sane wall // clock — if this loops past 1s, the cross-thread visibility // is broken). let start = std::time::Instant::now(); while !t.is_cancelled() { assert!( start.elapsed() < Duration::from_secs(1), "cross-thread cancel never observed" ); thread::yield_now(); } join.join().unwrap(); } #[test] fn hook_aborts_infinite_loop() { let lua = Lua::new(); disable_jit_if_present(&lua); let token = CancelToken::new(); install_cancel_hook(&lua, &token, 1000); let h = token.handle(); // Cancel from another thread while the loop runs on this one. let join = thread::spawn(move || { thread::sleep(Duration::from_millis(50)); h.cancel(); }); let result = lua.load("while true do end").exec(); join.join().unwrap(); let err = result.expect_err("infinite loop should be cancelled"); assert!( is_cancellation(&err), "expected IsolationError::Cancelled, got: {err:?}" ); } #[test] fn hook_does_not_fire_when_flag_clear() { let lua = Lua::new(); let token = CancelToken::new(); install_cancel_hook(&lua, &token, 100); // Should run to completion without firing the hook. let r: i64 = lua .load( "local s = 0 \ for i = 1, 10000 do s = s + i end \ return s", ) .eval() .expect("uncancelled chunk should run"); assert_eq!(r, 50_005_000); } #[test] fn reset_after_cancel_allows_next_eval() { let lua = Lua::new(); disable_jit_if_present(&lua); let token = CancelToken::new(); install_cancel_hook(&lua, &token, 1000); let h = token.handle(); // Trigger a cancel. let join = thread::spawn(move || { thread::sleep(Duration::from_millis(50)); h.cancel(); }); let r1 = lua.load("while true do end").exec(); join.join().unwrap(); assert!(r1.is_err()); // Reset and run another chunk; should succeed. token.reset(); let r2: i64 = lua.load("return 42").eval().expect("post-reset eval"); assert_eq!(r2, 42); } #[test] fn is_cancellation_walks_source_chain() { let token = CancelToken::new(); token.cancel(); let lua = Lua::new(); disable_jit_if_present(&lua); install_cancel_hook(&lua, &token, 100); let err = lua .load("for i=1,1000000 do end") .exec() .expect_err("flag pre-set; should cancel"); assert!(is_cancellation(&err)); } #[test] fn is_cancellation_false_for_unrelated_error() { let lua = Lua::new(); let err = lua .load("error('not a cancellation')") .exec() .expect_err("user error"); assert!(!is_cancellation(&err)); } }