Skip to main content

mlua_isle/
hook.rs

1//! Cancellation token and Lua debug hook.
2//!
3//! A [`CancelToken`] is a shared `AtomicBool` that can be checked from
4//! both Rust code and a Lua debug hook.  When cancelled, the debug hook
5//! raises a Lua error containing the sentinel `__isle_cancelled__`,
6//! which is recognized by [`IsleError::from(mlua::Error)`].
7
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11/// Cancellation signal shared between caller and Lua thread.
12///
13/// Clone is cheap (Arc).
14///
15/// Two cancellation pathways are wired:
16///
17/// 1. A Lua debug hook polls [`is_cancelled`](Self::is_cancelled) every
18///    `N` Lua instructions.  This interrupts pure-Lua CPU-bound loops
19///    (`while true do end` etc).
20///
21/// 2. When the feature `tokio` is enabled, [`cancelled`](Self::cancelled)
22///    provides an async signal that fires as soon as [`cancel`](Self::cancel)
23///    is called.  Coroutine executors (`execute_coroutine_eval`,
24///    `execute_coroutine_call`) use this in a `tokio::select!` to drop
25///    the in-flight Lua coroutine even when the coroutine is suspended
26///    inside a Rust `.await` (e.g. a `create_async_function` awaiting a
27///    tokio child process).  The debug hook alone cannot interrupt such
28///    Rust-suspended coroutines because no Lua instructions execute
29///    during the `.await`, so the hook never fires.
30#[derive(Clone)]
31pub struct CancelToken {
32    flag: Arc<AtomicBool>,
33    #[cfg(feature = "tokio")]
34    notify: Arc<tokio::sync::Notify>,
35}
36
37impl CancelToken {
38    /// Create a new token (not cancelled).
39    pub fn new() -> Self {
40        Self {
41            flag: Arc::new(AtomicBool::new(false)),
42            #[cfg(feature = "tokio")]
43            notify: Arc::new(tokio::sync::Notify::new()),
44        }
45    }
46
47    /// Signal cancellation.
48    ///
49    /// Sets the atomic flag (observed by the Lua debug hook) and, when
50    /// the `tokio` feature is enabled, notifies all waiters of the
51    /// async [`cancelled`](Self::cancelled) signal.
52    pub fn cancel(&self) {
53        self.flag.store(true, Ordering::Release);
54        #[cfg(feature = "tokio")]
55        self.notify.notify_waiters();
56    }
57
58    /// Check whether cancellation has been requested.
59    pub fn is_cancelled(&self) -> bool {
60        self.flag.load(Ordering::Acquire)
61    }
62
63    /// Await cancellation (async).
64    ///
65    /// Returns immediately if already cancelled; otherwise resolves
66    /// when [`cancel`](Self::cancel) is called.  Intended for use in
67    /// `tokio::select!` to race a Lua coroutine against its cancel
68    /// signal — when this future wins, dropping the other branch
69    /// releases any Rust async resources (e.g. a spawned child
70    /// process) that the coroutine was awaiting.
71    ///
72    /// Race-free: the returned future is registered with the
73    /// underlying [`tokio::sync::Notify`] via
74    /// [`Notified::enable`](tokio::sync::futures::Notified::enable)
75    /// before the flag is re-checked, so a `cancel()` call that
76    /// happens between `cancelled()` being constructed and awaited
77    /// is not lost.
78    #[cfg(feature = "tokio")]
79    pub async fn cancelled(&self) {
80        if self.is_cancelled() {
81            return;
82        }
83        let notified = self.notify.notified();
84        tokio::pin!(notified);
85        notified.as_mut().enable();
86        // Re-check after enabling: a cancel() that happened between
87        // the initial is_cancelled() check and enable() would have
88        // called notify_waiters() without us being registered, so
89        // this second read catches it.
90        if self.is_cancelled() {
91            return;
92        }
93        notified.await;
94    }
95}
96
97impl Default for CancelToken {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103/// Install a Lua debug hook that checks the cancel token every N instructions.
104///
105/// When the token is cancelled, the hook raises a Lua error with a
106/// sentinel message that [`IsleError`](crate::IsleError) recognizes as
107/// a cancellation.
108///
109/// # Instruction interval
110///
111/// The `interval` controls how often the check runs.  Lower values
112/// give faster cancellation response at the cost of overhead.
113/// A value of 1000 is a reasonable default.
114pub(crate) fn install_cancel_hook(
115    lua: &mlua::Lua,
116    token: CancelToken,
117    interval: u32,
118) -> Result<(), crate::IsleError> {
119    lua.set_hook(
120        mlua::HookTriggers::new().every_nth_instruction(interval),
121        move |_lua, _debug| {
122            if token.is_cancelled() {
123                Err(mlua::Error::runtime("__isle_cancelled__"))
124            } else {
125                Ok(mlua::VmState::Continue)
126            }
127        },
128    )
129    .map_err(crate::IsleError::from)
130}
131
132/// Remove the debug hook (restores normal execution speed).
133pub(crate) fn remove_hook(lua: &mlua::Lua) {
134    lua.remove_hook();
135}
136
137/// Install a cancel hook on a Lua coroutine thread.
138///
139/// Same as [`install_cancel_hook`] but targets a specific [`Thread`](mlua::Thread)
140/// instead of the main Lua state.  Used for cooperative coroutine execution
141/// where each coroutine needs its own cancel check.
142#[cfg(feature = "tokio")]
143pub(crate) fn install_cancel_hook_on_thread(
144    thread: &mlua::Thread,
145    token: CancelToken,
146    interval: u32,
147) -> Result<(), crate::IsleError> {
148    thread
149        .set_hook(
150            mlua::HookTriggers::new().every_nth_instruction(interval),
151            move |_lua, _debug| {
152                if token.is_cancelled() {
153                    Err(mlua::Error::runtime("__isle_cancelled__"))
154                } else {
155                    Ok(mlua::VmState::Continue)
156                }
157            },
158        )
159        .map_err(crate::IsleError::from)
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn token_default_not_cancelled() {
168        let token = CancelToken::new();
169        assert!(!token.is_cancelled());
170    }
171
172    #[test]
173    fn token_cancel_sets_flag() {
174        let token = CancelToken::new();
175        let clone = token.clone();
176        token.cancel();
177        assert!(clone.is_cancelled());
178    }
179
180    #[test]
181    fn hook_interrupts_lua_loop() {
182        let lua = mlua::Lua::new();
183        let token = CancelToken::new();
184        install_cancel_hook(&lua, token.clone(), 100).unwrap();
185
186        // Schedule cancel after a short spin
187        let t = token.clone();
188        std::thread::spawn(move || {
189            std::thread::sleep(std::time::Duration::from_millis(10));
190            t.cancel();
191        });
192
193        let result: mlua::Result<()> = lua.load("while true do end").exec();
194        assert!(result.is_err());
195        let err_msg = result.unwrap_err().to_string();
196        assert!(
197            err_msg.contains("__isle_cancelled__"),
198            "expected cancellation sentinel, got: {err_msg}"
199        );
200    }
201}