pmacs/src/lua_isolation.rs

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));
}
}