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] pub const fn new() -> Self {
84        Self {
85            fn_ptr: AtomicUsize::new(0),
86        }
87    }
88
89    /// Replace the registered function pointer.
90    ///
91    /// `SeqCst` because the slot is read on every callback fire and we
92    /// don't want any stale-pointer windows after the host swaps invokers
93    /// (rare but legal — e.g. unloading a Lua module that registered).
94    pub fn set(&self, ptr: usize) {
95        self.fn_ptr.store(ptr, Ordering::SeqCst);
96    }
97
98    /// Read the current function pointer; `0` if unregistered.
99    pub fn get(&self) -> usize {
100        self.fn_ptr.load(Ordering::SeqCst)
101    }
102}
103
104impl Default for InvokerSlot {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110/// Process-global slot for the host's "drop a handle id" callback. Set via
111/// [`AzApp_setHostHandleReleaser`]. Read by [`host_handle_destructor`]
112/// when a host-handle [`RefAny`]'s last clone drops.
113pub static HOST_HANDLE_RELEASER: InvokerSlot = InvokerSlot::new();
114
115/// Process-global slot for the host's *generic* invoker.
116///
117/// Set via
118/// [`AzApp_setGenericInvoker`]. Used as a fallback in macro-generated
119/// per-kind thunks when the per-kind invoker is not registered, and as
120/// the **only** dispatch path for user-defined custom callback kinds in
121/// libffi-restricted hosts (Lua, PHP, koffi, …) that can't easily ship
122/// an upstream `impl_managed_callback!` invocation.
123///
124/// Signature on the host side:
125///
126/// ```c
127/// typedef void (*AzGenericInvoker)(
128///     uint64_t           handle,    /* host-handle id from the RefAny ctx */
129///     const char*        kind,      /* null-terminated wrapper name */
130///     const void* const* args,      /* array of pointers, one per arg, in declared order */
131///     size_t             n_args,    /* args[] length */
132///     void*              ret        /* where to write the return value (kind-specific size) */
133/// );
134/// extern void AzApp_setGenericInvoker(AzGenericInvoker);
135/// ```
136///
137/// The args array carries pointers into the framework's by-value frame
138/// — host code must not retain them past the call. The host decides what
139/// to do per kind from the `kind` string (which matches the wrapper
140/// struct name, e.g. `"Callback"`, `"LayoutCallback"`,
141/// `"ButtonOnClickCallback"`).
142pub static GENERIC_INVOKER: InvokerSlot = InvokerSlot::new();
143
144/// Type alias for the generic invoker callable. Hosts cast a libffi
145/// closure to this signature once at module load.
146pub type AzGenericInvoker = extern "C" fn(
147    handle: u64,
148    kind: *const core::ffi::c_char,
149    args: *const *const c_void,
150    n_args: usize,
151    ret: *mut c_void,
152);
153
154/// Register the generic invoker for user-defined custom callback kinds
155/// or as a fallback for per-kind dispatch. Called once at module load;
156/// subsequent registrations replace the previous slot.
157///
158/// Safety: `invoker` must be a valid [`AzGenericInvoker`] function
159/// pointer for the lifetime of any callback that might be dispatched
160/// through it — typically the whole process.
161#[no_mangle]
162pub extern "C" fn AzApp_setGenericInvoker(invoker: AzGenericInvoker) {
163    GENERIC_INVOKER.set(invoker as usize);
164}
165
166/// Register the host-language releaser. Hosts call this once at module
167/// load time; subsequent registrations replace the previous slot.
168///
169/// `releaser` will be invoked as `releaser(id)` whenever a host-handle
170/// `RefAny` (the kind built by [`host_handle_to_refany`]) drops its last
171/// reference. The host should remove `id` from whatever id→callable table
172/// it maintains.
173///
174/// Safety: `releaser` must be a valid `extern "C" fn(u64)` for the lifetime
175/// of any host-handle [`RefAny`] that may still be alive — typically the
176/// whole process. Passing a function pointer that becomes invalid (e.g.,
177/// from an unloaded library) without first re-registering will cause a
178/// crash on the next collection.
179#[no_mangle]
180pub extern "C" fn AzApp_setHostHandleReleaser(releaser: extern "C" fn(u64)) {
181    HOST_HANDLE_RELEASER.set(releaser as usize);
182}
183
184/// Destructor stamped into every host-handle [`RefAny`]. Reads the payload's
185/// `id` and forwards it to the registered releaser; if no releaser has been
186/// registered (e.g., host hasn't initialized yet, or this is a release-build
187/// dll loaded by a non-managed-FFI consumer) the destructor is a no-op so
188/// the C side doesn't crash.
189extern "C" fn host_handle_destructor(ptr: *mut c_void) {
190    if ptr.is_null() {
191        return;
192    }
193    // SAFETY: the destructor only runs for RefAnys built via
194    // host_handle_to_refany, whose payload type is HostHandlePayload.
195    let payload = unsafe { &*(ptr as *const HostHandlePayload) };
196
197    let releaser_addr = HOST_HANDLE_RELEASER.get();
198    if releaser_addr == 0 {
199        return;
200    }
201    // SAFETY: HOST_HANDLE_RELEASER only ever holds a value that came from
202    // `releaser as usize` in `AzApp_setHostHandleReleaser`, where `releaser`
203    // is an `extern "C" fn(u64)`.
204    let releaser: extern "C" fn(u64) = unsafe { core::mem::transmute(releaser_addr) };
205    // AUDIT: this destructor is `extern "C"` and the host releaser is arbitrary
206    // (often a Rust closure via libffi). A panic escaping it would unwind across
207    // the FFI boundary (UB), so contain it. `catch_unwind` needs `std`; `no_std`
208    // builds use `panic = "abort"` where unwinding cannot occur.
209    #[cfg(feature = "std")]
210    {
211        drop(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| releaser(payload.id))));
212    }
213    #[cfg(not(feature = "std"))]
214    {
215        releaser(payload.id);
216    }
217}
218
219/// Wrap a host-language `u64` handle in a [`RefAny`] suitable for storing
220/// in a callback wrapper's `ctx` field.
221///
222/// The returned `RefAny`'s destructor calls back through the registered
223/// host releaser when the last clone is dropped, giving the host an
224/// opportunity to release whatever its `id` was keying.
225pub fn host_handle_to_refany(id: u64) -> RefAny {
226    let payload = HostHandlePayload { id };
227    let type_name: AzString = "AzHostHandle".into();
228    RefAny::new_c(
229        &raw const payload as *const c_void,
230        size_of::<HostHandlePayload>(),
231        align_of::<HostHandlePayload>(),
232        AZ_HOST_HANDLE_RTTI_ID,
233        type_name,
234        host_handle_destructor,
235        0,
236        0,
237    )
238}
239
240/// Read the host-language id back out of a [`RefAny`] previously created
241/// via [`host_handle_to_refany`].
242///
243/// Returns `None` for any other `RefAny`, so
244/// a static thunk that mistakenly receives a non-host-handle ctx falls
245/// back to the kind's default value rather than reading random bytes.
246#[must_use] pub fn refany_to_host_handle(refany: &RefAny) -> Option<u64> {
247    if !refany.is_type(AZ_HOST_HANDLE_RTTI_ID) {
248        return None;
249    }
250    let ptr = refany.get_data_ptr() as *const HostHandlePayload;
251    if ptr.is_null() {
252        return None;
253    }
254    // SAFETY: type-id check above guarantees the payload was a HostHandlePayload.
255    Some(unsafe { (*ptr).id })
256}
257
258/// C-ABI: build a [`RefAny`] wrapping a host-language id.
259///
260/// Lets managed-FFI
261/// bindings use the same machinery for user data that callbacks already use
262/// — one releaser, one id-keyed table, one lifetime story.
263///
264/// The returned `RefAny`'s destructor fires the releaser registered via
265/// [`AzApp_setHostHandleReleaser`] once the last clone drops, so the host
266/// can drop its `id → value` entry.
267#[no_mangle]
268pub extern "C" fn AzRefAny_newHostHandle(id: u64) -> RefAny {
269    host_handle_to_refany(id)
270}
271
272/// C-ABI: read the host-language id from a [`RefAny`] previously built via
273/// [`AzRefAny_newHostHandle`] (or any other host-handle constructor).
274///
275/// Returns `0` if `refany` is null or wasn't a host handle. Host bindings
276/// must reserve `0` as "no value" — [`host_handle_to_refany`] never produces
277/// `0` if the host's id allocator starts at `1` (the convention used by
278/// every binding in this repo).
279#[no_mangle]
280#[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.
281pub extern "C" fn AzRefAny_getHostHandle(refany: *const RefAny) -> u64 {
282    if refany.is_null() {
283        return 0;
284    }
285    // SAFETY: caller's responsibility per `*const` signature.
286    let r = unsafe { &*refany };
287    refany_to_host_handle(r).unwrap_or(0)
288}
289
290/// Macro that expands to the per-callback-kind boilerplate:
291///
292/// a static thunk
293/// (compiled into libazul) that the framework calls with by-value args, a
294/// `<Wrapper>::create_from_host_handle(u64)` constructor, and an
295/// `AzApp_set<Kind>Invoker` setter the host calls once at module load.
296///
297/// All identifiers are passed in explicitly so we don't need a proc-macro
298/// dependency just to concatenate idents. Codegen emits invocations of this
299/// macro from `ir.callback_typedefs`.
300///
301/// Caller responsibilities:
302///
303/// - The wrapper type must have public fields `cb: <typedef>` and
304///   `ctx: OptionRefAny` — that's the standard shape every callback wrapper
305///   in the framework already follows.
306/// - `info_ty` must expose a `.get_ctx() -> OptionRefAny` method (also
307///   standard for `*CallbackInfo` types).
308/// - `default_ret` is returned when:
309///   - the framework invokes the thunk with `OptionRefAny::None` ctx
310///     (host called the typedef directly without going through this path),
311///   - the ctx isn't a host-handle (host registered the wrapper but the
312///     ctx came from somewhere else),
313///   - or no invoker has been registered yet for this kind. Pick a value
314///     that can't be confused with a "real" return — typically the kind's
315///     "do nothing" / "empty body" default.
316#[macro_export]
317macro_rules! impl_managed_callback {
318    // Form 1: simple two-argument callbacks `(RefAny, info) -> ret` —
319    // matches `Callback`, `LayoutCallback`, `ButtonOnClickCallback`,
320    // and the bulk of widget event callbacks. Identical to the
321    // extras-form below with an empty extra-args list.
322    (
323        wrapper:        $wrapper:ty,
324        info_ty:        $info_ty:ty,
325        return_ty:      $ret:ty,
326        default_ret:    $default:expr,
327        invoker_static: $invoker_static:ident,
328        invoker_ty:     $invoker_ty:ident,
329        thunk_fn:       $thunk_fn:ident,
330        setter_fn:      $setter_fn:ident,
331        from_handle_fn: $from_handle_fn:ident,
332    ) => {
333        $crate::impl_managed_callback! {
334            wrapper:        $wrapper,
335            info_ty:        $info_ty,
336            return_ty:      $ret,
337            default_ret:    $default,
338            invoker_static: $invoker_static,
339            invoker_ty:     $invoker_ty,
340            thunk_fn:       $thunk_fn,
341            setter_fn:      $setter_fn,
342            from_handle_fn: $from_handle_fn,
343            extra_args:     [],
344        }
345    };
346    // Form 2: callbacks that take additional state after info — e.g.
347    // `CheckBoxOnToggleCallback(RefAny, CallbackInfo, CheckBoxState)`.
348    // The extras list is forwarded by reference into the host invoker
349    // so libffi-style runtimes never have to handle aggregate-by-value
350    // returns OR aggregate-by-value args.
351    (
352        wrapper:        $wrapper:ty,
353        info_ty:        $info_ty:ty,
354        return_ty:      $ret:ty,
355        default_ret:    $default:expr,
356        invoker_static: $invoker_static:ident,
357        invoker_ty:     $invoker_ty:ident,
358        thunk_fn:       $thunk_fn:ident,
359        setter_fn:      $setter_fn:ident,
360        from_handle_fn: $from_handle_fn:ident,
361        extra_args:     [ $( $extra_name:ident : $extra_ty:ty ),* $(,)? ] $(,)?
362    ) => {
363        /// Process-global slot for this callback kind's host-side invoker.
364        pub static $invoker_static: $crate::host_invoker::InvokerSlot =
365            $crate::host_invoker::InvokerSlot::new();
366
367        /// Pointer-arg variant of this callback kind's typedef.
368        ///
369        /// The host's libffi closure casts to this signature (which all
370        /// managed-FFI runtimes can handle — args and return are passed
371        /// by pointer, no aggregate-by-value anywhere). The static thunk
372        /// in libazul does the by-value plumbing on the C ABI side.
373        ///
374        /// `LuaJIT` FFI in particular cannot return aggregates larger than
375        /// 8 bytes from a callback, so we use an out-pointer for the
376        /// return value uniformly across kinds — even for `Update` which
377        /// would fit in a register, so the macro stays homogeneous.
378        pub type $invoker_ty = extern "C" fn(
379            handle: u64,
380            data: *const $crate::refany::RefAny,
381            info: *const $info_ty,
382            $( $extra_name : *const $extra_ty , )*
383            out: *mut $ret,
384        );
385
386        /// Register the host-side invoker for this callback kind.
387        #[no_mangle]
388        pub extern "C" fn $setter_fn(invoker: $invoker_ty) {
389            $invoker_static.set(invoker as usize);
390        }
391
392        /// Static thunk compiled into libazul. The framework calls this
393        /// with by-value args; we extract the host handle from `info.ctx`,
394        /// allocate space for the return value on our stack, and forward
395        /// pointers to the registered invoker.
396        extern "C" fn $thunk_fn(
397            data: $crate::refany::RefAny,
398            info: $info_ty,
399            $( $extra_name : $extra_ty , )*
400        ) -> $ret {
401            // Wrapper name as a null-terminated C string. `stringify!`
402            // expands `$wrapper:ty` to e.g. `Callback`,
403            // `ButtonOnClickCallback`, etc. — matching what the host's
404            // dispatch table keys on.
405            const KIND_STR: &str = concat!(stringify!($wrapper), "\0");
406
407            // AUDIT: this thunk is `extern "C"` and dispatches into arbitrary
408            // host code (via a transmuted invoker pointer). A panic escaping the
409            // dispatch would unwind across the FFI boundary (UB), so run the
410            // whole body inside `catch_unwind` and fall back to `$default` on a
411            // panic. `catch_unwind` needs `std`; `no_std` builds use
412            // `panic = "abort"` where unwinding cannot occur. The body captures
413            // `data`/`info`/extras by move (they are consumed either way).
414            let body = move || -> $ret {
415                let ctx = info.get_ctx();
416                let handle = match ctx {
417                    $crate::refany::OptionRefAny::Some(ref refany) => {
418                        match $crate::host_invoker::refany_to_host_handle(refany) {
419                            Some(id) => id,
420                            None => return $default,
421                        }
422                    }
423                    _ => return $default,
424                };
425                let invoker_addr = $invoker_static.get();
426                if invoker_addr == 0 {
427                    // Per-kind invoker not registered — fall back to the
428                    // generic invoker for hosts that wired up only the
429                    // single `AzApp_setGenericInvoker` slot (or for custom
430                    // user-defined kinds emitted by a downstream
431                    // `impl_managed_callback!` whose host hasn't shipped a
432                    // per-kind invoker setter yet).
433                    let generic_addr = $crate::host_invoker::GENERIC_INVOKER.get();
434                    if generic_addr == 0 {
435                        return $default;
436                    }
437                    // SAFETY: GENERIC_INVOKER only ever holds an address that
438                    // came from `invoker as usize` in `AzApp_setGenericInvoker`,
439                    // whose parameter is typed as `AzGenericInvoker`.
440                    let generic: $crate::host_invoker::AzGenericInvoker =
441                        unsafe { core::mem::transmute(generic_addr) };
442
443                    // Build the args array: pointers to each by-value frame
444                    // arg, in declared order (data, info, extras…). Lifetime
445                    // is the scope of this thunk; the host MUST NOT retain
446                    // these pointers past the call. Array size is inferred
447                    // (2 base args + however many extras the macro forwarded).
448                    let args = [
449                        &raw const data as *const core::ffi::c_void,
450                        &raw const info as *const core::ffi::c_void,
451                        $( & $extra_name as *const _ as *const core::ffi::c_void , )*
452                    ];
453
454                    let mut out: $ret = $default;
455                    generic(
456                        handle,
457                        KIND_STR.as_ptr() as *const core::ffi::c_char,
458                        args.as_ptr(),
459                        args.len(),
460                        &raw mut out as *mut core::ffi::c_void,
461                    );
462                    return out;
463                }
464                // SAFETY: $invoker_static only ever holds a value that came from
465                // `invoker as usize` in `$setter_fn`, where `invoker` has type
466                // `$invoker_ty`.
467                let invoker: $invoker_ty = unsafe { core::mem::transmute(invoker_addr) };
468
469                // Pre-fill `out` with the kind's default so a host that fails
470                // to write to the out-pointer (e.g. a buggy invoker) leaves us
471                // with a sane value rather than uninitialized memory.
472                let mut out: $ret = $default;
473                invoker(
474                    handle,
475                    &raw const data,
476                    &raw const info,
477                    $( & $extra_name as *const $extra_ty , )*
478                    &raw mut out,
479                );
480                out
481            };
482
483            #[cfg(feature = "std")]
484            {
485                std::panic::catch_unwind(std::panic::AssertUnwindSafe(body))
486                    .unwrap_or($default)
487            }
488            #[cfg(not(feature = "std"))]
489            {
490                body()
491            }
492        }
493
494        impl $wrapper {
495            /// Build a wrapper whose `cb` is the static thunk above and
496            /// whose `ctx` carries the host's `u64` handle. The host
497            /// language is responsible for keeping its id→callable table
498            /// in sync with the releaser registered via
499            /// `AzApp_setHostHandleReleaser`.
500            #[must_use] pub fn create_from_host_handle(handle: u64) -> Self {
501                Self {
502                    cb: $thunk_fn,
503                    ctx: $crate::refany::OptionRefAny::Some(
504                        $crate::host_invoker::host_handle_to_refany(handle),
505                    ),
506                }
507            }
508        }
509
510        /// C-ABI export wrapping `<Wrapper>::create_from_host_handle`.
511        #[no_mangle]
512        pub extern "C" fn $from_handle_fn(handle: u64) -> $wrapper {
513            <$wrapper>::create_from_host_handle(handle)
514        }
515    };
516}
517
518// NOTE on Miri coverage: the *genuine* FFI transmutes here (a raw host fn
519// pointer stored as `usize` in an `InvokerSlot`, transmuted back to a fn
520// pointer) cannot be driven from real C under Miri. Instead the tests below
521// register real Rust `extern "C"` fns through the public C-ABI setters, so the
522// `set(ptr as usize)` -> `get()` -> `transmute` round-trip is exercised
523// end-to-end with a live pointer (Miri-clean, no UB). The panic-containment
524// test drives the macro-generated thunk's `catch_unwind` with a pure-Rust
525// panic raised *inside* the thunk body (before any extern-"C" boundary), which
526// is the realistic containment path.
527#[cfg(all(test, feature = "std"))]
528#[allow(clippy::items_after_statements, clippy::redundant_clone, clippy::cast_possible_truncation, clippy::cast_sign_loss, trivial_casts, clippy::borrow_as_ptr, clippy::cast_ptr_alignment, clippy::unused_self, unused_qualifications, unreachable_pub, private_interfaces)] // test-only fakes drive the FFI macro; pedantic lints are noise here
529mod tests {
530    use core::sync::atomic::{AtomicU64, Ordering as AtOrdering};
531    use std::sync::Mutex;
532
533    use super::*;
534
535    // The invoker/releaser slots are process-global; serialize tests that
536    // touch them so parallel test threads don't clobber each other.
537    // `pub(super)` so `autotest_generated` below locks the SAME mutex — a
538    // second, independent lock would not serialize the two modules against
539    // each other.
540    pub(super) static TEST_LOCK: Mutex<()> = Mutex::new(());
541
542    // Records the id the releaser was called with, so we can assert the
543    // transmuted-back fn pointer was invoked with the correct payload id.
544    static LAST_RELEASED: AtomicU64 = AtomicU64::new(0);
545
546    extern "C" fn recording_releaser(id: u64) {
547        LAST_RELEASED.store(id, AtOrdering::SeqCst);
548    }
549
550    #[test]
551    fn destructor_transmutes_and_invokes_releaser() {
552        let _g = TEST_LOCK.lock().unwrap();
553        LAST_RELEASED.store(0, AtOrdering::SeqCst);
554        // Register via the real C-ABI setter (exercises `releaser as usize`).
555        AzApp_setHostHandleReleaser(recording_releaser);
556        let mut payload = HostHandlePayload { id: 0xABCD_1234 };
557        // Drive the destructor directly with a pointer to the payload — the
558        // same shape a host-handle RefAny hands it. Exercises the payload
559        // deref + the usize->fn-pointer transmute + the invoke.
560        host_handle_destructor((&raw mut payload).cast::<c_void>());
561        assert_eq!(LAST_RELEASED.load(AtOrdering::SeqCst), 0xABCD_1234);
562        // Clear the slot so a later drop can't call a stale test fn pointer.
563        HOST_HANDLE_RELEASER.set(0);
564    }
565
566    #[test]
567    fn destructor_null_ptr_is_noop() {
568        // Returns before touching any global; no lock needed.
569        host_handle_destructor(core::ptr::null_mut());
570    }
571
572    #[test]
573    fn host_handle_roundtrips_through_refany() {
574        let _g = TEST_LOCK.lock().unwrap();
575        // Ensure the round-trip RefAny's drop fires no releaser.
576        HOST_HANDLE_RELEASER.set(0);
577        let refany = host_handle_to_refany(0x55);
578        // Exercises the type-id-guarded raw-ptr deref in refany_to_host_handle.
579        assert_eq!(refany_to_host_handle(&refany), Some(0x55));
580    }
581
582    // A fake callback kind used to instantiate `impl_managed_callback!` and
583    // assert the generated thunk contains a panic instead of unwinding out of
584    // its `extern "C"` boundary.
585    #[derive(PartialEq, Debug)]
586    struct FakeRet(u32);
587
588    struct FakeInfo;
589    impl FakeInfo {
590        // Panics from *inside* the thunk body (pure-Rust unwind), so the
591        // thunk's `catch_unwind` is the thing under test.
592        fn get_ctx(&self) -> crate::refany::OptionRefAny {
593            panic!("boom from get_ctx");
594        }
595    }
596
597    struct FakeWrapper {
598        #[allow(dead_code)]
599        cb: extern "C" fn(crate::refany::RefAny, FakeInfo) -> FakeRet,
600        #[allow(dead_code)]
601        ctx: crate::refany::OptionRefAny,
602    }
603
604    crate::impl_managed_callback! {
605        wrapper:        FakeWrapper,
606        info_ty:        FakeInfo,
607        return_ty:      FakeRet,
608        default_ret:    FakeRet(99),
609        invoker_static: AZ_TEST_FAKE_INVOKER,
610        invoker_ty:     AzTestFakeInvoker,
611        thunk_fn:       az_test_fake_thunk,
612        setter_fn:      az_test_fake_set_invoker,
613        from_handle_fn: az_test_fake_from_handle,
614    }
615
616    #[test]
617    fn thunk_contains_panic_and_returns_default() {
618        let _g = TEST_LOCK.lock().unwrap();
619        HOST_HANDLE_RELEASER.set(0);
620        let data = host_handle_to_refany(1);
621        // get_ctx() panics inside the thunk body; catch_unwind must contain it
622        // and hand back `default_ret` rather than unwinding across FFI.
623        let out = az_test_fake_thunk(data, FakeInfo);
624        assert_eq!(out, FakeRet(99));
625    }
626}
627
628/// Adversarial tests for the host-invoker registry.
629///
630/// Everything here that touches `HOST_HANDLE_RELEASER` / `GENERIC_INVOKER` /
631/// the per-kind slot holds [`tests::TEST_LOCK`] — those slots are
632/// process-global, so a parallel test thread would otherwise observe (or
633/// clobber) another test's registration.
634///
635/// Deliberately NOT tested: a host releaser / host invoker that panics. Those
636/// are `extern "C" fn`s, so Rust's abort-on-unwind shim fires *inside the
637/// callee*, before the caller's `catch_unwind` can see the payload — such a
638/// test would abort the whole test binary rather than assert anything. The
639/// realistic containment path (a panic raised inside the thunk body, before
640/// the FFI boundary) is already covered by
641/// `tests::thunk_contains_panic_and_returns_default`.
642#[cfg(all(test, feature = "std"))]
643#[allow(
644    clippy::items_after_statements,
645    clippy::redundant_clone,
646    clippy::cast_possible_truncation,
647    clippy::cast_sign_loss,
648    trivial_casts,
649    clippy::borrow_as_ptr,
650    clippy::cast_ptr_alignment,
651    clippy::unused_self,
652    unused_qualifications,
653    unreachable_pub,
654    private_interfaces,
655    improper_ctypes_definitions,
656    missing_debug_implementations,
657    missing_copy_implementations
658)] // test-only fakes drive the FFI macro; pedantic lints are noise here
659mod autotest_generated {
660    use core::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering as AtOrdering};
661    use std::{ffi::CStr, sync::PoisonError};
662
663    use super::{tests::TEST_LOCK, *};
664    use crate::refany::OptionRefAny;
665
666    /// Lock the shared slot mutex, tolerating poisoning from an earlier failed
667    /// test (otherwise one genuine failure cascades into N spurious ones).
668    fn lock_slots() -> std::sync::MutexGuard<'static, ()> {
669        TEST_LOCK.lock().unwrap_or_else(PoisonError::into_inner)
670    }
671
672    /// Zero every process-global slot this module touches, so a test that
673    /// asserts "unregistered" behaviour can't be fooled by a leftover pointer.
674    fn clear_all_slots() {
675        HOST_HANDLE_RELEASER.set(0);
676        GENERIC_INVOKER.set(0);
677        AZ_AUTOTEST_INVOKER.set(0);
678    }
679
680    /// Ids chosen to bracket every interesting `u64` boundary: the "no value"
681    /// sentinel, the low/high extremes, the 32-bit rollover (managed hosts
682    /// love to truncate to i32/f64), the sign bit, and the RTTI id itself.
683    const BOUNDARY_IDS: [u64; 11] = [
684        0,
685        1,
686        2,
687        u32::MAX as u64,
688        u32::MAX as u64 + 1, // 32-bit rollover: a host truncating to u32 wraps to 0
689        0xDEAD_BEEF_CAFE_BABE,
690        1 << 63,
691        (1 << 53) + 1, // > f64 mantissa: a JS/Lua host would round this
692        u64::MAX - 1,
693        u64::MAX,
694        AZ_HOST_HANDLE_RTTI_ID,
695    ];
696
697    // ---------------------------------------------------------------------
698    // InvokerSlot — constructor / numeric set / getter
699    // ---------------------------------------------------------------------
700
701    #[test]
702    fn slot_new_and_default_start_unregistered() {
703        assert_eq!(InvokerSlot::new().get(), 0);
704        assert_eq!(InvokerSlot::default().get(), 0);
705    }
706
707    #[test]
708    fn slot_new_is_const_usable_in_static() {
709        // The whole point of `const fn new()` — `impl_managed_callback!`
710        // declares per-kind slots as `static`.
711        static SLOT: InvokerSlot = InvokerSlot::new();
712        assert_eq!(SLOT.get(), 0);
713        SLOT.set(0x1234);
714        assert_eq!(SLOT.get(), 0x1234);
715        SLOT.set(0); // leave the process-global-shaped static clean
716    }
717
718    #[test]
719    fn slot_set_get_roundtrips_at_usize_boundaries() {
720        let slot = InvokerSlot::new();
721        for ptr in [
722            0usize,
723            1,
724            2,
725            usize::MAX,
726            usize::MAX - 1,
727            usize::MAX / 2,
728            1usize << (usize::BITS - 1), // sign bit, if reinterpreted as isize
729            usize::try_from(u32::MAX).unwrap(),
730            0xDEAD_BEEF,
731        ] {
732            slot.set(ptr);
733            assert_eq!(slot.get(), ptr, "set/get must round-trip {ptr:#x} exactly");
734        }
735    }
736
737    #[test]
738    fn slot_set_is_last_write_wins_and_zero_clears() {
739        let slot = InvokerSlot::new();
740        slot.set(usize::MAX);
741        slot.set(0x42);
742        assert_eq!(slot.get(), 0x42);
743        // `0` is the "unregistered" sentinel — setting it back must actually
744        // un-register, not be treated as a no-op.
745        slot.set(0);
746        assert_eq!(slot.get(), 0);
747    }
748
749    #[test]
750    fn slot_get_is_idempotent() {
751        // `get` is a load, not a take: reading must not clear the slot.
752        let slot = InvokerSlot::new();
753        slot.set(0xABCD);
754        assert_eq!(slot.get(), 0xABCD);
755        assert_eq!(slot.get(), 0xABCD);
756        assert_eq!(slot.get(), 0xABCD);
757    }
758
759    #[test]
760    fn slot_concurrent_writes_never_tear() {
761        // The slot is read on every callback fire while a host may be swapping
762        // invokers. A torn read would transmute into a wild fn pointer, so
763        // assert every observed value is one that was actually written.
764        let slot = InvokerSlot::new();
765        let written: [usize; 4] = [0, 1, usize::MAX, 1usize << (usize::BITS - 1)];
766        let slot_ref = &slot;
767        std::thread::scope(|s| {
768            for &w in &written {
769                // `move` copies `w`/`written` (both Copy) and the &-borrow of
770                // `slot`; a borrowing closure would capture the loop-local `w`,
771                // which does not outlive the scope.
772                s.spawn(move || {
773                    for _ in 0..200 {
774                        slot_ref.set(w);
775                        let seen = slot_ref.get();
776                        assert!(
777                            written.contains(&seen),
778                            "torn/garbage value observed in slot: {seen:#x}"
779                        );
780                    }
781                });
782            }
783        });
784        assert!(written.contains(&slot.get()));
785    }
786
787    // ---------------------------------------------------------------------
788    // Layout / RTTI invariants the FFI contract depends on
789    // ---------------------------------------------------------------------
790
791    #[test]
792    fn rtti_id_matches_documented_constant() {
793        // Hosts hard-code this value in their bindings; changing it silently
794        // would make every previously-built host handle unrecognisable.
795        assert_eq!(AZ_HOST_HANDLE_RTTI_ID, 0xA20A_4853_5448_5F44);
796        assert_ne!(AZ_HOST_HANDLE_RTTI_ID, 0);
797    }
798
799    #[test]
800    fn host_handle_payload_layout_is_a_bare_u64() {
801        assert_eq!(size_of::<HostHandlePayload>(), size_of::<u64>());
802        assert_eq!(align_of::<HostHandlePayload>(), align_of::<u64>());
803    }
804
805    // ---------------------------------------------------------------------
806    // host_handle_to_refany / refany_to_host_handle — round-trip + rejection
807    // ---------------------------------------------------------------------
808
809    #[test]
810    fn host_handle_roundtrips_at_every_u64_boundary() {
811        let _g = lock_slots();
812        clear_all_slots(); // no releaser: these RefAnys drop into a no-op
813        for id in BOUNDARY_IDS {
814            let refany = host_handle_to_refany(id);
815            assert_eq!(
816                refany_to_host_handle(&refany),
817                Some(id),
818                "encode/decode must be lossless for id {id:#x}"
819            );
820        }
821    }
822
823    #[test]
824    fn host_handle_refany_carries_the_expected_rtti_metadata() {
825        let _g = lock_slots();
826        clear_all_slots();
827        let refany = host_handle_to_refany(9);
828        assert!(refany.is_type(AZ_HOST_HANDLE_RTTI_ID));
829        assert_eq!(refany.get_type_id(), AZ_HOST_HANDLE_RTTI_ID);
830        assert_eq!(refany.get_type_name().as_str(), "AzHostHandle");
831        assert_eq!(refany.get_data_len(), size_of::<HostHandlePayload>());
832        assert_eq!(refany.get_ref_count(), 1);
833        assert!(!refany.get_data_ptr().is_null());
834    }
835
836    #[test]
837    fn host_handle_id_survives_cloning() {
838        let _g = lock_slots();
839        clear_all_slots();
840        let refany = host_handle_to_refany(u64::MAX);
841        let clone = refany.clone();
842        assert_eq!(refany.get_ref_count(), 2);
843        assert_eq!(refany_to_host_handle(&clone), Some(u64::MAX));
844        assert_eq!(refany_to_host_handle(&refany), Some(u64::MAX));
845    }
846
847    #[test]
848    fn refany_to_host_handle_rejects_foreign_refanys() {
849        // A stray ctx must decode as None (-> thunk returns its default),
850        // never as random bytes reinterpreted as an id.
851        assert_eq!(refany_to_host_handle(&RefAny::new(0u64)), None);
852        assert_eq!(refany_to_host_handle(&RefAny::new(u64::MAX)), None);
853        assert_eq!(refany_to_host_handle(&RefAny::new(())), None);
854        assert_eq!(refany_to_host_handle(&RefAny::new([0xFFu8; 64])), None);
855        // Same *payload type*, but built through RefAny::new -> TypeId-derived
856        // id, not the host RTTI id. Layout-compatible but must still be
857        // rejected: the guard is the id, not the shape.
858        let same_shape = RefAny::new(HostHandlePayload { id: 0x1111 });
859        assert_ne!(same_shape.get_type_id(), AZ_HOST_HANDLE_RTTI_ID);
860        assert_eq!(refany_to_host_handle(&same_shape), None);
861    }
862
863    extern "C" fn noop_destructor(_ptr: *mut c_void) {}
864
865    #[test]
866    fn refany_to_host_handle_trusts_the_rtti_id_alone() {
867        // Pins the documented hazard on AZ_HOST_HANDLE_RTTI_ID: a host that
868        // reuses the id for its own (layout-compatible) payload gets its bytes
869        // read back as a handle. If this ever starts returning None, the guard
870        // grew a second check and the doc comment needs updating.
871        let _g = lock_slots();
872        clear_all_slots();
873        let payload = HostHandlePayload {
874            id: 0x1234_5678_9ABC_DEF0,
875        };
876        let spoofed = RefAny::new_c(
877            (&raw const payload).cast::<c_void>(),
878            size_of::<HostHandlePayload>(),
879            align_of::<HostHandlePayload>(),
880            AZ_HOST_HANDLE_RTTI_ID,
881            "NotAHostHandle".into(),
882            noop_destructor,
883            0,
884            0,
885        );
886        assert_eq!(refany_to_host_handle(&spoofed), Some(0x1234_5678_9ABC_DEF0));
887    }
888
889    // ---------------------------------------------------------------------
890    // C-ABI surface: AzRefAny_newHostHandle / AzRefAny_getHostHandle
891    // ---------------------------------------------------------------------
892
893    #[test]
894    fn c_abi_new_and_get_host_handle_roundtrip() {
895        let _g = lock_slots();
896        clear_all_slots();
897        for id in BOUNDARY_IDS {
898            let refany = AzRefAny_newHostHandle(id);
899            assert_eq!(refany_to_host_handle(&refany), Some(id));
900            assert_eq!(AzRefAny_getHostHandle(&raw const refany), id);
901        }
902    }
903
904    #[test]
905    fn c_abi_get_host_handle_null_returns_zero() {
906        assert_eq!(AzRefAny_getHostHandle(core::ptr::null()), 0);
907    }
908
909    #[test]
910    fn c_abi_get_host_handle_foreign_refany_returns_zero() {
911        let foreign = RefAny::new(0xDEAD_BEEF_u64);
912        assert_eq!(AzRefAny_getHostHandle(&raw const foreign), 0);
913    }
914
915    #[test]
916    fn c_abi_get_host_handle_cannot_distinguish_id_zero_from_failure() {
917        // Documented contract: `0` is reserved as "no value", so a host whose
918        // id allocator starts at 0 gets an unfixable ambiguity across the C
919        // ABI. Assert the ambiguity exists (so nobody "fixes" getHostHandle
920        // without also fixing the bindings) AND that the Rust-side accessor
921        // stays lossless.
922        let _g = lock_slots();
923        clear_all_slots();
924        let zero_handle = AzRefAny_newHostHandle(0);
925        assert_eq!(AzRefAny_getHostHandle(&raw const zero_handle), 0);
926        assert_eq!(AzRefAny_getHostHandle(core::ptr::null()), 0);
927        // Rust callers can still tell the two apart:
928        assert_eq!(refany_to_host_handle(&zero_handle), Some(0));
929    }
930
931    // ---------------------------------------------------------------------
932    // Releaser registration + destructor firing
933    // ---------------------------------------------------------------------
934
935    static RELEASE_COUNT: AtomicUsize = AtomicUsize::new(0);
936    static RELEASED_ID: AtomicU64 = AtomicU64::new(0);
937
938    extern "C" fn counting_releaser(id: u64) {
939        RELEASED_ID.store(id, AtOrdering::SeqCst);
940        RELEASE_COUNT.fetch_add(1, AtOrdering::SeqCst);
941    }
942
943    static OTHER_RELEASE_COUNT: AtomicUsize = AtomicUsize::new(0);
944
945    extern "C" fn other_releaser(_id: u64) {
946        OTHER_RELEASE_COUNT.fetch_add(1, AtOrdering::SeqCst);
947    }
948
949    fn reset_release_recorder() {
950        RELEASE_COUNT.store(0, AtOrdering::SeqCst);
951        RELEASED_ID.store(0, AtOrdering::SeqCst);
952        OTHER_RELEASE_COUNT.store(0, AtOrdering::SeqCst);
953    }
954
955    #[test]
956    fn set_releaser_stores_the_fn_address_and_replaces_it() {
957        let _g = lock_slots();
958        clear_all_slots();
959        let expected: extern "C" fn(u64) = counting_releaser;
960        AzApp_setHostHandleReleaser(counting_releaser);
961        assert_eq!(HOST_HANDLE_RELEASER.get(), expected as usize);
962        assert_ne!(HOST_HANDLE_RELEASER.get(), 0);
963        // "subsequent registrations replace the previous slot"
964        let replacement: extern "C" fn(u64) = other_releaser;
965        AzApp_setHostHandleReleaser(other_releaser);
966        assert_eq!(HOST_HANDLE_RELEASER.get(), replacement as usize);
967        clear_all_slots();
968    }
969
970    #[test]
971    fn releaser_fires_exactly_once_on_the_last_drop() {
972        let _g = lock_slots();
973        clear_all_slots();
974        reset_release_recorder();
975        AzApp_setHostHandleReleaser(counting_releaser);
976
977        let refany = host_handle_to_refany(0xABC_DEF);
978        let clone_a = refany.clone();
979        let clone_b = refany.clone();
980
981        drop(clone_a);
982        drop(clone_b);
983        // Two of three refs gone — the host's table entry must still be alive.
984        assert_eq!(RELEASE_COUNT.load(AtOrdering::SeqCst), 0);
985
986        drop(refany);
987        assert_eq!(RELEASE_COUNT.load(AtOrdering::SeqCst), 1);
988        assert_eq!(RELEASED_ID.load(AtOrdering::SeqCst), 0xABC_DEF);
989
990        clear_all_slots();
991    }
992
993    #[test]
994    fn releaser_receives_boundary_ids_verbatim() {
995        let _g = lock_slots();
996        clear_all_slots();
997        reset_release_recorder();
998        AzApp_setHostHandleReleaser(counting_releaser);
999
1000        for (n, id) in BOUNDARY_IDS.into_iter().enumerate() {
1001            RELEASED_ID.store(0, AtOrdering::SeqCst);
1002            drop(host_handle_to_refany(id));
1003            assert_eq!(
1004                RELEASE_COUNT.load(AtOrdering::SeqCst),
1005                n + 1,
1006                "one release per dropped handle"
1007            );
1008            assert_eq!(
1009                RELEASED_ID.load(AtOrdering::SeqCst),
1010                id,
1011                "releaser must see id {id:#x} unmangled (no truncation/saturation)"
1012            );
1013        }
1014
1015        clear_all_slots();
1016    }
1017
1018    #[test]
1019    fn dropping_a_handle_with_no_releaser_registered_is_a_noop() {
1020        let _g = lock_slots();
1021        clear_all_slots();
1022        reset_release_recorder();
1023        // Slot is 0 ("host hasn't initialised yet") — the destructor must bail
1024        // rather than transmute 0 into a fn pointer and jump to it.
1025        drop(host_handle_to_refany(1));
1026        drop(host_handle_to_refany(u64::MAX));
1027        assert_eq!(RELEASE_COUNT.load(AtOrdering::SeqCst), 0);
1028        assert_eq!(HOST_HANDLE_RELEASER.get(), 0);
1029    }
1030
1031    #[test]
1032    fn re_registering_the_releaser_retires_the_old_one() {
1033        let _g = lock_slots();
1034        clear_all_slots();
1035        reset_release_recorder();
1036        AzApp_setHostHandleReleaser(counting_releaser);
1037        let live = host_handle_to_refany(7);
1038        // Host swaps releasers (e.g. module reload) while a handle is alive:
1039        // the *current* slot wins at drop time, not the one in force at
1040        // construction.
1041        AzApp_setHostHandleReleaser(other_releaser);
1042        drop(live);
1043        assert_eq!(RELEASE_COUNT.load(AtOrdering::SeqCst), 0);
1044        assert_eq!(OTHER_RELEASE_COUNT.load(AtOrdering::SeqCst), 1);
1045        clear_all_slots();
1046    }
1047
1048    #[test]
1049    fn destructor_on_null_payload_is_a_noop_even_with_a_releaser() {
1050        let _g = lock_slots();
1051        clear_all_slots();
1052        reset_release_recorder();
1053        AzApp_setHostHandleReleaser(counting_releaser);
1054        host_handle_destructor(core::ptr::null_mut());
1055        assert_eq!(
1056            RELEASE_COUNT.load(AtOrdering::SeqCst),
1057            0,
1058            "a null payload must not be deref'd, nor reported as id 0"
1059        );
1060        clear_all_slots();
1061    }
1062
1063    // ---------------------------------------------------------------------
1064    // A fake callback kind, so the generic/per-kind dispatch paths in
1065    // `impl_managed_callback!` can be driven end-to-end.
1066    // ---------------------------------------------------------------------
1067
1068    #[repr(C)]
1069    #[derive(Debug, PartialEq, Eq, Clone, Copy)]
1070    struct AutoRet(u32);
1071
1072    const DEFAULT_RET: AutoRet = AutoRet(0xDEAD);
1073
1074    #[repr(C)]
1075    #[derive(Debug)]
1076    struct AutoInfo {
1077        ctx: OptionRefAny,
1078    }
1079
1080    impl AutoInfo {
1081        fn get_ctx(&self) -> OptionRefAny {
1082            self.ctx.clone()
1083        }
1084    }
1085
1086    #[repr(C)]
1087    #[derive(Debug)]
1088    struct AutoWrapper {
1089        cb: extern "C" fn(RefAny, AutoInfo) -> AutoRet,
1090        ctx: OptionRefAny,
1091    }
1092
1093    crate::impl_managed_callback! {
1094        wrapper:        AutoWrapper,
1095        info_ty:        AutoInfo,
1096        return_ty:      AutoRet,
1097        default_ret:    DEFAULT_RET,
1098        invoker_static: AZ_AUTOTEST_INVOKER,
1099        invoker_ty:     AzAutotestInvoker,
1100        thunk_fn:       az_autotest_thunk,
1101        setter_fn:      az_autotest_set_invoker,
1102        from_handle_fn: az_autotest_from_handle,
1103    }
1104
1105    // What the fake host invokers saw. Recorded into atomics rather than
1106    // asserted in-place: these fns are `extern "C"`, so a failing assert!
1107    // inside one would abort the test binary instead of failing the test.
1108    static GENERIC_CALLS: AtomicUsize = AtomicUsize::new(0);
1109    static GENERIC_HANDLE: AtomicU64 = AtomicU64::new(0);
1110    static GENERIC_NARGS: AtomicUsize = AtomicUsize::new(0);
1111    static GENERIC_KIND_OK: AtomicBool = AtomicBool::new(false);
1112    static GENERIC_ARG0_ID: AtomicU64 = AtomicU64::new(0);
1113    static PERKIND_CALLS: AtomicUsize = AtomicUsize::new(0);
1114    static PERKIND_HANDLE: AtomicU64 = AtomicU64::new(0);
1115
1116    fn reset_invoker_recorders() {
1117        GENERIC_CALLS.store(0, AtOrdering::SeqCst);
1118        GENERIC_HANDLE.store(0, AtOrdering::SeqCst);
1119        GENERIC_NARGS.store(0, AtOrdering::SeqCst);
1120        GENERIC_KIND_OK.store(false, AtOrdering::SeqCst);
1121        GENERIC_ARG0_ID.store(0, AtOrdering::SeqCst);
1122        PERKIND_CALLS.store(0, AtOrdering::SeqCst);
1123        PERKIND_HANDLE.store(0, AtOrdering::SeqCst);
1124    }
1125
1126    /// Stand-in for a host's libffi generic-invoker closure.
1127    extern "C" fn recording_generic(
1128        handle: u64,
1129        kind: *const core::ffi::c_char,
1130        args: *const *const c_void,
1131        n_args: usize,
1132        ret: *mut c_void,
1133    ) {
1134        GENERIC_CALLS.fetch_add(1, AtOrdering::SeqCst);
1135        GENERIC_HANDLE.store(handle, AtOrdering::SeqCst);
1136        GENERIC_NARGS.store(n_args, AtOrdering::SeqCst);
1137
1138        // The kind string must be a NUL-terminated "AutoWrapper" — that's what
1139        // the host's dispatch table keys on. Also gates the `ret` write below:
1140        // another kind's thunk falling back here would have a differently-sized
1141        // out-slot.
1142        let kind_ok = !kind.is_null()
1143            && unsafe { CStr::from_ptr(kind) }.to_str() == Ok("AutoWrapper");
1144        GENERIC_KIND_OK.store(kind_ok, AtOrdering::SeqCst);
1145
1146        // args[0] is the by-value `data: RefAny` frame slot, args[1] the info.
1147        if !args.is_null() && n_args == 2 {
1148            let arg0 = unsafe { *args };
1149            if !arg0.is_null() {
1150                let data = unsafe { &*(arg0.cast::<RefAny>()) };
1151                GENERIC_ARG0_ID.store(
1152                    refany_to_host_handle(data).unwrap_or(0),
1153                    AtOrdering::SeqCst,
1154                );
1155            }
1156        }
1157
1158        if kind_ok && !ret.is_null() {
1159            unsafe { ret.cast::<AutoRet>().write(AutoRet(0x2222)) };
1160        }
1161    }
1162
1163    /// Stand-in for a host's per-kind libffi closure.
1164    extern "C" fn recording_perkind(
1165        handle: u64,
1166        _data: *const RefAny,
1167        _info: *const AutoInfo,
1168        out: *mut AutoRet,
1169    ) {
1170        PERKIND_CALLS.fetch_add(1, AtOrdering::SeqCst);
1171        PERKIND_HANDLE.store(handle, AtOrdering::SeqCst);
1172        if !out.is_null() {
1173            unsafe { out.write(AutoRet(0x1111)) };
1174        }
1175    }
1176
1177    /// A buggy host invoker: never writes the out-pointer.
1178    extern "C" fn silent_perkind(
1179        _handle: u64,
1180        _data: *const RefAny,
1181        _info: *const AutoInfo,
1182        _out: *mut AutoRet,
1183    ) {
1184        PERKIND_CALLS.fetch_add(1, AtOrdering::SeqCst);
1185    }
1186
1187    fn info_with_ctx(ctx: OptionRefAny) -> AutoInfo {
1188        AutoInfo { ctx }
1189    }
1190
1191    #[test]
1192    fn set_generic_invoker_stores_the_fn_address_and_replaces_it() {
1193        let _g = lock_slots();
1194        clear_all_slots();
1195        let expected: AzGenericInvoker = recording_generic;
1196        AzApp_setGenericInvoker(recording_generic);
1197        assert_eq!(GENERIC_INVOKER.get(), expected as usize);
1198        assert_ne!(GENERIC_INVOKER.get(), 0);
1199        AzApp_setGenericInvoker(recording_generic); // idempotent re-register
1200        assert_eq!(GENERIC_INVOKER.get(), expected as usize);
1201        clear_all_slots();
1202    }
1203
1204    #[test]
1205    fn create_from_host_handle_wires_the_thunk_and_ctx() {
1206        let _g = lock_slots();
1207        clear_all_slots();
1208        for id in BOUNDARY_IDS {
1209            let wrapper = AutoWrapper::create_from_host_handle(id);
1210            let expected: extern "C" fn(RefAny, AutoInfo) -> AutoRet = az_autotest_thunk;
1211            assert_eq!(wrapper.cb as usize, expected as usize);
1212            match &wrapper.ctx {
1213                OptionRefAny::Some(refany) => {
1214                    assert_eq!(refany_to_host_handle(refany), Some(id));
1215                }
1216                OptionRefAny::None => panic!("ctx must carry the host handle for id {id:#x}"),
1217            }
1218            // The C-ABI export must produce the identical wrapper.
1219            let from_c = az_autotest_from_handle(id);
1220            assert_eq!(from_c.cb as usize, expected as usize);
1221        }
1222    }
1223
1224    #[test]
1225    fn thunk_returns_default_when_ctx_is_none() {
1226        let _g = lock_slots();
1227        clear_all_slots();
1228        reset_invoker_recorders();
1229        AzApp_setGenericInvoker(recording_generic);
1230        az_autotest_set_invoker(recording_perkind);
1231
1232        // Framework invoked the typedef directly, without a host ctx: neither
1233        // invoker may fire (there is no handle to dispatch on).
1234        let out = az_autotest_thunk(RefAny::new(1u32), info_with_ctx(OptionRefAny::None));
1235        assert_eq!(out, DEFAULT_RET);
1236        assert_eq!(GENERIC_CALLS.load(AtOrdering::SeqCst), 0);
1237        assert_eq!(PERKIND_CALLS.load(AtOrdering::SeqCst), 0);
1238        clear_all_slots();
1239    }
1240
1241    #[test]
1242    fn thunk_returns_default_when_ctx_is_not_a_host_handle() {
1243        let _g = lock_slots();
1244        clear_all_slots();
1245        reset_invoker_recorders();
1246        AzApp_setGenericInvoker(recording_generic);
1247        az_autotest_set_invoker(recording_perkind);
1248
1249        // A foreign ctx must NOT be reinterpreted as a handle — that would
1250        // dispatch the host on a garbage id.
1251        let ctx = OptionRefAny::Some(RefAny::new(0xFFFF_FFFF_FFFF_FFFF_u64));
1252        let out = az_autotest_thunk(RefAny::new(1u32), info_with_ctx(ctx));
1253        assert_eq!(out, DEFAULT_RET);
1254        assert_eq!(GENERIC_CALLS.load(AtOrdering::SeqCst), 0);
1255        assert_eq!(PERKIND_CALLS.load(AtOrdering::SeqCst), 0);
1256        clear_all_slots();
1257    }
1258
1259    #[test]
1260    fn thunk_returns_default_when_nothing_is_registered() {
1261        let _g = lock_slots();
1262        clear_all_slots();
1263        // Valid host handle, but both slots are 0 — the thunk must bail with
1264        // the default instead of transmuting 0 into a fn pointer.
1265        let ctx = OptionRefAny::Some(host_handle_to_refany(5));
1266        let out = az_autotest_thunk(RefAny::new(1u32), info_with_ctx(ctx));
1267        assert_eq!(out, DEFAULT_RET);
1268    }
1269
1270    #[test]
1271    fn thunk_falls_back_to_the_generic_invoker() {
1272        let _g = lock_slots();
1273        clear_all_slots();
1274        reset_invoker_recorders();
1275        AzApp_setGenericInvoker(recording_generic);
1276        // AZ_AUTOTEST_INVOKER deliberately left at 0.
1277
1278        let data = host_handle_to_refany(0xDA7A);
1279        let ctx = OptionRefAny::Some(host_handle_to_refany(0xC7_C7_C7));
1280        let out = az_autotest_thunk(data, info_with_ctx(ctx));
1281
1282        assert_eq!(GENERIC_CALLS.load(AtOrdering::SeqCst), 1);
1283        assert_eq!(GENERIC_HANDLE.load(AtOrdering::SeqCst), 0xC7_C7_C7);
1284        assert!(GENERIC_KIND_OK.load(AtOrdering::SeqCst), "kind must be \"AutoWrapper\\0\"");
1285        assert_eq!(GENERIC_NARGS.load(AtOrdering::SeqCst), 2, "data + info");
1286        // args[] must be in *declared* order: data first, then info.
1287        assert_eq!(GENERIC_ARG0_ID.load(AtOrdering::SeqCst), 0xDA7A);
1288        // ...and the host's out-pointer write must be what the thunk returns.
1289        assert_eq!(out, AutoRet(0x2222));
1290        clear_all_slots();
1291    }
1292
1293    #[test]
1294    fn thunk_prefers_the_per_kind_invoker_over_the_generic_one() {
1295        let _g = lock_slots();
1296        clear_all_slots();
1297        reset_invoker_recorders();
1298        AzApp_setGenericInvoker(recording_generic);
1299        az_autotest_set_invoker(recording_perkind);
1300
1301        let ctx = OptionRefAny::Some(host_handle_to_refany(0x99));
1302        let out = az_autotest_thunk(RefAny::new(1u32), info_with_ctx(ctx));
1303
1304        assert_eq!(out, AutoRet(0x1111));
1305        assert_eq!(PERKIND_CALLS.load(AtOrdering::SeqCst), 1);
1306        assert_eq!(PERKIND_HANDLE.load(AtOrdering::SeqCst), 0x99);
1307        assert_eq!(
1308            GENERIC_CALLS.load(AtOrdering::SeqCst),
1309            0,
1310            "generic is a fallback only — it must not also fire"
1311        );
1312        clear_all_slots();
1313    }
1314
1315    #[test]
1316    fn thunk_returns_default_when_the_host_ignores_the_out_pointer() {
1317        let _g = lock_slots();
1318        clear_all_slots();
1319        reset_invoker_recorders();
1320        az_autotest_set_invoker(silent_perkind);
1321
1322        // A buggy host invoker that never writes `out` must leave us with the
1323        // pre-filled default, not uninitialised memory.
1324        let ctx = OptionRefAny::Some(host_handle_to_refany(3));
1325        let out = az_autotest_thunk(RefAny::new(1u32), info_with_ctx(ctx));
1326        assert_eq!(PERKIND_CALLS.load(AtOrdering::SeqCst), 1);
1327        assert_eq!(out, DEFAULT_RET);
1328        clear_all_slots();
1329    }
1330
1331    #[test]
1332    fn thunk_dispatches_boundary_handles_without_truncation() {
1333        let _g = lock_slots();
1334        clear_all_slots();
1335        reset_invoker_recorders();
1336        az_autotest_set_invoker(recording_perkind);
1337
1338        for id in BOUNDARY_IDS {
1339            let ctx = OptionRefAny::Some(host_handle_to_refany(id));
1340            let out = az_autotest_thunk(RefAny::new(1u32), info_with_ctx(ctx));
1341            assert_eq!(out, AutoRet(0x1111));
1342            assert_eq!(
1343                PERKIND_HANDLE.load(AtOrdering::SeqCst),
1344                id,
1345                "handle {id:#x} must reach the host invoker unmangled"
1346            );
1347        }
1348        clear_all_slots();
1349    }
1350}