377 lines
13 KiB
Rust
377 lines
13 KiB
Rust
// 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<AtomicBool>`] 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<AtomicBool>,
|
|
}
|
|
|
|
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<AtomicBool>,
|
|
}
|
|
|
|
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::<IsolationError>().is_some() {
|
|
return true;
|
|
}
|
|
for cause in err.chain() {
|
|
if cause.downcast_ref::<IsolationError>().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));
|
|
}
|
|
}
|