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

            
43
use core::ffi::c_void;
44
use core::sync::atomic::{AtomicUsize, Ordering};
45

            
46
use azul_css::AzString;
47

            
48
use 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`.
57
pub 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)]
64
pub 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)]
76
pub struct InvokerSlot {
77
    fn_ptr: AtomicUsize,
78
}
79

            
80
impl 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
6
    #[must_use] pub const fn new() -> Self {
84
6
        Self {
85
6
            fn_ptr: AtomicUsize::new(0),
86
6
        }
87
6
    }
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
1012
    pub fn set(&self, ptr: usize) {
95
1012
        self.fn_ptr.store(ptr, Ordering::SeqCst);
96
1012
    }
97

            
98
    /// Read the current function pointer; `0` if unregistered.
99
1019
    pub fn get(&self) -> usize {
100
1019
        self.fn_ptr.load(Ordering::SeqCst)
101
1019
    }
102
}
103

            
104
impl Default for InvokerSlot {
105
1
    fn default() -> Self {
106
1
        Self::new()
107
1
    }
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.
113
pub 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"`).
142
pub 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.
146
pub 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]
162
25
pub extern "C" fn AzApp_setGenericInvoker(invoker: AzGenericInvoker) {
163
25
    GENERIC_INVOKER.set(invoker as usize);
164
25
}
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]
180
65
pub extern "C" fn AzApp_setHostHandleReleaser(releaser: extern "C" fn(u64)) {
181
65
    HOST_HANDLE_RELEASER.set(releaser as usize);
182
65
}
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.
189
140
extern "C" fn host_handle_destructor(ptr: *mut c_void) {
190
140
    if ptr.is_null() {
191
2
        return;
192
138
    }
193
    // SAFETY: the destructor only runs for RefAnys built via
194
    // host_handle_to_refany, whose payload type is HostHandlePayload.
195
138
    let payload = unsafe { &*(ptr as *const HostHandlePayload) };
196

            
197
138
    let releaser_addr = HOST_HANDLE_RELEASER.get();
198
138
    if releaser_addr == 0 {
199
67
        return;
200
71
    }
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
71
    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
71
        drop(std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| releaser(payload.id))));
212
    }
213
    #[cfg(not(feature = "std"))]
214
    {
215
        releaser(payload.id);
216
    }
217
140
}
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.
225
137
pub fn host_handle_to_refany(id: u64) -> RefAny {
226
137
    let payload = HostHandlePayload { id };
227
137
    let type_name: AzString = "AzHostHandle".into();
228
137
    RefAny::new_c(
229
137
        &raw const payload as *const c_void,
230
137
        size_of::<HostHandlePayload>(),
231
137
        align_of::<HostHandlePayload>(),
232
        AZ_HOST_HANDLE_RTTI_ID,
233
137
        type_name,
234
137
        host_handle_destructor,
235
        0,
236
        0,
237
    )
238
137
}
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
130
#[must_use] pub fn refany_to_host_handle(refany: &RefAny) -> Option<u64> {
247
130
    if !refany.is_type(AZ_HOST_HANDLE_RTTI_ID) {
248
26
        return None;
249
104
    }
250
104
    let ptr = refany.get_data_ptr() as *const HostHandlePayload;
251
104
    if ptr.is_null() {
252
        return None;
253
104
    }
254
    // SAFETY: type-id check above guarantees the payload was a HostHandlePayload.
255
104
    Some(unsafe { (*ptr).id })
256
130
}
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]
268
12
pub extern "C" fn AzRefAny_newHostHandle(id: u64) -> RefAny {
269
12
    host_handle_to_refany(id)
270
12
}
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.
281
15
pub extern "C" fn AzRefAny_getHostHandle(refany: *const RefAny) -> u64 {
282
15
    if refany.is_null() {
283
2
        return 0;
284
13
    }
285
    // SAFETY: caller's responsibility per `*const` signature.
286
13
    let r = unsafe { &*refany };
287
13
    refany_to_host_handle(r).unwrap_or(0)
288
15
}
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]
317
macro_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
5
        pub extern "C" fn $setter_fn(invoker: $invoker_ty) {
389
5
            $invoker_static.set(invoker as usize);
390
5
        }
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
18
        extern "C" fn $thunk_fn(
397
18
            data: $crate::refany::RefAny,
398
18
            info: $info_ty,
399
18
            $( $extra_name : $extra_ty , )*
400
18
        ) -> $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
18
            let body = move || -> $ret {
415
18
                let ctx = info.get_ctx();
416
18
                let handle = match ctx {
417
17
                    $crate::refany::OptionRefAny::Some(ref refany) => {
418
17
                        match $crate::host_invoker::refany_to_host_handle(refany) {
419
15
                            Some(id) => id,
420
                            None => return $default,
421
                        }
422
                    }
423
                    _ => return $default,
424
                };
425
15
                let invoker_addr = $invoker_static.get();
426
15
                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
2
                    let generic_addr = $crate::host_invoker::GENERIC_INVOKER.get();
434
2
                    if generic_addr == 0 {
435
                        return $default;
436
1
                    }
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
1
                    let generic: $crate::host_invoker::AzGenericInvoker =
441
1
                        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
1
                    let args = [
449
1
                        &raw const data as *const core::ffi::c_void,
450
1
                        &raw const info as *const core::ffi::c_void,
451
1
                        $( & $extra_name as *const _ as *const core::ffi::c_void , )*
452
1
                    ];
453

            
454
1
                    let mut out: $ret = $default;
455
1
                    generic(
456
1
                        handle,
457
1
                        KIND_STR.as_ptr() as *const core::ffi::c_char,
458
1
                        args.as_ptr(),
459
1
                        args.len(),
460
1
                        &raw mut out as *mut core::ffi::c_void,
461
1
                    );
462
1
                    return out;
463
13
                }
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
13
                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
13
                let mut out: $ret = $default;
473
13
                invoker(
474
13
                    handle,
475
13
                    &raw const data,
476
13
                    &raw const info,
477
13
                    $( & $extra_name as *const $extra_ty , )*
478
13
                    &raw mut out,
479
13
                );
480
13
                out
481
18
            };
482

            
483
            #[cfg(feature = "std")]
484
            {
485
18
                std::panic::catch_unwind(std::panic::AssertUnwindSafe(body))
486
18
                    .unwrap_or($default)
487
            }
488
            #[cfg(not(feature = "std"))]
489
            {
490
                body()
491
            }
492
18
        }
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
41
            #[must_use] pub fn create_from_host_handle(handle: u64) -> Self {
501
41
                Self {
502
41
                    cb: $thunk_fn,
503
41
                    ctx: $crate::refany::OptionRefAny::Some(
504
41
                        $crate::host_invoker::host_handle_to_refany(handle),
505
41
                    ),
506
41
                }
507
41
            }
508
        }
509

            
510
        /// C-ABI export wrapping `<Wrapper>::create_from_host_handle`.
511
        #[no_mangle]
512
11
        pub extern "C" fn $from_handle_fn(handle: u64) -> $wrapper {
513
11
            <$wrapper>::create_from_host_handle(handle)
514
11
        }
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
529
mod 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
1
    extern "C" fn recording_releaser(id: u64) {
547
1
        LAST_RELEASED.store(id, AtOrdering::SeqCst);
548
1
    }
549

            
550
    #[test]
551
1
    fn destructor_transmutes_and_invokes_releaser() {
552
1
        let _g = TEST_LOCK.lock().unwrap();
553
1
        LAST_RELEASED.store(0, AtOrdering::SeqCst);
554
        // Register via the real C-ABI setter (exercises `releaser as usize`).
555
1
        AzApp_setHostHandleReleaser(recording_releaser);
556
1
        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
1
        host_handle_destructor((&raw mut payload).cast::<c_void>());
561
1
        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
1
        HOST_HANDLE_RELEASER.set(0);
564
1
    }
565

            
566
    #[test]
567
1
    fn destructor_null_ptr_is_noop() {
568
        // Returns before touching any global; no lock needed.
569
1
        host_handle_destructor(core::ptr::null_mut());
570
1
    }
571

            
572
    #[test]
573
1
    fn host_handle_roundtrips_through_refany() {
574
1
        let _g = TEST_LOCK.lock().unwrap();
575
        // Ensure the round-trip RefAny's drop fires no releaser.
576
1
        HOST_HANDLE_RELEASER.set(0);
577
1
        let refany = host_handle_to_refany(0x55);
578
        // Exercises the type-id-guarded raw-ptr deref in refany_to_host_handle.
579
1
        assert_eq!(refany_to_host_handle(&refany), Some(0x55));
580
1
    }
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
1
        fn get_ctx(&self) -> crate::refany::OptionRefAny {
593
1
            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
1
    fn thunk_contains_panic_and_returns_default() {
618
1
        let _g = TEST_LOCK.lock().unwrap();
619
1
        HOST_HANDLE_RELEASER.set(0);
620
1
        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
1
        let out = az_test_fake_thunk(data, FakeInfo);
624
1
        assert_eq!(out, FakeRet(99));
625
1
    }
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
659
mod 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

            
        for (n, id) in BOUNDARY_IDS.into_iter().enumerate() {
            RELEASED_ID.store(0, AtOrdering::SeqCst);
            drop(host_handle_to_refany(id));
            assert_eq!(
                RELEASE_COUNT.load(AtOrdering::SeqCst),
                n + 1,
                "one release per dropped handle"
            );
            assert_eq!(
                RELEASED_ID.load(AtOrdering::SeqCst),
                id,
                "releaser must see id {id:#x} unmangled (no truncation/saturation)"
            );
        }
        clear_all_slots();
    }
    #[test]
    fn dropping_a_handle_with_no_releaser_registered_is_a_noop() {
        let _g = lock_slots();
        clear_all_slots();
        reset_release_recorder();
        // Slot is 0 ("host hasn't initialised yet") — the destructor must bail
        // rather than transmute 0 into a fn pointer and jump to it.
        drop(host_handle_to_refany(1));
        drop(host_handle_to_refany(u64::MAX));
        assert_eq!(RELEASE_COUNT.load(AtOrdering::SeqCst), 0);
        assert_eq!(HOST_HANDLE_RELEASER.get(), 0);
    }
    #[test]
    fn re_registering_the_releaser_retires_the_old_one() {
        let _g = lock_slots();
        clear_all_slots();
        reset_release_recorder();
        AzApp_setHostHandleReleaser(counting_releaser);
        let live = host_handle_to_refany(7);
        // Host swaps releasers (e.g. module reload) while a handle is alive:
        // the *current* slot wins at drop time, not the one in force at
        // construction.
        AzApp_setHostHandleReleaser(other_releaser);
        drop(live);
        assert_eq!(RELEASE_COUNT.load(AtOrdering::SeqCst), 0);
        assert_eq!(OTHER_RELEASE_COUNT.load(AtOrdering::SeqCst), 1);
        clear_all_slots();
    }
    #[test]
    fn destructor_on_null_payload_is_a_noop_even_with_a_releaser() {
        let _g = lock_slots();
        clear_all_slots();
        reset_release_recorder();
        AzApp_setHostHandleReleaser(counting_releaser);
        host_handle_destructor(core::ptr::null_mut());
        assert_eq!(
            RELEASE_COUNT.load(AtOrdering::SeqCst),
            0,
            "a null payload must not be deref'd, nor reported as id 0"
        );
        clear_all_slots();
    }
    // ---------------------------------------------------------------------
    // A fake callback kind, so the generic/per-kind dispatch paths in
    // `impl_managed_callback!` can be driven end-to-end.
    // ---------------------------------------------------------------------
    #[repr(C)]
    #[derive(Debug, PartialEq, Eq, Clone, Copy)]
    struct AutoRet(u32);
    const DEFAULT_RET: AutoRet = AutoRet(0xDEAD);
    #[repr(C)]
    #[derive(Debug)]
    struct AutoInfo {
        ctx: OptionRefAny,
    }
    impl AutoInfo {
        fn get_ctx(&self) -> OptionRefAny {
            self.ctx.clone()
        }
    }
    #[repr(C)]
    #[derive(Debug)]
    struct AutoWrapper {
        cb: extern "C" fn(RefAny, AutoInfo) -> AutoRet,
        ctx: OptionRefAny,
    }
    crate::impl_managed_callback! {
        wrapper:        AutoWrapper,
        info_ty:        AutoInfo,
        return_ty:      AutoRet,
        default_ret:    DEFAULT_RET,
        invoker_static: AZ_AUTOTEST_INVOKER,
        invoker_ty:     AzAutotestInvoker,
        thunk_fn:       az_autotest_thunk,
        setter_fn:      az_autotest_set_invoker,
        from_handle_fn: az_autotest_from_handle,
    }
    // What the fake host invokers saw. Recorded into atomics rather than
    // asserted in-place: these fns are `extern "C"`, so a failing assert!
    // inside one would abort the test binary instead of failing the test.
    static GENERIC_CALLS: AtomicUsize = AtomicUsize::new(0);
    static GENERIC_HANDLE: AtomicU64 = AtomicU64::new(0);
    static GENERIC_NARGS: AtomicUsize = AtomicUsize::new(0);
    static GENERIC_KIND_OK: AtomicBool = AtomicBool::new(false);
    static GENERIC_ARG0_ID: AtomicU64 = AtomicU64::new(0);
    static PERKIND_CALLS: AtomicUsize = AtomicUsize::new(0);
    static PERKIND_HANDLE: AtomicU64 = AtomicU64::new(0);
    fn reset_invoker_recorders() {
        GENERIC_CALLS.store(0, AtOrdering::SeqCst);
        GENERIC_HANDLE.store(0, AtOrdering::SeqCst);
        GENERIC_NARGS.store(0, AtOrdering::SeqCst);
        GENERIC_KIND_OK.store(false, AtOrdering::SeqCst);
        GENERIC_ARG0_ID.store(0, AtOrdering::SeqCst);
        PERKIND_CALLS.store(0, AtOrdering::SeqCst);
        PERKIND_HANDLE.store(0, AtOrdering::SeqCst);
    }
    /// Stand-in for a host's libffi generic-invoker closure.
    extern "C" fn recording_generic(
        handle: u64,
        kind: *const core::ffi::c_char,
        args: *const *const c_void,
        n_args: usize,
        ret: *mut c_void,
    ) {
        GENERIC_CALLS.fetch_add(1, AtOrdering::SeqCst);
        GENERIC_HANDLE.store(handle, AtOrdering::SeqCst);
        GENERIC_NARGS.store(n_args, AtOrdering::SeqCst);
        // The kind string must be a NUL-terminated "AutoWrapper" — that's what
        // the host's dispatch table keys on. Also gates the `ret` write below:
        // another kind's thunk falling back here would have a differently-sized
        // out-slot.
        let kind_ok = !kind.is_null()
            && unsafe { CStr::from_ptr(kind) }.to_str() == Ok("AutoWrapper");
        GENERIC_KIND_OK.store(kind_ok, AtOrdering::SeqCst);
        // args[0] is the by-value `data: RefAny` frame slot, args[1] the info.
        if !args.is_null() && n_args == 2 {
            let arg0 = unsafe { *args };
            if !arg0.is_null() {
                let data = unsafe { &*(arg0.cast::<RefAny>()) };
                GENERIC_ARG0_ID.store(
                    refany_to_host_handle(data).unwrap_or(0),
                    AtOrdering::SeqCst,
                );
            }
        }
        if kind_ok && !ret.is_null() {
            unsafe { ret.cast::<AutoRet>().write(AutoRet(0x2222)) };
        }
    }
    /// Stand-in for a host's per-kind libffi closure.
    extern "C" fn recording_perkind(
        handle: u64,
        _data: *const RefAny,
        _info: *const AutoInfo,
        out: *mut AutoRet,
    ) {
        PERKIND_CALLS.fetch_add(1, AtOrdering::SeqCst);
        PERKIND_HANDLE.store(handle, AtOrdering::SeqCst);
        if !out.is_null() {
            unsafe { out.write(AutoRet(0x1111)) };
        }
    }
    /// A buggy host invoker: never writes the out-pointer.
    extern "C" fn silent_perkind(
        _handle: u64,
        _data: *const RefAny,
        _info: *const AutoInfo,
        _out: *mut AutoRet,
    ) {
        PERKIND_CALLS.fetch_add(1, AtOrdering::SeqCst);
    }
    fn info_with_ctx(ctx: OptionRefAny) -> AutoInfo {
        AutoInfo { ctx }
    }
    #[test]
    fn set_generic_invoker_stores_the_fn_address_and_replaces_it() {
        let _g = lock_slots();
        clear_all_slots();
        let expected: AzGenericInvoker = recording_generic;
        AzApp_setGenericInvoker(recording_generic);
        assert_eq!(GENERIC_INVOKER.get(), expected as usize);
        assert_ne!(GENERIC_INVOKER.get(), 0);
        AzApp_setGenericInvoker(recording_generic); // idempotent re-register
        assert_eq!(GENERIC_INVOKER.get(), expected as usize);
        clear_all_slots();
    }
    #[test]
    fn create_from_host_handle_wires_the_thunk_and_ctx() {
        let _g = lock_slots();
        clear_all_slots();
        for id in BOUNDARY_IDS {
            let wrapper = AutoWrapper::create_from_host_handle(id);
            let expected: extern "C" fn(RefAny, AutoInfo) -> AutoRet = az_autotest_thunk;
            assert_eq!(wrapper.cb as usize, expected as usize);
            match &wrapper.ctx {
                OptionRefAny::Some(refany) => {
                    assert_eq!(refany_to_host_handle(refany), Some(id));
                }
                OptionRefAny::None => panic!("ctx must carry the host handle for id {id:#x}"),
            }
            // The C-ABI export must produce the identical wrapper.
            let from_c = az_autotest_from_handle(id);
            assert_eq!(from_c.cb as usize, expected as usize);
        }
    }
    #[test]
    fn thunk_returns_default_when_ctx_is_none() {
        let _g = lock_slots();
        clear_all_slots();
        reset_invoker_recorders();
        AzApp_setGenericInvoker(recording_generic);
        az_autotest_set_invoker(recording_perkind);
        // Framework invoked the typedef directly, without a host ctx: neither
        // invoker may fire (there is no handle to dispatch on).
        let out = az_autotest_thunk(RefAny::new(1u32), info_with_ctx(OptionRefAny::None));
        assert_eq!(out, DEFAULT_RET);
        assert_eq!(GENERIC_CALLS.load(AtOrdering::SeqCst), 0);
        assert_eq!(PERKIND_CALLS.load(AtOrdering::SeqCst), 0);
        clear_all_slots();
    }
    #[test]
    fn thunk_returns_default_when_ctx_is_not_a_host_handle() {
        let _g = lock_slots();
        clear_all_slots();
        reset_invoker_recorders();
        AzApp_setGenericInvoker(recording_generic);
        az_autotest_set_invoker(recording_perkind);
        // A foreign ctx must NOT be reinterpreted as a handle — that would
        // dispatch the host on a garbage id.
        let ctx = OptionRefAny::Some(RefAny::new(0xFFFF_FFFF_FFFF_FFFF_u64));
        let out = az_autotest_thunk(RefAny::new(1u32), info_with_ctx(ctx));
        assert_eq!(out, DEFAULT_RET);
        assert_eq!(GENERIC_CALLS.load(AtOrdering::SeqCst), 0);
        assert_eq!(PERKIND_CALLS.load(AtOrdering::SeqCst), 0);
        clear_all_slots();
    }
    #[test]
    fn thunk_returns_default_when_nothing_is_registered() {
        let _g = lock_slots();
        clear_all_slots();
        // Valid host handle, but both slots are 0 — the thunk must bail with
        // the default instead of transmuting 0 into a fn pointer.
        let ctx = OptionRefAny::Some(host_handle_to_refany(5));
        let out = az_autotest_thunk(RefAny::new(1u32), info_with_ctx(ctx));
        assert_eq!(out, DEFAULT_RET);
    }
    #[test]
    fn thunk_falls_back_to_the_generic_invoker() {
        let _g = lock_slots();
        clear_all_slots();
        reset_invoker_recorders();
        AzApp_setGenericInvoker(recording_generic);
        // AZ_AUTOTEST_INVOKER deliberately left at 0.
        let data = host_handle_to_refany(0xDA7A);
        let ctx = OptionRefAny::Some(host_handle_to_refany(0xC7_C7_C7));
        let out = az_autotest_thunk(data, info_with_ctx(ctx));
        assert_eq!(GENERIC_CALLS.load(AtOrdering::SeqCst), 1);
        assert_eq!(GENERIC_HANDLE.load(AtOrdering::SeqCst), 0xC7_C7_C7);
        assert!(GENERIC_KIND_OK.load(AtOrdering::SeqCst), "kind must be \"AutoWrapper\\0\"");
        assert_eq!(GENERIC_NARGS.load(AtOrdering::SeqCst), 2, "data + info");
        // args[] must be in *declared* order: data first, then info.
        assert_eq!(GENERIC_ARG0_ID.load(AtOrdering::SeqCst), 0xDA7A);
        // ...and the host's out-pointer write must be what the thunk returns.
        assert_eq!(out, AutoRet(0x2222));
        clear_all_slots();
    }
    #[test]
    fn thunk_prefers_the_per_kind_invoker_over_the_generic_one() {
        let _g = lock_slots();
        clear_all_slots();
        reset_invoker_recorders();
        AzApp_setGenericInvoker(recording_generic);
        az_autotest_set_invoker(recording_perkind);
        let ctx = OptionRefAny::Some(host_handle_to_refany(0x99));
        let out = az_autotest_thunk(RefAny::new(1u32), info_with_ctx(ctx));
        assert_eq!(out, AutoRet(0x1111));
        assert_eq!(PERKIND_CALLS.load(AtOrdering::SeqCst), 1);
        assert_eq!(PERKIND_HANDLE.load(AtOrdering::SeqCst), 0x99);
        assert_eq!(
            GENERIC_CALLS.load(AtOrdering::SeqCst),
            0,
            "generic is a fallback only — it must not also fire"
        );
        clear_all_slots();
    }
    #[test]
    fn thunk_returns_default_when_the_host_ignores_the_out_pointer() {
        let _g = lock_slots();
        clear_all_slots();
        reset_invoker_recorders();
        az_autotest_set_invoker(silent_perkind);
        // A buggy host invoker that never writes `out` must leave us with the
        // pre-filled default, not uninitialised memory.
        let ctx = OptionRefAny::Some(host_handle_to_refany(3));
        let out = az_autotest_thunk(RefAny::new(1u32), info_with_ctx(ctx));
        assert_eq!(PERKIND_CALLS.load(AtOrdering::SeqCst), 1);
        assert_eq!(out, DEFAULT_RET);
        clear_all_slots();
    }
    #[test]
    fn thunk_dispatches_boundary_handles_without_truncation() {
        let _g = lock_slots();
        clear_all_slots();
        reset_invoker_recorders();
        az_autotest_set_invoker(recording_perkind);
        for id in BOUNDARY_IDS {
            let ctx = OptionRefAny::Some(host_handle_to_refany(id));
            let out = az_autotest_thunk(RefAny::new(1u32), info_with_ctx(ctx));
            assert_eq!(out, AutoRet(0x1111));
            assert_eq!(
                PERKIND_HANDLE.load(AtOrdering::SeqCst),
                id,
                "handle {id:#x} must reach the host invoker unmangled"
            );
        }
        clear_all_slots();
    }
}