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}