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}