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