Skip to main content

azul_core/
host_invoker.rs

1//! Host-language callback invoker registry.
2//!
3//! Managed-FFI bindings (Lua, Ruby, Perl, PHP, OCaml, Node, C#, Java, …) can't
4//! generate C-ABI trampolines for callback typedefs that take aggregate args
5//! by value — that's a libffi / LuaJIT FFI / ruby-ffi limitation we can't fix
6//! at the host. This module provides the alternative the user's analysis
7//! settled on: each language registers **one** generic invoker function at
8//! module load time, plus a releaser that fires when a host-language handle
9//! goes out of use.
10//!
11//! Every callback the host registers becomes a `Callback { cb, ctx }` pair
12//! whose `cb` is a *static thunk* in libazul (so by-value args land on a
13//! native frame the way the framework already expects), and whose `ctx` is
14//! a `RefAny` payload that carries an opaque host-language `u64` handle.
15//! The thunk reads `info.get_ctx()`, extracts the handle, and dispatches to
16//! the registered per-kind invoker — which, on the host side, looks up the
17//! callable by id in a host-managed table and runs it. When the RefAny's
18//! refcount drops to zero, the destructor calls back through the registered
19//! releaser so the host can drop its table entry, mirroring Python's
20//! `Py<PyAny>` lifetime story without making libazul link against any host
21//! runtime.
22//!
23//! ## API surface
24//!
25//! - [`AzApp_setHostHandleReleaser`] — register the host's "drop this id"
26//!   callback once per process. Fires when a host-handle [`RefAny`] is
27//!   collected.
28//! - Per callback kind, [`crate::impl_managed_callback!`] expands to:
29//!   - A static thunk (`extern "C" fn`) compiled into libazul.
30//!   - A `<Wrapper>::create_from_host_handle(u64)` constructor.
31//!   - An `AzApp_set<Kind>Invoker(...)` setter for the host-side per-kind
32//!     pointer-arg invoker.
33//!
34//! ## Why a single shared releaser
35//!
36//! Per-kind invokers are necessarily distinct — each callback typedef has
37//! a different signature, so the host has to register a libffi closure per
38//! typedef anyway. The releaser, on the other hand, has the same signature
39//! for every kind (`extern "C" fn(u64)`), so we can share one slot across
40//! all callbacks; the host registers it once and every kind's destructor
41//! routes through it.
42
43use core::ffi::c_void;
44use core::sync::atomic::{AtomicUsize, Ordering};
45
46use azul_css::AzString;
47
48use crate::refany::RefAny;
49
50/// RTTI id stamped into every `RefAny` created via [`host_handle_to_refany`].
51///
52/// Hosts must not reuse this id for their own user-data `RefAnys`, otherwise
53/// `refany_to_host_handle` would mis-identify their data as a host handle
54/// and the destructor would call the registered releaser with a bogus id.
55/// The high 32 bits are reserved for azul-internal RTTI ids; the low 32
56/// spell `'H','S','T','H'` so the value reads `0xA20A_4853_5448_5F44`.
57pub const AZ_HOST_HANDLE_RTTI_ID: u64 = 0xA20A_4853_5448_5F44;
58
59/// Heap payload stored inside the [`RefAny`] returned by
60/// [`host_handle_to_refany`]. Just the opaque host-language id — the actual
61/// host callable lives on the host side keyed by this id.
62#[repr(C)]
63#[derive(Debug, Copy, Clone)]
64pub struct HostHandlePayload {
65    pub id: u64,
66}
67
68/// A single atomic-pointer slot for one registered host-side function
69/// pointer.
70///
71/// `0` means "not registered"; the static thunks bail out (returning
72/// the kind's default value) when they see an unregistered slot rather than
73/// transmuting `0` into a fn pointer and crashing.
74#[repr(C)]
75#[derive(Debug)]
76pub struct InvokerSlot {
77    fn_ptr: AtomicUsize,
78}
79
80impl InvokerSlot {
81    /// Create an empty slot. `const` so it can be used to declare `static`
82    /// per-kind slots in `impl_managed_callback!` expansions.
83    #[must_use]
84    pub const fn new() -> Self {
85        Self {
86            fn_ptr: AtomicUsize::new(0),
87        }
88    }
89
90    /// Replace the registered function pointer.
91    ///
92    /// `SeqCst` because the slot is read on every callback fire and we
93    /// don't want any stale-pointer windows after the host swaps invokers
94    /// (rare but legal — e.g. unloading a Lua module that registered).
95    pub fn set(&self, ptr: usize) {
96        self.fn_ptr.store(ptr, Ordering::SeqCst);
97    }
98
99    /// Read the current function pointer; `0` if unregistered.
100    pub fn get(&self) -> usize {
101        self.fn_ptr.load(Ordering::SeqCst)
102    }
103}
104
105impl Default for InvokerSlot {
106    fn default() -> Self {
107        Self::new()
108    }
109}
110
111/// Process-global slot for the host's "drop a handle id" callback. Set via
112/// [`AzApp_setHostHandleReleaser`]. Read by [`host_handle_destructor`]
113/// when a host-handle [`RefAny`]'s last clone drops.
114pub static HOST_HANDLE_RELEASER: InvokerSlot = InvokerSlot::new();
115
116/// Process-global slot for the host's *generic* invoker.
117///
118/// Set via
119/// [`AzApp_setGenericInvoker`]. Used as a fallback in macro-generated
120/// per-kind thunks when the per-kind invoker is not registered, and as
121/// the **only** dispatch path for user-defined custom callback kinds in
122/// libffi-restricted hosts (Lua, PHP, koffi, …) that can't easily ship
123/// an upstream `impl_managed_callback!` invocation.
124///
125/// Signature on the host side:
126///
127/// ```c
128/// typedef void (*AzGenericInvoker)(
129///     uint64_t           handle,    /* host-handle id from the RefAny ctx */
130///     const char*        kind,      /* null-terminated wrapper name */
131///     const void* const* args,      /* array of pointers, one per arg, in declared order */
132///     size_t             n_args,    /* args[] length */
133///     void*              ret        /* where to write the return value (kind-specific size) */
134/// );
135/// extern void AzApp_setGenericInvoker(AzGenericInvoker);
136/// ```
137///
138/// The args array carries pointers into the framework's by-value frame
139/// — host code must not retain them past the call. The host decides what
140/// to do per kind from the `kind` string (which matches the wrapper
141/// struct name, e.g. `"Callback"`, `"LayoutCallback"`,
142/// `"ButtonOnClickCallback"`).
143pub static GENERIC_INVOKER: InvokerSlot = InvokerSlot::new();
144
145/// Type alias for the generic invoker callable. Hosts cast a libffi
146/// closure to this signature once at module load.
147pub type AzGenericInvoker = extern "C" fn(
148    handle: u64,
149    kind: *const core::ffi::c_char,
150    args: *const *const c_void,
151    n_args: usize,
152    ret: *mut c_void,
153);
154
155/// Register the generic invoker for user-defined custom callback kinds
156/// or as a fallback for per-kind dispatch. Called once at module load;
157/// subsequent registrations replace the previous slot.
158///
159/// Safety: `invoker` must be a valid [`AzGenericInvoker`] function
160/// pointer for the lifetime of any callback that might be dispatched
161/// through it — typically the whole process.
162#[no_mangle]
163pub extern "C" fn AzApp_setGenericInvoker(invoker: AzGenericInvoker) {
164    GENERIC_INVOKER.set(invoker as usize);
165}
166
167/// Register the host-language releaser. Hosts call this once at module
168/// load time; subsequent registrations replace the previous slot.
169///
170/// `releaser` will be invoked as `releaser(id)` whenever a host-handle
171/// `RefAny` (the kind built by [`host_handle_to_refany`]) drops its last
172/// reference. The host should remove `id` from whatever id→callable table
173/// it maintains.
174///
175/// Safety: `releaser` must be a valid `extern "C" fn(u64)` for the lifetime
176/// of any host-handle [`RefAny`] that may still be alive — typically the
177/// whole process. Passing a function pointer that becomes invalid (e.g.,
178/// from an unloaded library) without first re-registering will cause a
179/// crash on the next collection.
180#[no_mangle]
181pub extern "C" fn AzApp_setHostHandleReleaser(releaser: extern "C" fn(u64)) {
182    HOST_HANDLE_RELEASER.set(releaser as usize);
183}
184
185/// Destructor stamped into every host-handle [`RefAny`]. Reads the payload's
186/// `id` and forwards it to the registered releaser; if no releaser has been
187/// registered (e.g., host hasn't initialized yet, or this is a release-build
188/// dll loaded by a non-managed-FFI consumer) the destructor is a no-op so
189/// the C side doesn't crash.
190extern "C" fn host_handle_destructor(ptr: *mut c_void) {
191    if ptr.is_null() {
192        return;
193    }
194    // SAFETY: the destructor only runs for RefAnys built via
195    // host_handle_to_refany, whose payload type is HostHandlePayload.
196    let payload = unsafe { &*(ptr as *const HostHandlePayload) };
197
198    let releaser_addr = HOST_HANDLE_RELEASER.get();
199    if releaser_addr == 0 {
200        return;
201    }
202    // SAFETY: HOST_HANDLE_RELEASER only ever holds a value that came from
203    // `releaser as usize` in `AzApp_setHostHandleReleaser`, where `releaser`
204    // is an `extern "C" fn(u64)`.
205    let releaser: extern "C" fn(u64) = unsafe { core::mem::transmute(releaser_addr) };
206    // AUDIT: this destructor is `extern "C"` and the host releaser is arbitrary
207    // (often a Rust closure via libffi). A panic escaping it would unwind across
208    // the FFI boundary (UB), so contain it. `catch_unwind` needs `std`; `no_std`
209    // builds use `panic = "abort"` where unwinding cannot occur.
210    #[cfg(feature = "std")]
211    {
212        drop(std::panic::catch_unwind(std::panic::AssertUnwindSafe(
213            || releaser(payload.id),
214        )));
215    }
216    #[cfg(not(feature = "std"))]
217    {
218        releaser(payload.id);
219    }
220}
221
222/// Wrap a host-language `u64` handle in a [`RefAny`] suitable for storing
223/// in a callback wrapper's `ctx` field.
224///
225/// The returned `RefAny`'s destructor calls back through the registered
226/// host releaser when the last clone is dropped, giving the host an
227/// opportunity to release whatever its `id` was keying.
228pub fn host_handle_to_refany(id: u64) -> RefAny {
229    let payload = HostHandlePayload { id };
230    let type_name: AzString = "AzHostHandle".into();
231    RefAny::new_c(
232        &raw const payload as *const c_void,
233        size_of::<HostHandlePayload>(),
234        align_of::<HostHandlePayload>(),
235        AZ_HOST_HANDLE_RTTI_ID,
236        type_name,
237        host_handle_destructor,
238        0,
239        0,
240    )
241}
242
243/// Read the host-language id back out of a [`RefAny`] previously created
244/// via [`host_handle_to_refany`].
245///
246/// Returns `None` for any other `RefAny`, so
247/// a static thunk that mistakenly receives a non-host-handle ctx falls
248/// back to the kind's default value rather than reading random bytes.
249#[must_use]
250pub fn refany_to_host_handle(refany: &RefAny) -> Option<u64> {
251    if !refany.is_type(AZ_HOST_HANDLE_RTTI_ID) {
252        return None;
253    }
254    let ptr = refany.get_data_ptr() as *const HostHandlePayload;
255    if ptr.is_null() {
256        return None;
257    }
258    // SAFETY: type-id check above guarantees the payload was a HostHandlePayload.
259    Some(unsafe { (*ptr).id })
260}
261
262/// C-ABI: build a [`RefAny`] wrapping a host-language id.
263///
264/// Lets managed-FFI
265/// bindings use the same machinery for user data that callbacks already use
266/// — one releaser, one id-keyed table, one lifetime story.
267///
268/// The returned `RefAny`'s destructor fires the releaser registered via
269/// [`AzApp_setHostHandleReleaser`] once the last clone drops, so the host
270/// can drop its `id → value` entry.
271#[no_mangle]
272pub extern "C" fn AzRefAny_newHostHandle(id: u64) -> RefAny {
273    host_handle_to_refany(id)
274}
275
276/// C-ABI: read the host-language id from a [`RefAny`] previously built via
277/// [`AzRefAny_newHostHandle`] (or any other host-handle constructor).
278///
279/// Returns `0` if `refany` is null or wasn't a host handle. Host bindings
280/// must reserve `0` as "no value" — [`host_handle_to_refany`] never produces
281/// `0` if the host's id allocator starts at `1` (the convention used by
282/// every binding in this repo).
283#[no_mangle]
284#[allow(clippy::not_unsafe_ptr_arg_deref)] // SAFETY/FFI: `*const T` is the C-ABI signature; the fn null-checks then derefs under the documented caller contract (C guarantees a valid ptr/len). Marking it `unsafe fn` would force unsafe blocks into the generated dll bindings.
285pub extern "C" fn AzRefAny_getHostHandle(refany: *const RefAny) -> u64 {
286    if refany.is_null() {
287        return 0;
288    }
289    // SAFETY: caller's responsibility per `*const` signature.
290    let r = unsafe { &*refany };
291    refany_to_host_handle(r).unwrap_or(0)
292}
293
294/// Macro that expands to the per-callback-kind boilerplate:
295///
296/// a static thunk
297/// (compiled into libazul) that the framework calls with by-value args, a
298/// `<Wrapper>::create_from_host_handle(u64)` constructor, and an
299/// `AzApp_set<Kind>Invoker` setter the host calls once at module load.
300///
301/// All identifiers are passed in explicitly so we don't need a proc-macro
302/// dependency just to concatenate idents. Codegen emits invocations of this
303/// macro from `ir.callback_typedefs`.
304///
305/// Caller responsibilities:
306///
307/// - The wrapper type must have public fields `cb: <typedef>` and
308///   `ctx: OptionRefAny` — that's the standard shape every callback wrapper
309///   in the framework already follows.
310/// - `info_ty` must expose a `.get_ctx() -> OptionRefAny` method (also
311///   standard for `*CallbackInfo` types).
312/// - `default_ret` is returned when:
313///   - the framework invokes the thunk with `OptionRefAny::None` ctx
314///     (host called the typedef directly without going through this path),
315///   - the ctx isn't a host-handle (host registered the wrapper but the
316///     ctx came from somewhere else),
317///   - or no invoker has been registered yet for this kind. Pick a value
318///     that can't be confused with a "real" return — typically the kind's
319///     "do nothing" / "empty body" default.
320#[macro_export]
321macro_rules! impl_managed_callback {
322    // Form 1: simple two-argument callbacks `(RefAny, info) -> ret` —
323    // matches `Callback`, `LayoutCallback`, `ButtonOnClickCallback`,
324    // and the bulk of widget event callbacks. Identical to the
325    // extras-form below with an empty extra-args list.
326    (
327        wrapper:        $wrapper:ty,
328        info_ty:        $info_ty:ty,
329        return_ty:      $ret:ty,
330        default_ret:    $default:expr,
331        invoker_static: $invoker_static:ident,
332        invoker_ty:     $invoker_ty:ident,
333        thunk_fn:       $thunk_fn:ident,
334        setter_fn:      $setter_fn:ident,
335        from_handle_fn: $from_handle_fn:ident,
336    ) => {
337        $crate::impl_managed_callback! {
338            wrapper:        $wrapper,
339            info_ty:        $info_ty,
340            return_ty:      $ret,
341            default_ret:    $default,
342            invoker_static: $invoker_static,
343            invoker_ty:     $invoker_ty,
344            thunk_fn:       $thunk_fn,
345            setter_fn:      $setter_fn,
346            from_handle_fn: $from_handle_fn,
347            extra_args:     [],
348        }
349    };
350    // Form 2: callbacks that take additional state after info — e.g.
351    // `CheckBoxOnToggleCallback(RefAny, CallbackInfo, CheckBoxState)`.
352    // The extras list is forwarded by reference into the host invoker
353    // so libffi-style runtimes never have to handle aggregate-by-value
354    // returns OR aggregate-by-value args.
355    (
356        wrapper:        $wrapper:ty,
357        info_ty:        $info_ty:ty,
358        return_ty:      $ret:ty,
359        default_ret:    $default:expr,
360        invoker_static: $invoker_static:ident,
361        invoker_ty:     $invoker_ty:ident,
362        thunk_fn:       $thunk_fn:ident,
363        setter_fn:      $setter_fn:ident,
364        from_handle_fn: $from_handle_fn:ident,
365        extra_args:     [ $( $extra_name:ident : $extra_ty:ty ),* $(,)? ] $(,)?
366    ) => {
367        /// Process-global slot for this callback kind's host-side invoker.
368        pub static $invoker_static: $crate::host_invoker::InvokerSlot =
369            $crate::host_invoker::InvokerSlot::new();
370
371        /// Pointer-arg variant of this callback kind's typedef.
372        ///
373        /// The host's libffi closure casts to this signature (which all
374        /// managed-FFI runtimes can handle — args and return are passed
375        /// by pointer, no aggregate-by-value anywhere). The static thunk
376        /// in libazul does the by-value plumbing on the C ABI side.
377        ///
378        /// `LuaJIT` FFI in particular cannot return aggregates larger than
379        /// 8 bytes from a callback, so we use an out-pointer for the
380        /// return value uniformly across kinds — even for `Update` which
381        /// would fit in a register, so the macro stays homogeneous.
382        pub type $invoker_ty = extern "C" fn(
383            handle: u64,
384            data: *const $crate::refany::RefAny,
385            info: *const $info_ty,
386            $( $extra_name : *const $extra_ty , )*
387            out: *mut $ret,
388        );
389
390        /// Register the host-side invoker for this callback kind.
391        #[no_mangle]
392        pub extern "C" fn $setter_fn(invoker: $invoker_ty) {
393            $invoker_static.set(invoker as usize);
394        }
395
396        /// Static thunk compiled into libazul. The framework calls this
397        /// with by-value args; we extract the host handle from `info.ctx`,
398        /// allocate space for the return value on our stack, and forward
399        /// pointers to the registered invoker.
400        extern "C" fn $thunk_fn(
401            data: $crate::refany::RefAny,
402            info: $info_ty,
403            $( $extra_name : $extra_ty , )*
404        ) -> $ret {
405            // Wrapper name as a null-terminated C string. `stringify!`
406            // expands `$wrapper:ty` to e.g. `Callback`,
407            // `ButtonOnClickCallback`, etc. — matching what the host's
408            // dispatch table keys on.
409            const KIND_STR: &str = concat!(stringify!($wrapper), "\0");
410
411            // AUDIT: this thunk is `extern "C"` and dispatches into arbitrary
412            // host code (via a transmuted invoker pointer). A panic escaping the
413            // dispatch would unwind across the FFI boundary (UB), so run the
414            // whole body inside `catch_unwind` and fall back to `$default` on a
415            // panic. `catch_unwind` needs `std`; `no_std` builds use
416            // `panic = "abort"` where unwinding cannot occur. The body captures
417            // `data`/`info`/extras by move (they are consumed either way).
418            let body = move || -> $ret {
419                let ctx = info.get_ctx();
420                let handle = match ctx {
421                    $crate::refany::OptionRefAny::Some(ref refany) => {
422                        match $crate::host_invoker::refany_to_host_handle(refany) {
423                            Some(id) => id,
424                            None => return $default,
425                        }
426                    }
427                    _ => return $default,
428                };
429                let invoker_addr = $invoker_static.get();
430                if invoker_addr == 0 {
431                    // Per-kind invoker not registered — fall back to the
432                    // generic invoker for hosts that wired up only the
433                    // single `AzApp_setGenericInvoker` slot (or for custom
434                    // user-defined kinds emitted by a downstream
435                    // `impl_managed_callback!` whose host hasn't shipped a
436                    // per-kind invoker setter yet).
437                    let generic_addr = $crate::host_invoker::GENERIC_INVOKER.get();
438                    if generic_addr == 0 {
439                        return $default;
440                    }
441                    // SAFETY: GENERIC_INVOKER only ever holds an address that
442                    // came from `invoker as usize` in `AzApp_setGenericInvoker`,
443                    // whose parameter is typed as `AzGenericInvoker`.
444                    let generic: $crate::host_invoker::AzGenericInvoker =
445                        unsafe { core::mem::transmute(generic_addr) };
446
447                    // Build the args array: pointers to each by-value frame
448                    // arg, in declared order (data, info, extras…). Lifetime
449                    // is the scope of this thunk; the host MUST NOT retain
450                    // these pointers past the call. Array size is inferred
451                    // (2 base args + however many extras the macro forwarded).
452                    let args = [
453                        &raw const data as *const core::ffi::c_void,
454                        &raw const info as *const core::ffi::c_void,
455                        $( & $extra_name as *const _ as *const core::ffi::c_void , )*
456                    ];
457
458                    let mut out: $ret = $default;
459                    generic(
460                        handle,
461                        KIND_STR.as_ptr() as *const core::ffi::c_char,
462                        args.as_ptr(),
463                        args.len(),
464                        &raw mut out as *mut core::ffi::c_void,
465                    );
466                    return out;
467                }
468                // SAFETY: $invoker_static only ever holds a value that came from
469                // `invoker as usize` in `$setter_fn`, where `invoker` has type
470                // `$invoker_ty`.
471                let invoker: $invoker_ty = unsafe { core::mem::transmute(invoker_addr) };
472
473                // Pre-fill `out` with the kind's default so a host that fails
474                // to write to the out-pointer (e.g. a buggy invoker) leaves us
475                // with a sane value rather than uninitialized memory.
476                let mut out: $ret = $default;
477                invoker(
478                    handle,
479                    &raw const data,
480                    &raw const info,
481                    $( & $extra_name as *const $extra_ty , )*
482                    &raw mut out,
483                );
484                out
485            };
486
487            #[cfg(feature = "std")]
488            {
489                std::panic::catch_unwind(std::panic::AssertUnwindSafe(body))
490                    .unwrap_or($default)
491            }
492            #[cfg(not(feature = "std"))]
493            {
494                body()
495            }
496        }
497
498        impl $wrapper {
499            /// Build a wrapper whose `cb` is the static thunk above and
500            /// whose `ctx` carries the host's `u64` handle. The host
501            /// language is responsible for keeping its id→callable table
502            /// in sync with the releaser registered via
503            /// `AzApp_setHostHandleReleaser`.
504            #[must_use] pub fn create_from_host_handle(handle: u64) -> Self {
505                Self {
506                    cb: $thunk_fn,
507                    ctx: $crate::refany::OptionRefAny::Some(
508                        $crate::host_invoker::host_handle_to_refany(handle),
509                    ),
510                }
511            }
512        }
513
514        /// C-ABI export wrapping `<Wrapper>::create_from_host_handle`.
515        #[no_mangle]
516        pub extern "C" fn $from_handle_fn(handle: u64) -> $wrapper {
517            <$wrapper>::create_from_host_handle(handle)
518        }
519    };
520}
521
522// NOTE on Miri coverage: the *genuine* FFI transmutes here (a raw host fn
523// pointer stored as `usize` in an `InvokerSlot`, transmuted back to a fn
524// pointer) cannot be driven from real C under Miri. Instead the tests below
525// register real Rust `extern "C"` fns through the public C-ABI setters, so the
526// `set(ptr as usize)` -> `get()` -> `transmute` round-trip is exercised
527// end-to-end with a live pointer (Miri-clean, no UB). The panic-containment
528// test drives the macro-generated thunk's `catch_unwind` with a pure-Rust
529// panic raised *inside* the thunk body (before any extern-"C" boundary), which
530// is the realistic containment path.
531#[cfg(all(test, feature = "std"))]
532#[allow(
533    clippy::items_after_statements,
534    clippy::redundant_clone,
535    clippy::cast_possible_truncation,
536    clippy::cast_sign_loss,
537    trivial_casts,
538    clippy::borrow_as_ptr,
539    clippy::cast_ptr_alignment,
540    clippy::unused_self,
541    unused_qualifications,
542    unreachable_pub,
543    private_interfaces
544)] // test-only fakes drive the FFI macro; pedantic lints are noise here
545mod tests {
546    use core::sync::atomic::{AtomicU64, Ordering as AtOrdering};
547    use std::sync::Mutex;
548
549    use super::*;
550
551    // The invoker/releaser slots are process-global; serialize tests that
552    // touch them so parallel test threads don't clobber each other.
553    // `pub(super)` so `autotest_generated` below locks the SAME mutex — a
554    // second, independent lock would not serialize the two modules against
555    // each other.
556    pub(super) static TEST_LOCK: Mutex<()> = Mutex::new(());
557
558    // Records the id the releaser was called with, so we can assert the
559    // transmuted-back fn pointer was invoked with the correct payload id.
560    static LAST_RELEASED: AtomicU64 = AtomicU64::new(0);
561
562    extern "C" fn recording_releaser(id: u64) {
563        LAST_RELEASED.store(id, AtOrdering::SeqCst);
564    }
565
566    #[test]
567    fn destructor_transmutes_and_invokes_releaser() {
568        let _g = TEST_LOCK.lock().unwrap();
569        LAST_RELEASED.store(0, AtOrdering::SeqCst);
570        // Register via the real C-ABI setter (exercises `releaser as usize`).
571        AzApp_setHostHandleReleaser(recording_releaser);
572        let mut payload = HostHandlePayload { id: 0xABCD_1234 };
573        // Drive the destructor directly with a pointer to the payload — the
574        // same shape a host-handle RefAny hands it. Exercises the payload
575        // deref + the usize->fn-pointer transmute + the invoke.
576        host_handle_destructor((&raw mut payload).cast::<c_void>());
577        assert_eq!(LAST_RELEASED.load(AtOrdering::SeqCst), 0xABCD_1234);
578        // Clear the slot so a later drop can't call a stale test fn pointer.
579        HOST_HANDLE_RELEASER.set(0);
580    }
581
582    #[test]
583    fn destructor_null_ptr_is_noop() {
584        // Returns before touching any global; no lock needed.
585        host_handle_destructor(core::ptr::null_mut());
586    }
587
588    #[test]
589    fn host_handle_roundtrips_through_refany() {
590        let _g = TEST_LOCK.lock().unwrap();
591        // Ensure the round-trip RefAny's drop fires no releaser.
592        HOST_HANDLE_RELEASER.set(0);
593        let refany = host_handle_to_refany(0x55);
594        // Exercises the type-id-guarded raw-ptr deref in refany_to_host_handle.
595        assert_eq!(refany_to_host_handle(&refany), Some(0x55));
596    }
597
598    // A fake callback kind used to instantiate `impl_managed_callback!` and
599    // assert the generated thunk contains a panic instead of unwinding out of
600    // its `extern "C"` boundary.
601    #[derive(PartialEq, Debug)]
602    struct FakeRet(u32);
603
604    struct FakeInfo;
605    impl FakeInfo {
606        // Panics from *inside* the thunk body (pure-Rust unwind), so the
607        // thunk's `catch_unwind` is the thing under test.
608        fn get_ctx(&self) -> crate::refany::OptionRefAny {
609            panic!("boom from get_ctx");
610        }
611    }
612
613    struct FakeWrapper {
614        #[allow(dead_code)]
615        cb: extern "C" fn(crate::refany::RefAny, FakeInfo) -> FakeRet,
616        #[allow(dead_code)]
617        ctx: crate::refany::OptionRefAny,
618    }
619
620    crate::impl_managed_callback! {
621        wrapper:        FakeWrapper,
622        info_ty:        FakeInfo,
623        return_ty:      FakeRet,
624        default_ret:    FakeRet(99),
625        invoker_static: AZ_TEST_FAKE_INVOKER,
626        invoker_ty:     AzTestFakeInvoker,
627        thunk_fn:       az_test_fake_thunk,
628        setter_fn:      az_test_fake_set_invoker,
629        from_handle_fn: az_test_fake_from_handle,
630    }
631
632    #[test]
633    fn thunk_contains_panic_and_returns_default() {
634        let _g = TEST_LOCK.lock().unwrap();
635        HOST_HANDLE_RELEASER.set(0);
636        let data = host_handle_to_refany(1);
637        // get_ctx() panics inside the thunk body; catch_unwind must contain it
638        // and hand back `default_ret` rather than unwinding across FFI.
639        let out = az_test_fake_thunk(data, FakeInfo);
640        assert_eq!(out, FakeRet(99));
641    }
642}
643
644#[cfg(test)]
645#[path = "host_invoker_test.rs"]
646mod host_invoker_test;