From 5b9df232ded371bd9263b6fad4143cb40dc29ef1 Mon Sep 17 00:00:00 2001 From: Johannes Lundberg Date: Thu, 16 Apr 2026 10:59:48 -0700 Subject: [PATCH] Add hooks for coroutine yield and resume --- src/debug.rs | 134 ++++++++++++++++++++++++++++++++++++++- src/state.rs | 3 + src/state/extra.rs | 7 +++ src/state/raw.rs | 139 ++++++++++++++++++++++++++++++++++++++--- src/thread.rs | 12 +++- tests/hooks.rs | 152 ++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 435 insertions(+), 12 deletions(-) diff --git a/src/debug.rs b/src/debug.rs index 89d8c501..e64dd969 100644 --- a/src/debug.rs +++ b/src/debug.rs @@ -22,7 +22,6 @@ use crate::util::{StackGuard, assert_stack, linenumber_to_usize, ptr_to_lossy_st pub struct Debug<'a> { state: *mut lua_State, lua: &'a RawLua, - #[cfg_attr(not(feature = "luau"), allow(unused))] level: c_int, ar: *mut lua_Debug, } @@ -37,6 +36,34 @@ impl<'a> Debug<'a> { } } + /// Creates a synthetic `Debug` instance for a coroutine resume event. + /// + /// This is used internally to fire hooks when [`Thread::resume`] is called. + /// The `ar` field is null since there is no native Lua activation record for this event. + #[cfg(not(feature = "luau"))] + pub(crate) fn new_resume(lua: &'a RawLua) -> Self { + Debug { + state: lua.state(), + lua, + ar: std::ptr::null_mut(), + level: 0, + } + } + + /// Creates a synthetic `Debug` instance for a coroutine yield event. + /// + /// This is used internally to fire hooks when a coroutine yields. + /// The `ar` field is null since there is no native Lua activation record for this event. + #[cfg(not(feature = "luau"))] + pub(crate) fn new_yield(lua: &'a RawLua) -> Self { + Debug { + state: lua.state(), + lua, + ar: std::ptr::null_mut(), + level: -1, // -1 distinguishes Yield from Resume (level 0) + } + } + /// Returns the specific event that triggered the hook. /// /// For [Lua 5.1] [`DebugEvent::TailCall`] is used for return events to indicate a return @@ -46,6 +73,10 @@ impl<'a> Debug<'a> { #[cfg(not(feature = "luau"))] #[cfg_attr(docsrs, doc(cfg(not(feature = "luau"))))] pub fn event(&self) -> DebugEvent { + if self.ar.is_null() { + // Synthetic event: level -1 = Yield, level 0 = Resume + return if self.level == -1 { DebugEvent::Yield } else { DebugEvent::Resume }; + } unsafe { match (*self.ar).event { ffi::LUA_HOOKCALL => DebugEvent::Call, @@ -61,7 +92,17 @@ impl<'a> Debug<'a> { /// Returns the function that is running at the given level. /// /// Corresponds to the `f` "what" mask. + /// + /// # Panics + /// + /// Panics when called on a [`DebugEvent::Resume`] or [`DebugEvent::Yield`] event, as there + /// is no associated function context. Check [`Debug::event`] before calling this method. pub fn function(&self) -> Function { + #[cfg(not(feature = "luau"))] + assert!( + !self.ar.is_null(), + "Debug::function() is not available for Resume/Yield events" + ); unsafe { let _sg = StackGuard::new(self.state); assert_stack(self.state, 1); @@ -83,7 +124,13 @@ impl<'a> Debug<'a> { } /// Corresponds to the `n` "what" mask. + /// + /// Returns empty/`None` values for [`DebugEvent::Resume`] and [`DebugEvent::Yield`] events. pub fn names(&self) -> DebugNames<'_> { + #[cfg(not(feature = "luau"))] + if self.ar.is_null() { + return DebugNames { name: None, name_what: None }; + } unsafe { #[cfg(not(feature = "luau"))] mlua_assert!( @@ -110,7 +157,21 @@ impl<'a> Debug<'a> { } /// Corresponds to the `S` "what" mask. + /// + /// Returns synthetic source information for [`DebugEvent::Resume`] and [`DebugEvent::Yield`] + /// events. pub fn source(&self) -> DebugSource<'_> { + #[cfg(not(feature = "luau"))] + if self.ar.is_null() { + let what = if self.level == -1 { "yield" } else { "resume" }; + return DebugSource { + source: None, + short_src: None, + line_defined: None, + last_line_defined: None, + what, + }; + } unsafe { #[cfg(not(feature = "luau"))] mlua_assert!( @@ -140,7 +201,13 @@ impl<'a> Debug<'a> { } /// Corresponds to the `l` "what" mask. Returns the current line. + /// + /// Returns `None` for [`DebugEvent::Resume`] and [`DebugEvent::Yield`] events. pub fn current_line(&self) -> Option { + #[cfg(not(feature = "luau"))] + if self.ar.is_null() { + return None; + } unsafe { #[cfg(not(feature = "luau"))] mlua_assert!( @@ -165,6 +232,9 @@ impl<'a> Debug<'a> { doc(cfg(any(feature = "lua55", feature = "lua54", feature = "lua53", feature = "lua52"))) )] pub fn is_tail_call(&self) -> bool { + if self.ar.is_null() { + return false; + } unsafe { mlua_assert!( ffi::lua_getinfo(self.state, cstr!("t"), self.ar) != 0, @@ -175,7 +245,19 @@ impl<'a> Debug<'a> { } /// Corresponds to the `u` "what" mask. + /// + /// Returns zeroed values for [`DebugEvent::Resume`] and [`DebugEvent::Yield`] events. pub fn stack(&self) -> DebugStack { + #[cfg(not(feature = "luau"))] + if self.ar.is_null() { + return DebugStack { + num_upvalues: 0, + #[cfg(not(any(feature = "lua51", feature = "luajit")))] + num_params: 0, + #[cfg(not(any(feature = "lua51", feature = "luajit")))] + is_vararg: false, + }; + } unsafe { #[cfg(not(feature = "luau"))] mlua_assert!( @@ -217,6 +299,18 @@ pub enum DebugEvent { TailCall, Line, Count, + /// Fired when a coroutine resumes execution. + /// + /// This is not a native Lua debug event. It is synthesized by mlua when [`Thread::resume`] + /// is called. There is no associated activation record, so [`Debug::function`] will panic + /// and most other [`Debug`] methods return empty/default values. + Resume, + /// Fired when a coroutine yields execution. + /// + /// This is not a native Lua debug event. It is synthesized by mlua after `lua_resume` + /// returns `LUA_YIELD`. There is no associated activation record, so [`Debug::function`] + /// will panic and most other [`Debug`] methods return empty/default values. + Yield, Unknown(c_int), } @@ -286,6 +380,18 @@ pub struct HookTriggers { /// /// Setting this option to a low value can incur a very high overhead. pub every_nth_instruction: Option, + /// When a coroutine resumes execution. + /// + /// This is not a native Lua debug event. The hook fires just before `lua_resume` is called, + /// so the coroutine has not yet re-entered Lua execution. The [`Debug`] argument will report + /// [`DebugEvent::Resume`]; most info methods return empty/default values. + pub on_resume: bool, + /// When a coroutine yields execution. + /// + /// This is not a native Lua debug event. The hook fires after `lua_resume` returns + /// `LUA_YIELD`. The [`Debug`] argument will report [`DebugEvent::Yield`]; most info methods + /// return empty/default values. + pub on_yield: bool, } #[cfg(not(feature = "luau"))] @@ -299,6 +405,12 @@ impl HookTriggers { /// An instance of `HookTriggers` with `every_line` trigger set. pub const EVERY_LINE: Self = HookTriggers::new().every_line(); + /// An instance of `HookTriggers` with `on_resume` trigger set. + pub const ON_RESUME: Self = HookTriggers::new().on_resume(); + + /// An instance of `HookTriggers` with `on_yield` trigger set. + pub const ON_YIELD: Self = HookTriggers::new().on_yield(); + /// Returns a new instance of `HookTriggers` with all triggers disabled. pub const fn new() -> Self { HookTriggers { @@ -306,6 +418,8 @@ impl HookTriggers { on_returns: false, every_line: false, every_nth_instruction: None, + on_resume: false, + on_yield: false, } } @@ -341,6 +455,22 @@ impl HookTriggers { self } + /// Returns an instance of `HookTriggers` with [`on_resume`] trigger set. + /// + /// [`on_resume`]: #structfield.on_resume + pub const fn on_resume(mut self) -> Self { + self.on_resume = true; + self + } + + /// Returns an instance of `HookTriggers` with [`on_yield`] trigger set. + /// + /// [`on_yield`]: #structfield.on_yield + pub const fn on_yield(mut self) -> Self { + self.on_yield = true; + self + } + // Compute the mask to pass to `lua_sethook`. #[cfg(not(feature = "luau"))] pub(crate) const fn mask(&self) -> c_int { @@ -379,6 +509,8 @@ impl std::ops::BitOr for HookTriggers { self.on_calls |= rhs.on_calls; self.on_returns |= rhs.on_returns; self.every_line |= rhs.every_line; + self.on_resume |= rhs.on_resume; + self.on_yield |= rhs.on_yield; if self.every_nth_instruction.is_none() && rhs.every_nth_instruction.is_some() { self.every_nth_instruction = rhs.every_nth_instruction; } diff --git a/src/state.rs b/src/state.rs index 7e624ec2..05dbe449 100644 --- a/src/state.rs +++ b/src/state.rs @@ -668,6 +668,9 @@ impl Lua { { let lua = self.lock(); unsafe { + if triggers.on_resume || triggers.on_yield { + (*lua.extra.get()).has_resume_yield_hooks = true; + } (*lua.extra.get()).hook_triggers = triggers; (*lua.extra.get()).hook_callback = Some(XRc::new(callback)); lua.set_thread_hook(lua.state(), HookKind::Global) diff --git a/src/state/extra.rs b/src/state/extra.rs index d761c9cb..00245a00 100644 --- a/src/state/extra.rs +++ b/src/state/extra.rs @@ -77,6 +77,11 @@ pub(crate) struct ExtraData { pub(super) hook_callback: Option, #[cfg(not(feature = "luau"))] pub(super) hook_triggers: crate::debug::HookTriggers, + /// Fast-path flag: true if any registered hook (global or thread-specific) has + /// `on_resume` or `on_yield` set. Checked before doing any registry lookup in + /// `trigger_resume_hook` / `trigger_yield_hook`. + #[cfg(not(feature = "luau"))] + pub(super) has_resume_yield_hooks: bool, #[cfg(any(feature = "lua55", feature = "lua54"))] pub(super) warn_callback: Option, #[cfg(feature = "luau")] @@ -182,6 +187,8 @@ impl ExtraData { hook_callback: None, #[cfg(not(feature = "luau"))] hook_triggers: Default::default(), + #[cfg(not(feature = "luau"))] + has_resume_yield_hooks: false, #[cfg(any(feature = "lua55", feature = "lua54"))] warn_callback: None, #[cfg(feature = "luau")] diff --git a/src/state/raw.rs b/src/state/raw.rs index dcc860a3..1ac33cb5 100644 --- a/src/state/raw.rs +++ b/src/state/raw.rs @@ -38,10 +38,30 @@ use super::{Lua, LuaOptions, WeakLua}; #[cfg(not(feature = "luau"))] use crate::{ - debug::Debug, + debug::{Debug, HookTriggers}, types::{HookCallback, HookKind, VmState}, }; +/// Combined hook info stored per thread in the `__mlua_hooks` registry table. +/// +/// Keyed by the thread's `lua_State` pointer cast to a light userdata, so it can be +/// looked up without touching the (potentially suspended) thread's own stack. +#[cfg(not(feature = "luau"))] +#[derive(Clone)] +pub(crate) struct ThreadHook { + pub(crate) triggers: HookTriggers, + pub(crate) callback: HookCallback, +} + +#[cfg(not(feature = "luau"))] +impl crate::util::TypeKey for ThreadHook { + #[inline(always)] + fn type_key() -> *const std::os::raw::c_void { + static THREAD_HOOK_TYPE_KEY: u8 = 0; + &THREAD_HOOK_TYPE_KEY as *const u8 as *const std::os::raw::c_void + } +} + #[cfg(feature = "async")] use { crate::multi::MultiValue, @@ -205,6 +225,8 @@ impl RawLua { init_internal_metatable::(state, None)?; #[cfg(not(feature = "luau"))] init_internal_metatable::(state, None)?; + #[cfg(not(feature = "luau"))] + init_internal_metatable::(state, None)?; #[cfg(feature = "async")] { init_internal_metatable::(state, None)?; @@ -456,11 +478,15 @@ impl RawLua { unsafe extern "C-unwind" fn hook_proc(state: *mut ffi::lua_State, ar: *mut ffi::lua_Debug) { let top = ffi::lua_gettop(state); let mut hook_callback_ptr = ptr::null(); - ffi::luaL_checkstack(state, 3, ptr::null()); + ffi::luaL_checkstack(state, 2, ptr::null()); if ffi::lua_getfield(state, ffi::LUA_REGISTRYINDEX, HOOKS_KEY) == ffi::LUA_TTABLE { - ffi::lua_pushthread(state); + // Use the thread's state pointer as the key to avoid pushing the thread object. + ffi::lua_pushlightuserdata(state, state as *mut c_void); if ffi::lua_rawget(state, -2) == ffi::LUA_TUSERDATA { - hook_callback_ptr = get_internal_userdata::(state, -1, ptr::null()); + let hook_ptr = get_internal_userdata::(state, -1, ptr::null()); + if !hook_ptr.is_null() { + hook_callback_ptr = &(*hook_ptr).callback as *const HookCallback; + } } } ffi::lua_settop(state, top); @@ -491,7 +517,14 @@ impl RawLua { HookKind::Thread(triggers, callback) => (triggers, callback), }; - // Hooks for threads stored in the registry (in a weak table) + // Update fast-path flag so trigger_resume_hook/trigger_yield_hook know to check. + if triggers.on_resume || triggers.on_yield { + (*self.extra.get()).has_resume_yield_hooks = true; + } + + // Hooks for threads stored in the registry (in a weak table). + // The key is the thread's lua_State pointer as a light userdata so we never need + // to push the thread object (which would touch a potentially-suspended thread's stack). let state = self.state(); let _sg = StackGuard::new(state); check_stack(state, 3)?; @@ -504,10 +537,9 @@ impl RawLua { ffi::lua_setmetatable(state, -2); // metatable(hooktable) = hooktable } - ffi::lua_pushthread(thread_state); - ffi::lua_xmove(thread_state, state, 1); // key (thread) - let _ = push_internal_userdata(state, callback, false); // value (hook callback) - ffi::lua_rawset(state, -3); // hooktable[thread] = hook callback + ffi::lua_pushlightuserdata(state, thread_state as *mut c_void); // key + let _ = push_internal_userdata(state, ThreadHook { triggers, callback }, false); // value + ffi::lua_rawset(state, -3); // hooktable[thread_ptr] = ThreadHook })?; ffi::lua_sethook(thread_state, Some(hook_proc), triggers.mask(), triggers.count()); @@ -515,6 +547,95 @@ impl RawLua { Ok(()) } + /// Looks up the `ThreadHook` for `thread_state` from the registry. + /// + /// Safe to call for suspended threads: uses the state pointer as a light userdata key so + /// it never touches the thread's own stack. + #[cfg(not(feature = "luau"))] + unsafe fn get_thread_hook(&self, thread_state: *mut ffi::lua_State) -> Option { + const HOOKS_KEY: *const c_char = cstr!("__mlua_hooks"); + let state = self.state(); + let top = ffi::lua_gettop(state); + if ffi::lua_getfield(state, ffi::LUA_REGISTRYINDEX, HOOKS_KEY) != ffi::LUA_TTABLE { + ffi::lua_settop(state, top); + return None; + } + ffi::lua_pushlightuserdata(state, thread_state as *mut c_void); + let result = if ffi::lua_rawget(state, -2) == ffi::LUA_TUSERDATA { + let ptr = get_internal_userdata::(state, -1, ptr::null()); + if ptr.is_null() { None } else { Some((*ptr).clone()) } + } else { + None + }; + ffi::lua_settop(state, top); + result + } + + /// Fires the `on_resume` hook for `thread_state`, if one is registered. + /// + /// Must be called just before `lua_resume`. Errors from the hook are propagated back to + /// the caller, aborting the resume. + #[cfg(not(feature = "luau"))] + pub(crate) unsafe fn trigger_resume_hook(&self, thread_state: *mut ffi::lua_State) -> Result<()> { + // Fast path: skip registry lookup entirely if no resume/yield hooks exist. + if !(*self.extra.get()).has_resume_yield_hooks { + return Ok(()); + } + + // Thread-specific hook takes precedence over the global hook. + if let Some(hook) = self.get_thread_hook(thread_state) { + if hook.triggers.on_resume { + let debug = Debug::new_resume(self); + (hook.callback)(self.lua(), &debug)?; + } + // A thread-specific hook was found; don't fall through to the global hook. + return Ok(()); + } + + // Fall back to global hook. + let extra = &*self.extra.get(); + if extra.hook_triggers.on_resume { + if let Some(ref cb) = extra.hook_callback { + let cb = cb.clone(); + let debug = Debug::new_resume(self); + cb(self.lua(), &debug)?; + } + } + Ok(()) + } + + /// Fires the `on_yield` hook for `thread_state`, if one is registered. + /// + /// Must be called immediately after `lua_resume` returns `LUA_YIELD`. + #[cfg(not(feature = "luau"))] + pub(crate) unsafe fn trigger_yield_hook(&self, thread_state: *mut ffi::lua_State) -> Result<()> { + // Fast path: skip registry lookup entirely if no resume/yield hooks exist. + if !(*self.extra.get()).has_resume_yield_hooks { + return Ok(()); + } + + // Thread-specific hook takes precedence over the global hook. + if let Some(hook) = self.get_thread_hook(thread_state) { + if hook.triggers.on_yield { + let debug = Debug::new_yield(self); + (hook.callback)(self.lua(), &debug)?; + } + // A thread-specific hook was found; don't fall through to the global hook. + return Ok(()); + } + + // Fall back to global hook. + let extra = &*self.extra.get(); + if extra.hook_triggers.on_yield { + if let Some(ref cb) = extra.hook_callback { + let cb = cb.clone(); + let debug = Debug::new_yield(self); + cb(self.lua(), &debug)?; + } + } + Ok(()) + } + /// See [`Lua::create_string`] pub(crate) unsafe fn create_string(&self, s: &[u8]) -> Result { let state = self.state(); diff --git a/src/thread.rs b/src/thread.rs index 65d209ed..160f84bc 100644 --- a/src/thread.rs +++ b/src/thread.rs @@ -248,6 +248,11 @@ impl Thread { unsafe fn resume_inner(&self, lua: &RawLua, nargs: c_int) -> Result<(ThreadStatusInner, c_int)> { let state = lua.state(); let thread_state = self.state(); + + // Fire on_resume hooks before handing control back to the coroutine. + #[cfg(not(feature = "luau"))] + lua.trigger_resume_hook(thread_state)?; + let mut nresults = 0; #[cfg(not(feature = "luau"))] let ret = ffi::lua_resume(thread_state, state, nargs, &mut nresults as *mut c_int); @@ -255,7 +260,12 @@ impl Thread { let ret = ffi::lua_resumex(thread_state, state, nargs, &mut nresults as *mut c_int); match ret { ffi::LUA_OK => Ok((ThreadStatusInner::Finished, nresults)), - ffi::LUA_YIELD => Ok((ThreadStatusInner::Yielded(0), nresults)), + ffi::LUA_YIELD => { + // Fire on_yield hooks after the coroutine has yielded. + #[cfg(not(feature = "luau"))] + lua.trigger_yield_hook(thread_state)?; + Ok((ThreadStatusInner::Yielded(0), nresults)) + } ffi::LUA_ERRMEM => { // Don't call error handler for memory errors Err(pop_error(thread_state, ret)) diff --git a/tests/hooks.rs b/tests/hooks.rs index 9d68c84b..001b161a 100644 --- a/tests/hooks.rs +++ b/tests/hooks.rs @@ -1,11 +1,161 @@ #![cfg(not(feature = "luau"))] -use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; use std::sync::{Arc, Mutex}; use mlua::debug::DebugEvent; use mlua::{Error, HookTriggers, Lua, Result, Value, VmState}; +// Ensure the new trigger fields participate in the BitOr impl. +#[test] +fn test_resume_yield_trigger_bitor() { + let t = HookTriggers::ON_RESUME | HookTriggers::ON_YIELD | HookTriggers::ON_CALLS; + assert!(t.on_resume); + assert!(t.on_yield); + assert!(t.on_calls); + assert!(!t.on_returns); +} + +#[test] +fn test_on_resume_hook() -> Result<()> { + let lua = Lua::new(); + + let counter = Arc::new(AtomicI64::new(0)); + let hook_counter = counter.clone(); + + let co = lua.create_thread( + lua.load("coroutine.yield() coroutine.yield()").into_function()?, + )?; + + co.set_hook(HookTriggers::ON_RESUME, move |_lua, debug| { + assert_eq!(debug.event(), DebugEvent::Resume); + // Confirm other Debug methods return graceful defaults. + assert_eq!(debug.current_line(), None); + assert_eq!(debug.names().name, None); + hook_counter.fetch_add(1, Ordering::Relaxed); + Ok(VmState::Continue) + })?; + + co.resume::<()>(())?; // first resume → hook fires once, then yields + co.resume::<()>(())?; // second resume → hook fires once, then yields + co.resume::<()>(())?; // third resume → hook fires once, then finishes + + assert_eq!(counter.load(Ordering::Relaxed), 3); + assert!(co.is_finished()); + Ok(()) +} + +#[test] +fn test_on_yield_hook() -> Result<()> { + let lua = Lua::new(); + + let counter = Arc::new(AtomicI64::new(0)); + let hook_counter = counter.clone(); + + let co = lua.create_thread( + lua.load("coroutine.yield() coroutine.yield()").into_function()?, + )?; + + co.set_hook(HookTriggers::ON_YIELD, move |_lua, debug| { + assert_eq!(debug.event(), DebugEvent::Yield); + hook_counter.fetch_add(1, Ordering::Relaxed); + Ok(VmState::Continue) + })?; + + co.resume::<()>(())?; // yields → hook fires + co.resume::<()>(())?; // yields → hook fires + co.resume::<()>(())?; // finishes → no hook + + assert_eq!(counter.load(Ordering::Relaxed), 2); + assert!(co.is_finished()); + Ok(()) +} + +#[test] +fn test_resume_yield_combined() -> Result<()> { + let lua = Lua::new(); + + // Track events in order. + let events = Arc::new(Mutex::new(Vec::new())); + let hook_events = events.clone(); + + let co = lua.create_thread( + lua.load("coroutine.yield()").into_function()?, + )?; + + co.set_hook( + HookTriggers::ON_RESUME | HookTriggers::ON_YIELD, + move |_lua, debug| { + hook_events.lock().unwrap().push(debug.event()); + Ok(VmState::Continue) + }, + )?; + + co.resume::<()>(())?; // resume hook → coroutine runs → yield hook + co.resume::<()>(())?; // resume hook → coroutine finishes (no yield hook) + + let events = events.lock().unwrap(); + assert_eq!( + *events, + vec![DebugEvent::Resume, DebugEvent::Yield, DebugEvent::Resume] + ); + Ok(()) +} + +#[test] +fn test_global_resume_yield_hook() -> Result<()> { + let lua = Lua::new(); + + let resume_count = Arc::new(AtomicI64::new(0)); + let yield_count = Arc::new(AtomicI64::new(0)); + let rc = resume_count.clone(); + let yc = yield_count.clone(); + + lua.set_global_hook( + HookTriggers::ON_RESUME | HookTriggers::ON_YIELD, + move |_lua, debug| { + match debug.event() { + DebugEvent::Resume => rc.fetch_add(1, Ordering::Relaxed), + DebugEvent::Yield => yc.fetch_add(1, Ordering::Relaxed), + _ => 0, + }; + Ok(VmState::Continue) + }, + )?; + + let co = lua.create_thread( + lua.load("coroutine.yield() coroutine.yield()").into_function()?, + )?; + + co.resume::<()>(())?; + co.resume::<()>(())?; + co.resume::<()>(())?; + + assert_eq!(resume_count.load(Ordering::Relaxed), 3); + assert_eq!(yield_count.load(Ordering::Relaxed), 2); + Ok(()) +} + +#[test] +fn test_resume_hook_error_propagates() -> Result<()> { + let lua = Lua::new(); + + let co = lua.create_thread(lua.load("coroutine.yield()").into_function()?)?; + + let fired = Arc::new(AtomicBool::new(false)); + let fired2 = fired.clone(); + co.set_hook(HookTriggers::ON_RESUME, move |_lua, _debug| { + fired2.store(true, Ordering::Relaxed); + Err(Error::runtime("hook error")) + })?; + + let err = co.resume::<()>(()).expect_err("expected hook error to propagate"); + assert!(fired.load(Ordering::Relaxed), "hook should have fired"); + assert!(err.to_string().contains("hook error"), "got: {err}"); + + Ok(()) +} + #[test] fn test_hook_triggers() { let trigger = HookTriggers::new().on_calls().on_returns()